From 782ab3e05a2d8bbcf26b32bee6d269788cf28f7a Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 16 Dec 2025 00:21:13 +0000 Subject: [PATCH 1/2] stash implmented create cmint fix tests refactor: remove all spl code from ctoken program test: functional test for primitive cmint creation add compressibility update tests test: functional create cmint for existing compressed mint feat: add compress and close cmint test compress and close cmint fix lint and tests stash refactor close cmint when decompressed fix lint chore: dont require mint signer as signer for decompression refactor: anyone can compress and close a cmint rename close cmint -> compress and close cmint feat: add mint to feat: ctoken burn test: burn ctoken, feat: add burn sdk test: mint to ctoken, feat: add mint to sdk fix lint fix: ts sdk naming and tests --- Cargo.lock | 5 +- Cargo.toml | 2 +- .../src/v3/actions/mint-to-compressed.ts | 2 +- js/compressed-token/src/v3/actions/mint-to.ts | 2 +- .../src/v3/instructions/create-mint.ts | 2 +- .../src/v3/instructions/mint-to-compressed.ts | 2 +- .../src/v3/instructions/mint-to-interface.ts | 2 +- .../src/v3/instructions/mint-to.ts | 2 +- .../src/v3/instructions/update-metadata.ts | 3 +- .../src/v3/instructions/update-mint.ts | 4 +- .../src/v3/layout/layout-mint-action.ts | 42 ++- .../src/v3/layout/layout-mint.ts | 16 +- .../tests/e2e/get-mint-interface.test.ts | 32 +- .../tests/unit/layout-mint-action.test.ts | 16 +- .../tests/unit/layout-mint.test.ts | 24 +- js/compressed-token/tests/unit/serde.test.ts | 66 ++-- program-libs/ctoken-interface/src/error.rs | 16 + .../src/instructions/extensions/mod.rs | 5 + .../src/instructions/mint_action/builder.rs | 24 +- .../mint_action/compress_and_close_cmint.rs | 22 ++ .../mint_action/decompress_mint.rs | 22 ++ .../mint_action/instruction_data.rs | 52 +++- .../src/instructions/mint_action/mod.rs | 6 +- .../src/state/mint/compressed_mint.rs | 54 +++- .../ctoken-interface/tests/compressed_mint.rs | 16 +- .../ctoken-interface/tests/hash_tests.rs | 22 +- .../tests/mint_borsh_zero_copy.rs | 6 +- .../compressed-token-test/tests/mint.rs | 6 + .../compressed-token-test/tests/mint/burn.rs | 155 ++++++++++ .../tests/mint/cpi_context.rs | 14 +- .../tests/mint/ctoken_mint_to.rs | 150 +++++++++ .../tests/mint/failing.rs | 8 +- .../tests/mint/functional.rs | 290 +++++++++++++++++- .../tests/transfer2/compress_failing.rs | 4 + .../tests/transfer2/decompress_failing.rs | 2 + .../no_system_program_cpi_failing.rs | 4 + program-tests/utils/src/assert_ctoken_burn.rs | 150 +++++++++ .../utils/src/assert_ctoken_mint_to.rs | 150 +++++++++ program-tests/utils/src/assert_mint_action.rs | 138 +++++++-- .../utils/src/assert_mint_to_compressed.rs | 2 +- program-tests/utils/src/lib.rs | 2 + program-tests/utils/src/mint_assert.rs | 2 +- programs/compressed-token/anchor/src/lib.rs | 39 +++ .../program/docs/instructions/MINT_ACTION.md | 6 +- .../program/src/ctoken_burn.rs | 58 ++++ .../program/src/ctoken_mint_to.rs | 58 ++++ .../program/src/ctoken_transfer.rs | 2 +- .../program/src/extensions/mod.rs | 134 ++++---- .../program/src/extensions/processor.rs | 6 +- programs/compressed-token/program/src/lib.rs | 38 ++- .../program/src/mint_action/accounts.rs | 219 +++++++------ .../actions/compress_and_close_cmint.rs | 148 +++++++++ .../src/mint_action/actions/create_mint.rs | 28 +- .../create_spl_mint/create_mint_account.rs | 88 ------ .../create_spl_mint/create_token_pool.rs | 98 ------ .../actions/create_spl_mint/mod.rs | 7 - .../actions/create_spl_mint/process.rs | 88 ------ .../mint_action/actions/decompress_mint.rs | 221 +++++++++++++ .../src/mint_action/actions/mint_to.rs | 17 +- .../src/mint_action/actions/mint_to_ctoken.rs | 48 --- .../program/src/mint_action/actions/mod.rs | 5 +- .../mint_action/actions/process_actions.rs | 35 ++- .../program/src/mint_action/mint_input.rs | 45 ++- .../program/src/mint_action/mint_output.rs | 138 ++++++++- .../program/src/mint_action/processor.rs | 58 +++- .../src/mint_action/zero_copy_config.rs | 35 ++- .../program/src/shared/compressible_top_up.rs | 125 ++++++++ .../program/src/shared/mod.rs | 1 + .../compressed-token/program/tests/mint.rs | 31 +- .../program/tests/mint_action.rs | 55 +++- .../tests/mint_action_accounts_validation.rs | 4 +- .../v2/create_compressed_mint/instruction.rs | 16 +- .../v2/mint_action/account_metas.rs | 84 ++++- sdk-libs/ctoken-sdk/src/ctoken/burn.rs | 114 +++++++ .../ctoken-sdk/src/ctoken/create_cmint.rs | 4 +- .../ctoken-sdk/src/ctoken/ctoken_mint_to.rs | 114 +++++++ sdk-libs/ctoken-sdk/src/ctoken/mod.rs | 4 + sdk-libs/token-client/Cargo.toml | 1 + .../token-client/src/actions/mint_action.rs | 39 ++- .../src/instructions/mint_action.rs | 140 +++++++-- .../src/instructions/mint_to_compressed.rs | 51 ++- .../instructions/update_compressed_mint.rs | 2 +- sdk-tests/csdk-anchor-derived-test/src/lib.rs | 2 +- .../tests/basic_test.rs | 6 +- .../csdk-anchor-full-derived-test/src/lib.rs | 2 +- .../tests/basic_test.rs | 6 +- .../create_user_record_and_game_session.rs | 2 +- .../tests/multi_account_tests.rs | 6 +- sdk-tests/sdk-ctoken-test/tests/shared.rs | 2 +- .../tests/test_mint_to_ctoken.rs | 4 +- .../sdk-token-test/src/ctoken_pda/mint.rs | 2 +- .../sdk-token-test/src/pda_ctoken/mint.rs | 2 +- sdk-tests/sdk-token-test/tests/ctoken_pda.rs | 6 +- .../tests/decompress_full_cpi.rs | 4 +- sdk-tests/sdk-token-test/tests/pda_ctoken.rs | 6 +- .../sdk-token-test/tests/test_4_transfer2.rs | 4 +- .../tests/test_compress_full_and_close.rs | 4 +- 97 files changed, 3105 insertions(+), 901 deletions(-) create mode 100644 program-libs/ctoken-interface/src/instructions/mint_action/compress_and_close_cmint.rs create mode 100644 program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs create mode 100644 program-tests/compressed-token-test/tests/mint/burn.rs create mode 100644 program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs create mode 100644 program-tests/utils/src/assert_ctoken_burn.rs create mode 100644 program-tests/utils/src/assert_ctoken_mint_to.rs create mode 100644 programs/compressed-token/program/src/ctoken_burn.rs create mode 100644 programs/compressed-token/program/src/ctoken_mint_to.rs create mode 100644 programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs delete mode 100644 programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_mint_account.rs delete mode 100644 programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_token_pool.rs delete mode 100644 programs/compressed-token/program/src/mint_action/actions/create_spl_mint/mod.rs delete mode 100644 programs/compressed-token/program/src/mint_action/actions/create_spl_mint/process.rs create mode 100644 programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs create mode 100644 programs/compressed-token/program/src/shared/compressible_top_up.rs create mode 100644 sdk-libs/ctoken-sdk/src/ctoken/burn.rs create mode 100644 sdk-libs/ctoken-sdk/src/ctoken/ctoken_mint_to.rs diff --git a/Cargo.lock b/Cargo.lock index 2e157da1b5..381470d251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4295,6 +4295,7 @@ dependencies = [ "borsh 0.10.4", "light-client", "light-compressed-account", + "light-compressible", "light-ctoken-interface", "light-ctoken-sdk", "light-ctoken-types", @@ -5097,7 +5098,7 @@ dependencies = [ [[package]] name = "pinocchio-token-interface" version = "0.0.0" -source = "git+https://github.com/Lightprotocol/token?rev=fc39a485cf1b214e16a0f13fd6ddea0cd87aaa87#fc39a485cf1b214e16a0f13fd6ddea0cd87aaa87" +source = "git+https://github.com/Lightprotocol/token?rev=5d2768b98075ba03dfc5d6e6dd8567ba065c84ba#5d2768b98075ba03dfc5d6e6dd8567ba065c84ba" dependencies = [ "pinocchio", "pinocchio-pubkey", @@ -5106,7 +5107,7 @@ dependencies = [ [[package]] name = "pinocchio-token-program" version = "0.1.0" -source = "git+https://github.com/Lightprotocol/token?rev=fc39a485cf1b214e16a0f13fd6ddea0cd87aaa87#fc39a485cf1b214e16a0f13fd6ddea0cd87aaa87" +source = "git+https://github.com/Lightprotocol/token?rev=5d2768b98075ba03dfc5d6e6dd8567ba065c84ba#5d2768b98075ba03dfc5d6e6dd8567ba065c84ba" dependencies = [ "pinocchio", "pinocchio-log", diff --git a/Cargo.toml b/Cargo.toml index d562fcce98..c2a938bf13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -229,7 +229,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="fc39a485cf1b214e16a0f13fd6ddea0cd87aaa87" } +pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="5d2768b98075ba03dfc5d6e6dd8567ba065c84ba" } # Math and crypto num-bigint = "0.4.6" tabled = "0.20" diff --git a/js/compressed-token/src/v3/actions/mint-to-compressed.ts b/js/compressed-token/src/v3/actions/mint-to-compressed.ts index 2e6b7367fd..db09d5bddc 100644 --- a/js/compressed-token/src/v3/actions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/actions/mint-to-compressed.ts @@ -81,7 +81,7 @@ export async function mintToCompressed( mintAuthority: mintInfo.mint.mintAuthority, freezeAuthority: mintInfo.mint.freezeAuthority, splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, + cmintDecompressed: mintInfo.mintContext!.cmintDecompressed, version: mintInfo.mintContext!.version, metadata: mintInfo.tokenMetadata ? { diff --git a/js/compressed-token/src/v3/actions/mint-to.ts b/js/compressed-token/src/v3/actions/mint-to.ts index 480541e0f7..70238a8ee6 100644 --- a/js/compressed-token/src/v3/actions/mint-to.ts +++ b/js/compressed-token/src/v3/actions/mint-to.ts @@ -77,7 +77,7 @@ export async function mintTo( mintAuthority: mintInfo.mint.mintAuthority, freezeAuthority: mintInfo.mint.freezeAuthority, splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, + cmintDecompressed: mintInfo.mintContext!.cmintDecompressed, version: mintInfo.mintContext!.version, metadata: mintInfo.tokenMetadata ? { diff --git a/js/compressed-token/src/v3/instructions/create-mint.ts b/js/compressed-token/src/v3/instructions/create-mint.ts index 56e3b32b6b..df737eb7b0 100644 --- a/js/compressed-token/src/v3/instructions/create-mint.ts +++ b/js/compressed-token/src/v3/instructions/create-mint.ts @@ -142,7 +142,7 @@ export function encodeCreateMintInstructionData( decimals: params.decimals, metadata: { version: TokenDataVersion.ShaFlat, - splMintInitialized: false, + cmintDecompressed: false, mint: splMintPda, }, mintAuthority: params.mintAuthority, diff --git a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts index c6ab187f77..61bae4e274 100644 --- a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts @@ -76,7 +76,7 @@ function encodeCompressedMintToInstructionData( decimals: params.mintData.decimals, metadata: { version: params.mintData.version, - splMintInitialized: params.mintData.splMintInitialized, + cmintDecompressed: params.mintData.cmintDecompressed, mint: params.mintData.splMint, }, mintAuthority: params.mintData.mintAuthority, diff --git a/js/compressed-token/src/v3/instructions/mint-to-interface.ts b/js/compressed-token/src/v3/instructions/mint-to-interface.ts index 01eaa3c6e7..004ab494b3 100644 --- a/js/compressed-token/src/v3/instructions/mint-to-interface.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-interface.ts @@ -67,7 +67,7 @@ export function createMintToInterfaceInstruction( mintAuthority: mintInterface.mint.mintAuthority, freezeAuthority: mintInterface.mint.freezeAuthority, splMint: mintInterface.mintContext.splMint, - splMintInitialized: mintInterface.mintContext.splMintInitialized, + cmintDecompressed: mintInterface.mintContext.cmintDecompressed, version: mintInterface.mintContext.version, metadata: mintInterface.tokenMetadata ? { diff --git a/js/compressed-token/src/v3/instructions/mint-to.ts b/js/compressed-token/src/v3/instructions/mint-to.ts index c4ca8ca0d4..56b5aef32d 100644 --- a/js/compressed-token/src/v3/instructions/mint-to.ts +++ b/js/compressed-token/src/v3/instructions/mint-to.ts @@ -71,7 +71,7 @@ function encodeMintToCTokenInstructionData( decimals: params.mintData.decimals, metadata: { version: params.mintData.version, - splMintInitialized: params.mintData.splMintInitialized, + cmintDecompressed: params.mintData.cmintDecompressed, mint: params.mintData.splMint, }, mintAuthority: params.mintData.mintAuthority, diff --git a/js/compressed-token/src/v3/instructions/update-metadata.ts b/js/compressed-token/src/v3/instructions/update-metadata.ts index ad41b6295f..5ceee40814 100644 --- a/js/compressed-token/src/v3/instructions/update-metadata.ts +++ b/js/compressed-token/src/v3/instructions/update-metadata.ts @@ -113,8 +113,7 @@ function encodeUpdateMetadataInstructionData( decimals: mintInterface.mint.decimals, metadata: { version: mintInterface.mintContext!.version, - splMintInitialized: - mintInterface.mintContext!.splMintInitialized, + cmintDecompressed: mintInterface.mintContext!.cmintDecompressed, mint: mintInterface.mintContext!.splMint, }, mintAuthority: mintInterface.mint.mintAuthority, diff --git a/js/compressed-token/src/v3/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts index 6891bd5c5b..7e09096911 100644 --- a/js/compressed-token/src/v3/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -86,8 +86,8 @@ function encodeUpdateMintInstructionData( decimals: params.mintInterface.mint.decimals, metadata: { version: params.mintInterface.mintContext!.version, - splMintInitialized: - params.mintInterface.mintContext!.splMintInitialized, + cmintDecompressed: + params.mintInterface.mintContext!.cmintDecompressed, mint: params.mintInterface.mintContext!.splMint, }, mintAuthority: params.mintInterface.mint.mintAuthority, diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index c74aaa5f31..4c2c8ce53d 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -62,6 +62,14 @@ export const RemoveMetadataKeyActionLayout = struct([ u8('idempotent'), ]); +export const DecompressMintActionLayout = struct([ + u8('cmintBump'), + u8('rentPayment'), + u32('writeTopUp'), +]); + +export const CompressAndCloseCMintActionLayout = struct([u8('idempotent')]); + export const ActionLayout = rustEnum([ MintToCompressedActionLayout.replicate('mintToCompressed'), UpdateAuthorityLayout.replicate('updateMintAuthority'), @@ -71,6 +79,8 @@ export const ActionLayout = rustEnum([ UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), RemoveMetadataKeyActionLayout.replicate('removeMetadataKey'), + DecompressMintActionLayout.replicate('decompressMint'), + CompressAndCloseCMintActionLayout.replicate('compressAndCloseCMint'), ]); export const CompressedProofLayout = struct([ @@ -133,7 +143,7 @@ export const ExtensionInstructionDataLayout = rustEnum([ export const CompressedMintMetadataLayout = struct([ u8('version'), - bool('splMintInitialized'), + bool('cmintDecompressed'), publicKey('mint'), ]); @@ -158,7 +168,7 @@ export const MintActionCompressedInstructionDataLayout = struct([ vec(ActionLayout, 'actions'), option(CompressedProofLayout, 'proof'), option(CpiContextLayout, 'cpiContext'), - CompressedMintInstructionDataLayout.replicate('mint'), + option(CompressedMintInstructionDataLayout, 'mint'), ]); export interface ValidityProof { @@ -208,6 +218,16 @@ export interface RemoveMetadataKeyAction { idempotent: number; } +export interface DecompressMintAction { + cmintBump: number; + rentPayment: number; + writeTopUp: number; +} + +export interface CompressAndCloseCMintAction { + idempotent: number; +} + export type Action = | { mintToCompressed: MintToCompressedAction } | { updateMintAuthority: UpdateAuthority } @@ -216,7 +236,9 @@ export type Action = | { mintToCToken: MintToCTokenAction } | { updateMetadataField: UpdateMetadataFieldAction } | { updateMetadataAuthority: UpdateMetadataAuthorityAction } - | { removeMetadataKey: RemoveMetadataKeyAction }; + | { removeMetadataKey: RemoveMetadataKeyAction } + | { decompressMint: DecompressMintAction } + | { compressAndCloseCMint: CompressAndCloseCMintAction }; export interface CpiContext { setContext: boolean; @@ -254,7 +276,7 @@ export type ExtensionInstructionData = { export interface CompressedMintMetadata { version: number; - splMintInitialized: boolean; + cmintDecompressed: boolean; mint: PublicKey; } @@ -279,7 +301,7 @@ export interface MintActionCompressedInstructionData { actions: Action[]; proof: ValidityProof | null; cpiContext: CpiContext | null; - mint: CompressedMintInstructionData; + mint: CompressedMintInstructionData | null; } /** @@ -294,10 +316,12 @@ export function encodeMintActionInstructionData( // Convert bigint fields to BN for Borsh encoding const encodableData = { ...data, - mint: { - ...data.mint, - supply: bn(data.mint.supply.toString()), - }, + mint: data.mint + ? { + ...data.mint, + supply: bn(data.mint.supply.toString()), + } + : null, actions: data.actions.map(action => { // Handle MintToCompressed action with recipients if ('mintToCompressed' in action && action.mintToCompressed) { diff --git a/js/compressed-token/src/v3/layout/layout-mint.ts b/js/compressed-token/src/v3/layout/layout-mint.ts index 82e39c4938..eea2f927fc 100644 --- a/js/compressed-token/src/v3/layout/layout-mint.ts +++ b/js/compressed-token/src/v3/layout/layout-mint.ts @@ -33,8 +33,8 @@ export interface BaseMint { export interface MintContext { /** Protocol version for upgradability */ version: number; - /** Whether the associated SPL mint is initialized */ - splMintInitialized: boolean; + /** Whether the compressed mint is decompressed to a CMint Solana account */ + cmintDecompressed: boolean; /** PDA of the associated SPL mint */ splMint: PublicKey; } @@ -91,14 +91,14 @@ export interface CompressedMint { /** MintContext as stored by the program */ export interface RawMintContext { version: number; - splMintInitialized: number; // bool as u8 + cmintDecompressed: number; // bool as u8 splMint: PublicKey; } /** Buffer layout for de/serializing MintContext */ export const MintContextLayout = struct([ u8('version'), - u8('splMintInitialized'), + u8('cmintDecompressed'), publicKey('splMint'), ]); @@ -231,7 +231,7 @@ export function deserializeMint(data: Buffer | Uint8Array): CompressedMint { const mintContext: MintContext = { version: rawContext.version, - splMintInitialized: rawContext.splMintInitialized !== 0, + cmintDecompressed: rawContext.cmintDecompressed !== 0, splMint: rawContext.splMint, }; @@ -275,7 +275,7 @@ export function serializeMint(mint: CompressedMint): Buffer { MintContextLayout.encode( { version: mint.mintContext.version, - splMintInitialized: mint.mintContext.splMintInitialized ? 1 : 0, + cmintDecompressed: mint.mintContext.cmintDecompressed ? 1 : 0, splMint: mint.mintContext.splMint, }, contextBuffer, @@ -441,7 +441,7 @@ export interface MintInstructionData { mintAuthority: PublicKey | null; freezeAuthority: PublicKey | null; splMint: PublicKey; - splMintInitialized: boolean; + cmintDecompressed: boolean; version: number; metadata?: MintMetadataField; } @@ -483,7 +483,7 @@ export function toMintInstructionData( mintAuthority: base.mintAuthority, freezeAuthority: base.freezeAuthority, splMint: mintContext.splMint, - splMintInitialized: mintContext.splMintInitialized, + cmintDecompressed: mintContext.cmintDecompressed, version: mintContext.version, metadata, }; diff --git a/js/compressed-token/tests/e2e/get-mint-interface.test.ts b/js/compressed-token/tests/e2e/get-mint-interface.test.ts index 6571022e92..c06745e8ca 100644 --- a/js/compressed-token/tests/e2e/get-mint-interface.test.ts +++ b/js/compressed-token/tests/e2e/get-mint-interface.test.ts @@ -378,7 +378,7 @@ describe('getMintInterface', () => { expect(result.mintContext).toBeDefined(); expect(result.mintContext!.version).toBeDefined(); - expect(typeof result.mintContext!.splMintInitialized).toBe( + expect(typeof result.mintContext!.cmintDecompressed).toBe( 'boolean', ); expect(result.mintContext!.splMint).toBeInstanceOf(PublicKey); @@ -561,7 +561,7 @@ describe('unpackMintInterface', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -587,7 +587,7 @@ describe('unpackMintInterface', () => { ); expect(result.mintContext).toBeDefined(); expect(result.mintContext!.version).toBe(1); - expect(result.mintContext!.splMintInitialized).toBe(true); + expect(result.mintContext!.cmintDecompressed).toBe(true); expect(result.mintContext!.splMint.toBase58()).toBe( splMint.toBase58(), ); @@ -618,7 +618,7 @@ describe('unpackMintInterface', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint, }, extensions: [ @@ -663,7 +663,7 @@ describe('unpackMintInterface', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -693,7 +693,7 @@ describe('unpackMintInterface', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -760,7 +760,7 @@ describe('unpackMintData', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -771,7 +771,7 @@ describe('unpackMintData', () => { expect(result.mintContext).toBeDefined(); expect(result.mintContext.version).toBe(1); - expect(result.mintContext.splMintInitialized).toBe(true); + expect(result.mintContext.cmintDecompressed).toBe(true); expect(result.mintContext.splMint.toBase58()).toBe(splMint.toBase58()); expect(result.tokenMetadata).toBeUndefined(); expect(result.extensions).toBeUndefined(); @@ -800,7 +800,7 @@ describe('unpackMintData', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ @@ -851,7 +851,7 @@ describe('unpackMintData', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ @@ -883,7 +883,7 @@ describe('unpackMintData', () => { }, mintContext: { version: 2, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -910,7 +910,7 @@ describe('unpackMintData', () => { }, mintContext: { version, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -923,7 +923,7 @@ describe('unpackMintData', () => { }); }); - it('should handle splMintInitialized boolean correctly', () => { + it('should handle cmintDecompressed boolean correctly', () => { [true, false].forEach(initialized => { const compressedMint: CompressedMint = { base: { @@ -935,7 +935,7 @@ describe('unpackMintData', () => { }, mintContext: { version: 1, - splMintInitialized: initialized, + cmintDecompressed: initialized, splMint: PublicKey.default, }, extensions: null, @@ -944,7 +944,7 @@ describe('unpackMintData', () => { const buffer = serializeMint(compressedMint); const result = unpackMintData(buffer); - expect(result.mintContext.splMintInitialized).toBe(initialized); + expect(result.mintContext.cmintDecompressed).toBe(initialized); }); }); @@ -968,7 +968,7 @@ describe('unpackMintData', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ diff --git a/js/compressed-token/tests/unit/layout-mint-action.test.ts b/js/compressed-token/tests/unit/layout-mint-action.test.ts index 8f336faa67..ff323f41da 100644 --- a/js/compressed-token/tests/unit/layout-mint-action.test.ts +++ b/js/compressed-token/tests/unit/layout-mint-action.test.ts @@ -30,7 +30,7 @@ describe('layout-mint-action', () => { decimals: 9, metadata: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, mint, }, mintAuthority: mint, @@ -87,7 +87,7 @@ describe('layout-mint-action', () => { decimals: 6, metadata: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, mint, }, mintAuthority: mint, @@ -139,7 +139,7 @@ describe('layout-mint-action', () => { decimals: 9, metadata: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, mint, }, mintAuthority: mint, @@ -187,7 +187,7 @@ describe('layout-mint-action', () => { decimals: 6, metadata: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, mint, }, mintAuthority: mint, @@ -236,7 +236,7 @@ describe('layout-mint-action', () => { decimals: 9, metadata: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, mint, }, mintAuthority: mint, @@ -294,7 +294,7 @@ describe('layout-mint-action', () => { decimals: 9, metadata: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, mint, }, mintAuthority: mint, @@ -330,7 +330,7 @@ describe('layout-mint-action', () => { decimals: 9, metadata: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, mint, }, mintAuthority: mint, @@ -376,7 +376,7 @@ describe('layout-mint-action', () => { decimals: 9, metadata: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, mint, }, mintAuthority: mint, diff --git a/js/compressed-token/tests/unit/layout-mint.test.ts b/js/compressed-token/tests/unit/layout-mint.test.ts index 05af92da6c..0079281c7b 100644 --- a/js/compressed-token/tests/unit/layout-mint.test.ts +++ b/js/compressed-token/tests/unit/layout-mint.test.ts @@ -32,7 +32,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -49,7 +49,7 @@ describe('layout-mint', () => { expect(deserialized.base.isInitialized).toBe(true); expect(deserialized.base.freezeAuthority).toBe(null); expect(deserialized.mintContext.version).toBe(1); - expect(deserialized.mintContext.splMintInitialized).toBe(true); + expect(deserialized.mintContext.cmintDecompressed).toBe(true); expect(deserialized.mintContext.splMint.toBase58()).toBe( splMint.toBase58(), ); @@ -71,7 +71,7 @@ describe('layout-mint', () => { }, mintContext: { version: 0, - splMintInitialized: false, + cmintDecompressed: false, splMint, }, extensions: null, @@ -83,7 +83,7 @@ describe('layout-mint', () => { expect(deserialized.base.freezeAuthority?.toBase58()).toBe( freezeAuthority.toBase58(), ); - expect(deserialized.mintContext.splMintInitialized).toBe(false); + expect(deserialized.mintContext.cmintDecompressed).toBe(false); }); it('should handle null mintAuthority', () => { @@ -99,7 +99,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -138,7 +138,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: [ @@ -173,7 +173,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -353,7 +353,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -368,7 +368,7 @@ describe('layout-mint', () => { ); expect(instructionData.freezeAuthority).toBe(null); expect(instructionData.splMint.toBase58()).toBe(splMint.toBase58()); - expect(instructionData.splMintInitialized).toBe(true); + expect(instructionData.cmintDecompressed).toBe(true); expect(instructionData.version).toBe(1); expect(instructionData.metadata).toBeUndefined(); }); @@ -396,7 +396,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: [ @@ -442,7 +442,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: [ @@ -474,7 +474,7 @@ describe('layout-mint', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, diff --git a/js/compressed-token/tests/unit/serde.test.ts b/js/compressed-token/tests/unit/serde.test.ts index a4f8c0a8d2..b4ff99e111 100644 --- a/js/compressed-token/tests/unit/serde.test.ts +++ b/js/compressed-token/tests/unit/serde.test.ts @@ -46,7 +46,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -64,7 +64,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint: Keypair.generate().publicKey, }, extensions: null, @@ -82,7 +82,7 @@ describe('serde', () => { }, mintContext: { version: 0, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -100,7 +100,7 @@ describe('serde', () => { }, mintContext: { version: 255, - splMintInitialized: true, + cmintDecompressed: true, splMint: Keypair.generate().publicKey, }, extensions: null, @@ -118,7 +118,7 @@ describe('serde', () => { }, mintContext: { version: 0, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -158,8 +158,8 @@ describe('serde', () => { expect(deserialized.mintContext.version).toBe( mint.mintContext.version, ); - expect(deserialized.mintContext.splMintInitialized).toBe( - mint.mintContext.splMintInitialized, + expect(deserialized.mintContext.cmintDecompressed).toBe( + mint.mintContext.cmintDecompressed, ); expect(deserialized.mintContext.splMint.toBase58()).toBe( mint.mintContext.splMint.toBase58(), @@ -181,7 +181,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -206,7 +206,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ @@ -239,7 +239,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ @@ -268,7 +268,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [], @@ -497,7 +497,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -525,7 +525,7 @@ describe('serde', () => { }, mintContext: { version, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -538,7 +538,7 @@ describe('serde', () => { }); }); - it('should correctly parse splMintInitialized boolean', () => { + it('should correctly parse cmintDecompressed boolean', () => { [true, false].forEach(initialized => { const mint: CompressedMint = { base: { @@ -550,7 +550,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: initialized, + cmintDecompressed: initialized, splMint: PublicKey.default, }, extensions: null, @@ -559,7 +559,7 @@ describe('serde', () => { const serialized = serializeMint(mint); const deserialized = deserializeMint(serialized); - expect(deserialized.mintContext.splMintInitialized).toBe( + expect(deserialized.mintContext.cmintDecompressed).toBe( initialized, ); }); @@ -586,7 +586,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint: pubkey, }, extensions: null, @@ -620,7 +620,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -650,7 +650,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -677,7 +677,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -712,7 +712,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint: Keypair.generate().publicKey, }, extensions: [ @@ -758,7 +758,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -841,7 +841,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ @@ -1040,7 +1040,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint, }, extensions: null, @@ -1055,7 +1055,7 @@ describe('serde', () => { ); expect(result.freezeAuthority).toBeNull(); expect(result.splMint.toBase58()).toBe(splMint.toBase58()); - expect(result.splMintInitialized).toBe(true); + expect(result.cmintDecompressed).toBe(true); expect(result.version).toBe(1); expect(result.metadata).toBeUndefined(); }); @@ -1082,7 +1082,7 @@ describe('serde', () => { }, mintContext: { version: 2, - splMintInitialized: false, + cmintDecompressed: false, splMint, }, extensions: [ @@ -1120,7 +1120,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [], @@ -1148,7 +1148,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [ @@ -1185,7 +1185,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: true, + cmintDecompressed: true, splMint: Keypair.generate().publicKey, }, extensions: [ @@ -1215,7 +1215,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: null, @@ -1237,7 +1237,7 @@ describe('serde', () => { }, mintContext: { version: 1, - splMintInitialized: false, + cmintDecompressed: false, splMint: PublicKey.default, }, extensions: [], @@ -1257,7 +1257,7 @@ describe('serde', () => { mintAuthority: null, freezeAuthority: null, splMint: PublicKey.default, - splMintInitialized: false, + cmintDecompressed: false, version: 1, }; @@ -1273,7 +1273,7 @@ describe('serde', () => { mintAuthority: null, freezeAuthority: null, splMint: PublicKey.default, - splMintInitialized: false, + cmintDecompressed: false, version: 1, metadata: { updateAuthority: null, diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index 1e88755f2d..188f336091 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -135,6 +135,18 @@ pub enum CTokenError { #[error("Calculated top-up exceeds sender's max_top_up limit")] MaxTopUpExceeded, + + #[error("CMint account has invalid owner")] + InvalidCMintOwner, + + #[error("CMint account is not initialized")] + CMintNotInitialized, + + #[error("Failed to borrow CMint account data")] + CMintBorrowFailed, + + #[error("Failed to deserialize CMint account data")] + CMintDeserializationFailed, } impl From for u32 { @@ -183,6 +195,10 @@ impl From for u32 { CTokenError::TooManySeeds(_) => 18041, CTokenError::WriteTopUpExceedsMaximum => 18042, CTokenError::MaxTopUpExceeded => 18043, + CTokenError::InvalidCMintOwner => 18044, + CTokenError::CMintNotInitialized => 18045, + CTokenError::CMintBorrowFailed => 18046, + CTokenError::CMintDeserializationFailed => 18047, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/ctoken-interface/src/instructions/extensions/mod.rs b/program-libs/ctoken-interface/src/instructions/extensions/mod.rs index 2740b942a1..dc05a512d2 100644 --- a/program-libs/ctoken-interface/src/instructions/extensions/mod.rs +++ b/program-libs/ctoken-interface/src/instructions/extensions/mod.rs @@ -1,5 +1,7 @@ pub mod compressible; pub mod token_metadata; +pub use compressible::{CompressToPubkey, CompressibleExtensionInstructionData}; +use light_compressible::compression_info::CompressionInfo; use light_zero_copy::ZeroCopy; pub use token_metadata::{TokenMetadataInstructionData, ZTokenMetadataInstructionData}; @@ -43,4 +45,7 @@ pub enum ExtensionInstructionData { Placeholder30, /// Reserved for CompressedOnly extension Placeholder31, + /// Compressible extension - reuses CompressionInfo from light_compressible + /// Position 32 matches ExtensionStruct::Compressible + Compressible(CompressionInfo), } diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs b/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs index 11bbd9d77a..31c9202468 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs @@ -4,10 +4,10 @@ use light_compressed_account::instruction_data::{ }; use crate::instructions::mint_action::{ - Action, CompressedMintInstructionData, CompressedMintWithContext, CpiContext, CreateMint, - MintActionCompressedInstructionData, MintToCTokenAction, MintToCompressedAction, - RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, - UpdateMetadataFieldAction, + Action, CompressAndCloseCMintAction, CompressedMintInstructionData, CompressedMintWithContext, + CpiContext, CreateMint, DecompressMintAction, MintActionCompressedInstructionData, + MintToCTokenAction, MintToCompressedAction, RemoveMetadataKeyAction, UpdateAuthority, + UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, }; /// Discriminator for MintAction instruction @@ -60,7 +60,7 @@ impl MintActionCompressedInstructionData { actions: Vec::new(), proof: Some(proof), cpi_context: None, - mint, + mint: Some(mint), } } @@ -82,7 +82,7 @@ impl MintActionCompressedInstructionData { actions: Vec::new(), proof: None, // Proof is verified with execution not write cpi_context: Some(cpi_context), - mint, + mint: Some(mint), } } @@ -128,6 +128,18 @@ impl MintActionCompressedInstructionData { self } + #[must_use = "with_decompress_mint returns a new value"] + pub fn with_decompress_mint(mut self, action: DecompressMintAction) -> Self { + self.actions.push(Action::DecompressMint(action)); + self + } + + #[must_use = "with_compress_and_close_cmint returns a new value"] + pub fn with_compress_and_close_cmint(mut self, action: CompressAndCloseCMintAction) -> Self { + self.actions.push(Action::CompressAndCloseCMint(action)); + self + } + #[must_use = "with_cpi_context returns a new value"] pub fn with_cpi_context(mut self, cpi_context: CpiContext) -> Self { self.cpi_context = Some(cpi_context); diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/compress_and_close_cmint.rs b/program-libs/ctoken-interface/src/instructions/mint_action/compress_and_close_cmint.rs new file mode 100644 index 0000000000..a5b1e1dbdc --- /dev/null +++ b/program-libs/ctoken-interface/src/instructions/mint_action/compress_and_close_cmint.rs @@ -0,0 +1,22 @@ +use light_zero_copy::ZeroCopy; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Action to compress and close a CMint Solana account. +/// The compressed mint state is always preserved. +/// +/// ## Requirements +/// - CMint must exist (cmint_decompressed = true) - unless idempotent is set +/// - CMint must have Compressible extension +/// - is_compressible() must return true (rent expired) +/// - Cannot be combined with DecompressMint in same instruction +/// +/// ## Note +/// CompressAndCloseCMint is **permissionless** - anyone can compress and close a CMint +/// provided is_compressible() returns true. All lamports are returned to rent_sponsor. +#[repr(C)] +#[derive(Debug, Clone, Copy, AnchorSerialize, AnchorDeserialize, ZeroCopy)] +pub struct CompressAndCloseCMintAction { + /// If non-zero, succeed silently when CMint doesn't exist (cmint_decompressed = false) + pub idempotent: u8, +} diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs new file mode 100644 index 0000000000..3a64546250 --- /dev/null +++ b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs @@ -0,0 +1,22 @@ +use light_zero_copy::ZeroCopy; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Action to decompress a compressed mint to a CMint Solana account. +/// Creates a CMint PDA that becomes the source of truth for the mint state. +/// +/// CMint is ALWAYS compressible - `rent_payment` must be >= 2. +/// rent_payment == 0 or 1 is rejected (epoch boundary edge case). +#[repr(C)] +#[derive(Debug, Clone, Copy, AnchorSerialize, AnchorDeserialize, ZeroCopy)] +pub struct DecompressMintAction { + /// PDA bump for CMint account verification + pub cmint_bump: u8, + /// Rent payment in epochs (prepaid). REQUIRED field. + /// CMint is ALWAYS compressible - must be >= 2. + /// NOTE: rent_payment == 0 or 1 is REJECTED. + pub rent_payment: u8, + /// Lamports allocated for future write operations (top-up per write). + /// Must not exceed config.rent_config.max_top_up. + pub write_top_up: u32, +} diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs index 30d45d67ad..7737d168d6 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs @@ -2,9 +2,9 @@ use light_compressed_account::{instruction_data::compressed_proof::CompressedPro use light_zero_copy::ZeroCopy; use super::{ - CpiContext, CreateSplMintAction, MintToCTokenAction, MintToCompressedAction, - RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, - UpdateMetadataFieldAction, + CompressAndCloseCMintAction, CpiContext, CreateSplMintAction, DecompressMintAction, + MintToCTokenAction, MintToCompressedAction, RemoveMetadataKeyAction, UpdateAuthority, + UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, }; use crate::{ instructions::extensions::{ExtensionInstructionData, ZExtensionInstructionData}, @@ -35,6 +35,12 @@ pub enum Action { UpdateMetadataField(UpdateMetadataFieldAction), UpdateMetadataAuthority(UpdateMetadataAuthorityAction), RemoveMetadataKey(RemoveMetadataKeyAction), + /// Decompress a compressed mint to a CMint Solana account. + /// Creates a CMint PDA that becomes the source of truth. + DecompressMint(DecompressMintAction), + /// Compress and close a CMint Solana account. The compressed mint state is preserved. + /// Permissionless - anyone can call if is_compressible() returns true (rent expired). + CompressAndCloseCMint(CompressAndCloseCMintAction), } #[repr(C)] @@ -63,7 +69,7 @@ pub struct MintActionCompressedInstructionData { pub actions: Vec, pub proof: Option, pub cpi_context: Option, - pub mint: CompressedMintInstructionData, + pub mint: Option, } #[repr(C)] @@ -84,7 +90,7 @@ pub struct CompressedMintWithContext { pub prove_by_index: bool, pub root_index: u16, pub address: [u8; 32], - pub mint: CompressedMintInstructionData, + pub mint: Option, } #[repr(C)] @@ -124,10 +130,12 @@ impl TryFrom for CompressedMintInstructionData { symbol: token_metadata.symbol, uri: token_metadata.uri, additional_metadata: Some(token_metadata.additional_metadata), - }, )) } + ExtensionStruct::Compressible(compression_info) => { + Ok(ExtensionInstructionData::Compressible(compression_info)) + } _ => { Err(CTokenError::UnsupportedExtension) } @@ -184,6 +192,36 @@ impl<'a> TryFrom<&ZCompressedMintInstructionData<'a>> for CompressedMint { .unwrap_or_else(Vec::new), })) } + ZExtensionInstructionData::Compressible(compression_info) => { + // Convert zero-copy CompressionInfo to owned CompressionInfo + Ok(ExtensionStruct::Compressible( + light_compressible::compression_info::CompressionInfo { + config_account_version: compression_info + .config_account_version + .into(), + compress_to_pubkey: compression_info.compress_to_pubkey, + account_version: compression_info.account_version, + lamports_per_write: compression_info.lamports_per_write.into(), + compression_authority: compression_info.compression_authority, + rent_sponsor: compression_info.rent_sponsor, + last_claimed_slot: compression_info.last_claimed_slot.into(), + rent_config: light_compressible::rent::RentConfig { + base_rent: compression_info.rent_config.base_rent.into(), + compression_cost: compression_info + .rent_config + .compression_cost + .into(), + lamports_per_byte_per_epoch: compression_info + .rent_config + .lamports_per_byte_per_epoch, + max_funded_epochs: compression_info + .rent_config + .max_funded_epochs, + max_top_up: compression_info.rent_config.max_top_up.into(), + }, + }, + )) + } _ => Err(CTokenError::UnsupportedExtension), }) .collect(); @@ -202,7 +240,7 @@ impl<'a> TryFrom<&ZCompressedMintInstructionData<'a>> for CompressedMint { }, metadata: CompressedMintMetadata { version: instruction_data.metadata.version, - spl_mint_initialized: instruction_data.metadata.spl_mint_initialized(), + cmint_decompressed: instruction_data.metadata.cmint_decompressed(), mint: instruction_data.metadata.mint, }, extensions, diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs b/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs index cc3282f313..d9e4de2a52 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs @@ -1,16 +1,20 @@ +mod builder; +mod compress_and_close_cmint; mod cpi_context; mod create_spl_mint; +mod decompress_mint; mod instruction_data; mod mint_to_compressed; mod mint_to_ctoken; mod update_metadata; mod update_mint; +pub use compress_and_close_cmint::*; pub use cpi_context::*; pub use create_spl_mint::*; +pub use decompress_mint::*; pub use instruction_data::*; pub use mint_to_compressed::*; pub use mint_to_ctoken::*; pub use update_metadata::*; pub use update_mint::*; -mod builder; diff --git a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs index bc3e5e7ae5..53d3e137e8 100644 --- a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs +++ b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs @@ -12,7 +12,9 @@ use crate::{ }; #[repr(C)] -#[derive(Debug, PartialEq, Eq, Clone, BorshSerialize, BorshDeserialize, ZeroCopyMut, ZeroCopy)] +#[derive( + Debug, PartialEq, Default, Eq, Clone, BorshSerialize, BorshDeserialize, ZeroCopyMut, ZeroCopy, +)] pub struct CompressedMint { pub base: BaseMint, pub metadata: CompressedMintMetadata, @@ -22,7 +24,7 @@ pub struct CompressedMint { // and subsequent deserialization for remaining data (compression metadata + extensions) /// SPL-compatible base mint structure with padding for COption alignment #[repr(C)] -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Eq, Clone)] pub struct BaseMint { /// Optional authority used to mint new tokens. The mint authority may only /// be provided during mint creation. If no mint authority is present @@ -42,13 +44,14 @@ pub struct BaseMint { /// Light Protocol-specific metadata for compressed mints #[repr(C)] #[derive( - Debug, PartialEq, Eq, Clone, AnchorDeserialize, AnchorSerialize, ZeroCopyMut, ZeroCopy, + Debug, Default, PartialEq, Eq, Clone, AnchorDeserialize, AnchorSerialize, ZeroCopyMut, ZeroCopy, )] pub struct CompressedMintMetadata { /// Version for upgradability pub version: u8, - /// Extension, necessary for mint to. - pub spl_mint_initialized: bool, + /// Whether the compressed mint has been decompressed to a CMint Solana account. + /// When true, the CMint account is the source of truth. + pub cmint_decompressed: bool, /// Pda with seed address of compressed mint pub mint: Pubkey, } @@ -64,6 +67,43 @@ impl CompressedMint { _ => Err(CTokenError::InvalidTokenDataVersion), } } + + /// Deserialize a CompressedMint from a CMint Solana account with validation. + /// + /// Checks: + /// 1. Account is owned by the specified program + /// 2. Account is initialized (BaseMint.is_initialized == true) + /// + /// Note: CMint accounts follow SPL token mint pattern (no discriminator). + /// Validation is done via owner check + PDA derivation (caller responsibility). + pub fn from_account_info_checked( + program_id: &[u8; 32], + account_info: &pinocchio::account_info::AccountInfo, + ) -> Result { + // 1. Check program ownership + if !account_info.is_owned_by(program_id) { + #[cfg(feature = "solana")] + msg!("CMint account has invalid owner"); + return Err(CTokenError::InvalidCMintOwner); + } + + // 2. Borrow and deserialize account data + let data = account_info + .try_borrow_data() + .map_err(|_| CTokenError::CMintBorrowFailed)?; + + let mint = + Self::try_from_slice(&data).map_err(|_| CTokenError::CMintDeserializationFailed)?; + + // 3. Check is_initialized + if !mint.base.is_initialized { + #[cfg(feature = "solana")] + msg!("CMint account is not initialized"); + return Err(CTokenError::CMintNotInitialized); + } + + Ok(mint) + } } // Implementation for zero-copy mutable CompressedMint @@ -74,7 +114,7 @@ impl ZCompressedMintMut<'_> { pub fn set( &mut self, ix_data: &>::ZeroCopyAt, - spl_mint_initialized: bool, + cmint_decompressed: bool, ) -> Result<(), CTokenError> { if ix_data.metadata.version != 3 { #[cfg(feature = "solana")] @@ -87,7 +127,7 @@ impl ZCompressedMintMut<'_> { // Set metadata fields from instruction data self.metadata.version = ix_data.metadata.version; self.metadata.mint = ix_data.metadata.mint; - self.metadata.spl_mint_initialized = if spl_mint_initialized { 1 } else { 0 }; + self.metadata.cmint_decompressed = if cmint_decompressed { 1 } else { 0 }; // Set base fields *self.base.supply = ix_data.supply; diff --git a/program-libs/ctoken-interface/tests/compressed_mint.rs b/program-libs/ctoken-interface/tests/compressed_mint.rs index 2ee8708419..85f87c499b 100644 --- a/program-libs/ctoken-interface/tests/compressed_mint.rs +++ b/program-libs/ctoken-interface/tests/compressed_mint.rs @@ -27,7 +27,7 @@ fn generate_random_compressed_mint(rng: &mut impl Rng, with_extensions: bool) -> metadata: CompressedMintMetadata { version: 3, mint: Pubkey::from(rng.gen::<[u8; 32]>()), - spl_mint_initialized: rng.gen_bool(0.5), + cmint_decompressed: rng.gen_bool(0.5), }, extensions: if with_extensions { // For simplicity, we'll test without extensions for now @@ -95,7 +95,7 @@ fn test_compressed_mint_borsh_zerocopy_compatibility() { .set_freeze_authority(original_mint.base.freeze_authority); zc_mint.metadata.version = original_mint.metadata.version; zc_mint.metadata.mint = original_mint.metadata.mint; - zc_mint.metadata.spl_mint_initialized = if original_mint.metadata.spl_mint_initialized { + zc_mint.metadata.cmint_decompressed = if original_mint.metadata.cmint_decompressed { 1 } else { 0 @@ -150,8 +150,8 @@ fn test_compressed_mint_borsh_zerocopy_compatibility() { i ); assert_eq!( - original_mint.metadata.spl_mint_initialized, - zc_read.metadata.spl_mint_initialized != 0, + original_mint.metadata.cmint_decompressed, + zc_read.metadata.cmint_decompressed != 0, "Is decompressed mismatch at iteration {}", i ); @@ -173,7 +173,7 @@ fn test_compressed_mint_edge_cases() { metadata: CompressedMintMetadata { version: 3, mint: Pubkey::from([0xff; 32]), - spl_mint_initialized: false, + cmint_decompressed: false, }, extensions: None, }; @@ -207,7 +207,7 @@ fn test_compressed_mint_edge_cases() { .set_freeze_authority(mint_no_auth.base.freeze_authority); zc_mint.metadata.version = mint_no_auth.metadata.version; zc_mint.metadata.mint = mint_no_auth.metadata.mint; - zc_mint.metadata.spl_mint_initialized = 0; + zc_mint.metadata.cmint_decompressed = 0; let zc_as_borsh = CompressedMint::deserialize(&mut zc_bytes.as_slice()).unwrap(); assert_eq!(mint_no_auth, zc_as_borsh); @@ -224,7 +224,7 @@ fn test_compressed_mint_edge_cases() { metadata: CompressedMintMetadata { version: 255, mint: Pubkey::from([0xbb; 32]), - spl_mint_initialized: true, + cmint_decompressed: true, }, extensions: None, }; @@ -248,7 +248,7 @@ fn test_base_mint_in_compressed_mint_spl_format() { metadata: CompressedMintMetadata { version: 3, mint: Pubkey::from([3; 32]), - spl_mint_initialized: false, + cmint_decompressed: false, }, extensions: None, }; diff --git a/program-libs/ctoken-interface/tests/hash_tests.rs b/program-libs/ctoken-interface/tests/hash_tests.rs index 39501471fc..2d029cec05 100644 --- a/program-libs/ctoken-interface/tests/hash_tests.rs +++ b/program-libs/ctoken-interface/tests/hash_tests.rs @@ -36,7 +36,7 @@ // metadata: CompressedMintMetadata { // version: 3, // mint: Pubkey::new_unique(), -// spl_mint_initialized: false, +// cmint_decompressed: false, // }, // extensions: None, // }; @@ -81,7 +81,7 @@ // metadata: CompressedMintMetadata { // version: 3, // mint: Pubkey::new_from_array([1u8; 32]), -// spl_mint_initialized: false, +// cmint_decompressed: false, // }, // extensions: None, // }; @@ -109,9 +109,9 @@ // assert_to_previous_hashes(variant.hash().unwrap(), &mut previous_hashes); // } -// // Test spl_mint_initialized boolean states +// // Test cmint_decompressed boolean states // let mut variant = base.clone(); -// variant.metadata.spl_mint_initialized = true; // Flip from false +// variant.metadata.cmint_decompressed = true; // Flip from false // assert_to_previous_hashes(variant.hash().unwrap(), &mut previous_hashes); // // Test mint_authority Option states @@ -133,7 +133,7 @@ // let mut variant = base.clone(); // variant.base.supply = 5000; // variant.base.decimals = 9; -// variant.metadata.spl_mint_initialized = true; +// variant.metadata.cmint_decompressed = true; // variant.base.mint_authority = Some(Pubkey::new_from_array([12u8; 32])); // variant.base.freeze_authority = Some(Pubkey::new_from_array([13u8; 32])); // variant.extensions = Some(vec![]); @@ -156,7 +156,7 @@ // metadata: CompressedMintMetadata { // version: 3, // mint: Pubkey::new_from_array([0u8; 32]), -// spl_mint_initialized: false, +// cmint_decompressed: false, // }, // extensions: None, // }; @@ -176,7 +176,7 @@ // assert_to_previous_hashes(variant.hash().unwrap(), &mut previous_hashes); // variant = all_minimal.clone(); -// variant.metadata.spl_mint_initialized = true; // Only this field non-false +// variant.metadata.cmint_decompressed = true; // Only this field non-false // assert_to_previous_hashes(variant.hash().unwrap(), &mut previous_hashes); // variant = all_minimal.clone(); @@ -207,7 +207,7 @@ // metadata: CompressedMintMetadata { // version: 3, // mint: Pubkey::new_from_array([1u8; 32]), -// spl_mint_initialized: false, +// cmint_decompressed: false, // }, // extensions: None, // }; @@ -257,7 +257,7 @@ // metadata: CompressedMintMetadata { // version: 3, // mint: Pubkey::new_from_array([1u8; 32]), -// spl_mint_initialized: false, +// cmint_decompressed: false, // }, // extensions: None, // }; @@ -334,7 +334,7 @@ // metadata: CompressedMintMetadata { // version: 3, // mint: Pubkey::new_from_array([1u8; 32]), -// spl_mint_initialized: false, +// cmint_decompressed: false, // }, // extensions: None, // }; @@ -408,7 +408,7 @@ // metadata: CompressedMintMetadata { // version: 3, // Always version 3 // mint: Pubkey::new_from_array(rng.gen::<[u8; 32]>()), -// spl_mint_initialized: rng.gen_bool(0.5), +// cmint_decompressed: rng.gen_bool(0.5), // }, // extensions: if rng.gen_bool(0.3) { // Some(vec![]) // Empty extensions for now diff --git a/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs b/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs index 9592ea0e6d..d492990f33 100644 --- a/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs +++ b/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs @@ -96,7 +96,7 @@ fn generate_random_mint() -> CompressedMint { }, metadata: CompressedMintMetadata { version: 3, - spl_mint_initialized: rng.gen_bool(0.5), + cmint_decompressed: rng.gen_bool(0.5), mint: { let mut bytes = [0u8; 32]; rng.fill(&mut bytes); @@ -159,7 +159,7 @@ fn compare_mint_borsh_vs_zero_copy(original: &CompressedMint, borsh_bytes: &[u8] }, metadata: CompressedMintMetadata { version: zc_mint.metadata.version, - spl_mint_initialized: zc_mint.metadata.spl_mint_initialized != 0, + cmint_decompressed: zc_mint.metadata.cmint_decompressed != 0, mint: zc_mint.metadata.mint, }, extensions: zc_extensions.clone(), @@ -180,7 +180,7 @@ fn compare_mint_borsh_vs_zero_copy(original: &CompressedMint, borsh_bytes: &[u8] }, metadata: CompressedMintMetadata { version: zc_mint_mut.metadata.version, - spl_mint_initialized: zc_mint_mut.metadata.spl_mint_initialized != 0, + cmint_decompressed: zc_mint_mut.metadata.cmint_decompressed != 0, mint: zc_mint_mut.metadata.mint, }, extensions: zc_extensions, // Extensions handling for mut is same as read-only diff --git a/program-tests/compressed-token-test/tests/mint.rs b/program-tests/compressed-token-test/tests/mint.rs index 4cffcafeeb..cf70f9db08 100644 --- a/program-tests/compressed-token-test/tests/mint.rs +++ b/program-tests/compressed-token-test/tests/mint.rs @@ -16,3 +16,9 @@ mod functional; #[path = "mint/random.rs"] mod random; + +#[path = "mint/burn.rs"] +mod burn; + +#[path = "mint/ctoken_mint_to.rs"] +mod ctoken_mint_to; diff --git a/program-tests/compressed-token-test/tests/mint/burn.rs b/program-tests/compressed-token-test/tests/mint/burn.rs new file mode 100644 index 0000000000..d00e4be8f0 --- /dev/null +++ b/program-tests/compressed-token-test/tests/mint/burn.rs @@ -0,0 +1,155 @@ +use light_ctoken_interface::instructions::mint_action::Recipient; +use light_ctoken_sdk::{ + compressed_token::create_compressed_mint::find_cmint_address, + ctoken::{derive_ctoken_ata, BurnCToken, CreateAssociatedCTokenAccount}, +}; +use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_test_utils::{assert_ctoken_burn::assert_ctoken_burn, Rpc}; +use light_token_client::instructions::mint_action::DecompressMintParams; +use serial_test::serial; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; + +/// Test context for burn operations +struct BurnTestContext { + rpc: LightProgramTest, + payer: Keypair, + cmint_pda: Pubkey, + ctoken_account: Pubkey, + owner_keypair: Keypair, +} + +/// Setup: Create CMint + CToken with tokens minted +/// +/// Steps: +/// 1. Init LightProgramTest +/// 2. Create compressed mint + CMint via mint_action_comprehensive +/// 3. Create CToken ATA with compressible extension +/// 4. Mint tokens to CToken via mint_action_comprehensive +async fn setup_burn_test(mint_amount: u64) -> BurnTestContext { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + // Use payer as mint_authority to simplify signing + let mint_authority = payer.insecure_clone(); + let owner_keypair = Keypair::new(); + + // Derive CMint PDA + let (cmint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // Step 1: Create CToken ATA for owner first (needed before minting) + let (ctoken_ata, _) = derive_ctoken_ata(&owner_keypair.pubkey(), &cmint_pda); + + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), owner_keypair.pubkey(), cmint_pda) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Step 2: Create compressed mint + CMint + mint tokens in one call + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), // Creates CMint + false, // Don't compress and close + vec![], // No compressed recipients + vec![Recipient { + recipient: owner_keypair.pubkey().into(), + amount: mint_amount, + }], // Mint to CToken in same tx + None, // No mint authority update + None, // No freeze authority update + Some(light_token_client::instructions::mint_action::NewMint { + decimals: 8, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: None, + metadata: None, + version: 3, + }), + ) + .await + .unwrap(); + + BurnTestContext { + rpc, + payer, + cmint_pda, + ctoken_account: ctoken_ata, + owner_keypair, + } +} + +/// Test burning tokens: mint 1000, burn 500, burn 500, end with 0 +#[tokio::test] +#[serial] +async fn test_ctoken_burn() { + let mut ctx = setup_burn_test(1000).await; + + // First burn: 500 tokens (half) + let burn_ix_1 = BurnCToken { + source: ctx.ctoken_account, + cmint: ctx.cmint_pda, + amount: 500, + authority: ctx.owner_keypair.pubkey(), + max_top_up: None, + } + .instruction() + .unwrap(); + + ctx.rpc + .create_and_send_transaction( + &[burn_ix_1], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.owner_keypair], + ) + .await + .unwrap(); + + assert_ctoken_burn(&mut ctx.rpc, ctx.ctoken_account, ctx.cmint_pda, 500).await; + + // Second burn: 500 tokens (remaining half) + let burn_ix_2 = BurnCToken { + source: ctx.ctoken_account, + cmint: ctx.cmint_pda, + amount: 500, + authority: ctx.owner_keypair.pubkey(), + max_top_up: None, + } + .instruction() + .unwrap(); + + ctx.rpc + .create_and_send_transaction( + &[burn_ix_2], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.owner_keypair], + ) + .await + .unwrap(); + + assert_ctoken_burn(&mut ctx.rpc, ctx.ctoken_account, ctx.cmint_pda, 500).await; + + // Verify final balance is 0 + use anchor_lang::prelude::borsh::BorshDeserialize; + use light_ctoken_interface::state::CToken; + let ctoken_after = ctx + .rpc + .get_account(ctx.ctoken_account) + .await + .unwrap() + .unwrap(); + let token_account: CToken = + BorshDeserialize::deserialize(&mut ctoken_after.data.as_slice()).unwrap(); + assert_eq!( + token_account.amount, 0, + "Final balance should be 0 after burning entire amount" + ); +} diff --git a/program-tests/compressed-token-test/tests/mint/cpi_context.rs b/program-tests/compressed-token-test/tests/mint/cpi_context.rs index d173b489de..bf35dd0609 100644 --- a/program-tests/compressed-token-test/tests/mint/cpi_context.rs +++ b/program-tests/compressed-token-test/tests/mint/cpi_context.rs @@ -76,18 +76,18 @@ async fn test_setup() -> TestSetup { prove_by_index: false, root_index: 0, address: compressed_mint_address, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: 0, decimals, metadata: CompressedMintMetadata { version: 3, - spl_mint_initialized: false, + cmint_decompressed: false, mint: spl_mint_pda.into(), }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: Some(freeze_authority.into()), extensions: None, - }, + }), }; TestSetup { @@ -127,7 +127,7 @@ async fn test_write_to_cpi_context_create_mint() { compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), - compressed_mint_inputs.mint.clone(), + compressed_mint_inputs.mint.clone().unwrap(), ) .with_cpi_context(CpiContext { set_context: false, @@ -248,7 +248,7 @@ async fn test_write_to_cpi_context_invalid_address_tree() { compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), - compressed_mint_inputs.mint.clone(), + compressed_mint_inputs.mint.clone().unwrap(), ) .with_cpi_context(CpiContext { set_context: false, @@ -340,7 +340,7 @@ async fn test_write_to_cpi_context_invalid_compressed_address() { invalid_compressed_address, compressed_mint_inputs.root_index, CompressedProof::default(), - compressed_mint_inputs.mint.clone(), + compressed_mint_inputs.mint.clone().unwrap(), ) .with_cpi_context(CpiContext { set_context: false, @@ -441,7 +441,7 @@ async fn test_execute_cpi_context_invalid_tree_index() { compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), - compressed_mint_inputs.mint.clone(), + compressed_mint_inputs.mint.clone().unwrap(), ) .with_cpi_context(execute_cpi_context); diff --git a/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs b/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs new file mode 100644 index 0000000000..613040a797 --- /dev/null +++ b/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs @@ -0,0 +1,150 @@ +use light_ctoken_sdk::{ + compressed_token::create_compressed_mint::find_cmint_address, + ctoken::{derive_ctoken_ata, CTokenMintTo, CreateAssociatedCTokenAccount}, +}; +use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_test_utils::{assert_ctoken_mint_to::assert_ctoken_mint_to, Rpc}; +use light_token_client::instructions::mint_action::DecompressMintParams; +use serial_test::serial; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; + +/// Test context for mint_to operations +struct MintToTestContext { + rpc: LightProgramTest, + payer: Keypair, + cmint_pda: Pubkey, + ctoken_account: Pubkey, + mint_authority: Keypair, +} + +/// Setup: Create CMint + CToken (without tokens) +/// +/// Steps: +/// 1. Init LightProgramTest +/// 2. Create compressed mint + CMint via mint_action_comprehensive (no recipients) +/// 3. Create CToken ATA with compressible extension +async fn setup_mint_to_test() -> MintToTestContext { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + // Use payer as mint_authority to simplify signing + let mint_authority = payer.insecure_clone(); + let owner_keypair = Keypair::new(); + + // Derive CMint PDA + let (cmint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // Step 1: Create CToken ATA for owner first + let (ctoken_ata, _) = derive_ctoken_ata(&owner_keypair.pubkey(), &cmint_pda); + + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), owner_keypair.pubkey(), cmint_pda) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Step 2: Create compressed mint + CMint (no recipients - we'll mint via CTokenMintTo) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), // Creates CMint + false, // Don't compress and close + vec![], // No compressed recipients + vec![], // No ctoken recipients - we'll mint separately + None, // No mint authority update + None, // No freeze authority update + Some(light_token_client::instructions::mint_action::NewMint { + decimals: 8, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: None, + metadata: None, + version: 3, + }), + ) + .await + .unwrap(); + + MintToTestContext { + rpc, + payer, + cmint_pda, + ctoken_account: ctoken_ata, + mint_authority, + } +} + +/// Test minting tokens: mint 500, mint 500, end with 1000 +#[tokio::test] +#[serial] +async fn test_ctoken_mint_to() { + let mut ctx = setup_mint_to_test().await; + + // First mint: 500 tokens + let mint_ix_1 = CTokenMintTo { + cmint: ctx.cmint_pda, + destination: ctx.ctoken_account, + amount: 500, + authority: ctx.mint_authority.pubkey(), + max_top_up: None, + } + .instruction() + .unwrap(); + + ctx.rpc + .create_and_send_transaction( + &[mint_ix_1], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.mint_authority], + ) + .await + .unwrap(); + + assert_ctoken_mint_to(&mut ctx.rpc, ctx.ctoken_account, ctx.cmint_pda, 500).await; + + // Second mint: 500 tokens + let mint_ix_2 = CTokenMintTo { + cmint: ctx.cmint_pda, + destination: ctx.ctoken_account, + amount: 500, + authority: ctx.mint_authority.pubkey(), + max_top_up: None, + } + .instruction() + .unwrap(); + + ctx.rpc + .create_and_send_transaction( + &[mint_ix_2], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.mint_authority], + ) + .await + .unwrap(); + + assert_ctoken_mint_to(&mut ctx.rpc, ctx.ctoken_account, ctx.cmint_pda, 500).await; + + // Verify final balance is 1000 + use anchor_lang::prelude::borsh::BorshDeserialize; + use light_ctoken_interface::state::CToken; + let ctoken_after = ctx + .rpc + .get_account(ctx.ctoken_account) + .await + .unwrap() + .unwrap(); + let token_account: CToken = + BorshDeserialize::deserialize(&mut ctoken_after.data.as_slice()).unwrap(); + assert_eq!( + token_account.amount, 1000, + "Final balance should be 1000 after minting 500 + 500" + ); +} diff --git a/program-tests/compressed-token-test/tests/mint/failing.rs b/program-tests/compressed-token-test/tests/mint/failing.rs index 84cb928cad..e121ce4a5a 100644 --- a/program-tests/compressed-token-test/tests/mint/failing.rs +++ b/program-tests/compressed-token-test/tests/mint/failing.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "test-sbf")] +// #![cfg(feature = "test-sbf")] use anchor_lang::prelude::borsh::BorshDeserialize; use light_client::indexer::Indexer; @@ -421,6 +421,8 @@ async fn functional_and_failing_tests() { &mint_seed, &invalid_mint_authority, // Invalid authority &payer, + None, // decompress_mint + false, // compress_and_close_cmint vec![], // No compressed recipients vec![ light_ctoken_interface::instructions::mint_action::Recipient::new( @@ -478,6 +480,8 @@ async fn functional_and_failing_tests() { &mint_seed, &new_mint_authority, // Valid NEW authority after update &payer, + None, // decompress_mint + false, // compress_and_close_cmint vec![], // No compressed recipients vec![ light_ctoken_interface::instructions::mint_action::Recipient::new( @@ -913,7 +917,7 @@ async fn test_mint_to_ctoken_max_top_up_exceeded() { .root_index() .unwrap_or_default(), address: compressed_mint_address, - mint: compressed_mint.try_into().unwrap(), + mint: Some(compressed_mint.try_into().unwrap()), }; // Build instruction data with max_top_up = 1 (too low to cover rent top-up) diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 12fa91773b..7f0564099a 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -1,5 +1,6 @@ use anchor_lang::{prelude::borsh::BorshDeserialize, solana_program::program_pack::Pack}; use light_client::indexer::Indexer; +use light_compressible::rent::SLOTS_PER_EPOCH; use light_ctoken_interface::{ instructions::{ extensions::token_metadata::TokenMetadataInstructionData, mint_action::Recipient, @@ -16,9 +17,10 @@ use light_ctoken_sdk::{ }, ctoken::{derive_ctoken_ata, CompressibleParams, CreateAssociatedCTokenAccount}, }; -use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_program_test::{program_test::TestRpc, LightProgramTest, ProgramTestConfig}; use light_test_utils::{ assert_ctoken_transfer::assert_ctoken_transfer, + assert_mint_action::assert_mint_action, assert_mint_to_compressed::{assert_mint_to_compressed, assert_mint_to_compressed_one}, assert_transfer2::{ assert_transfer2, assert_transfer2_compress, assert_transfer2_decompress, @@ -29,9 +31,12 @@ use light_test_utils::{ }; use light_token_client::{ actions::{create_mint, mint_to_compressed, transfer2, transfer_ctoken}, - instructions::transfer2::{ - create_decompress_instruction, create_generic_transfer2_instruction, CompressInput, - DecompressInput, Transfer2InstructionType, TransferInput, + instructions::{ + mint_action::{DecompressMintParams, MintActionType}, + transfer2::{ + create_decompress_instruction, create_generic_transfer2_instruction, CompressInput, + DecompressInput, Transfer2InstructionType, TransferInput, + }, }, }; use serial_test::serial; @@ -737,6 +742,8 @@ async fn test_ctoken_transfer() { &mint_seed, &mint_authority, &payer, + None, // decompress_mint + false, // compress_and_close_cmint vec![], // no compressed recipients decompressed_recipients, // mint to decompressed recipients None, // no mint authority update @@ -754,7 +761,7 @@ async fn test_ctoken_transfer() { .unwrap(); println!( - "✅ Mint creation and decompressed minting signature: {}", + "Mint creation and decompressed minting signature: {}", signature ); @@ -1186,13 +1193,15 @@ async fn test_mint_actions() { &mint_seed, &mint_authority, &payer, + None, // decompress_mint + false, // compress_and_close_cmint recipients.clone(), // mint_to_recipients vec![], // mint_to_decompressed_recipients Some(new_mint_authority.pubkey()), // update_mint_authority - None,// Some(new_freeze_authority.pubkey()), // update_freeze_authority + None, // update_freeze_authority Some(light_token_client::instructions::mint_action::NewMint { decimals, - supply:0, + supply: 0, mint_authority: mint_authority.pubkey(), freeze_authority: Some(freeze_authority.pubkey()), metadata: Some(light_ctoken_interface::instructions::extensions::token_metadata::TokenMetadataInstructionData { @@ -1227,7 +1236,7 @@ async fn test_mint_actions() { metadata: CompressedMintMetadata { version: 3, // With metadata mint: spl_mint_pda.into(), - spl_mint_initialized: false, // Should be true after CreateSplMint action + cmint_decompressed: false, // Should be true after CreateSplMint action }, extensions: Some(vec![ light_ctoken_interface::state::extensions::ExtensionStruct::TokenMetadata( @@ -1280,7 +1289,7 @@ async fn test_mint_actions() { "Supply should match minted amount" ); assert!( - !updated_compressed_mint.metadata.spl_mint_initialized, + !updated_compressed_mint.metadata.cmint_decompressed, "Mint should not be decompressed " ); @@ -1333,6 +1342,8 @@ async fn test_mint_actions() { &mint_seed, &new_mint_authority, // Current authority from first test (now the authority for this mint) &payer, + None, // decompress_mint + false, // compress_and_close_cmint additional_recipients.clone(), // mint_to_recipients vec![], // mint_to_decompressed_recipients Some(newer_mint_authority.pubkey()), // update_mint_authority to newer authority @@ -1386,7 +1397,266 @@ async fn test_mint_actions() { "Supply should include both mintings" ); assert!( - !final_compressed_mint.metadata.spl_mint_initialized, + !final_compressed_mint.metadata.cmint_decompressed, "Mint should remain compressed" ); } + +/// Test creating compressed mint and CMint (decompressed) in same instruction +#[tokio::test] +#[serial] +async fn test_create_compressed_mint_with_cmint() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Test parameters + let decimals = 8u8; + let mint_seed = Keypair::new(); + let mint_authority = Keypair::new(); + let freeze_authority = Keypair::new(); + + // Fund authority + rpc.airdrop_lamports(&mint_authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + // Derive addresses + 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_pda, _cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // Create mint + decompress in single instruction + let signature = light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), // decompress_mint = true (creates CMint) + false, // compress_and_close_cmint = false + vec![], // no compressed recipients + vec![], // no decompressed recipients + None, // no mint authority update + None, // no freeze authority update + Some(light_token_client::instructions::mint_action::NewMint { + decimals, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: Some(freeze_authority.pubkey()), + metadata: None, + version: 3, + }), + ) + .await + .unwrap(); + + println!("Create mint + CMint signature: {}", signature); + + // Build pre-state for DecompressMint assertion (state before DecompressMint was applied) + let pre_decompress_mint = CompressedMint { + base: BaseMint { + mint_authority: Some(mint_authority.pubkey().into()), + supply: 0, + decimals, + is_initialized: true, + freeze_authority: Some(freeze_authority.pubkey().into()), + }, + metadata: CompressedMintMetadata { + version: 3, + cmint_decompressed: false, // Before DecompressMint + mint: cmint_pda.to_bytes().into(), + }, + extensions: None, + }; + + // Verify DecompressMint action results using assert_mint_action + assert_mint_action( + &mut rpc, + compressed_mint_address, + pre_decompress_mint, + vec![MintActionType::DecompressMint { + cmint_bump: _cmint_bump, + rent_payment: 2, // Default rent payment + write_top_up: 0, // Default write top-up + }], + ) + .await; + + println!("Create compressed mint with CMint completed, now testing CompressAndCloseCMint..."); + + // === COMPRESS AND CLOSE CMINT === + // Warp to epoch 2 so that rent expires (CMint created with rent_payment: 2) + rpc.warp_to_slot(SLOTS_PER_EPOCH * 2).unwrap(); + + // Fetch pre-close state from CMint BEFORE closing (CMint will be closed after transaction) + let cmint_account_data = rpc + .get_account(cmint_pda) + .await + .expect("Failed to get CMint account") + .expect("CMint account should exist before close"); + let pre_close_mint: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_account_data.data.as_slice()) + .expect("Failed to deserialize CMint data"); + + // Compress and close CMint (permissionless when rent expired) + let close_signature = light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + None, // no decompress_mint + true, // compress_and_close_cmint = true + vec![], // no compressed recipients + vec![], // no decompressed recipients + None, // no mint authority update + None, // no freeze authority update + None, // no new_mint + ) + .await + .unwrap(); + + println!("CompressAndCloseCMint signature: {}", close_signature); + + // Verify CompressAndCloseCMint action results using assert_mint_action + assert_mint_action( + &mut rpc, + compressed_mint_address, + pre_close_mint, + vec![MintActionType::CompressAndCloseCMint { idempotent: false }], + ) + .await; + + println!("CompressAndCloseCMint test completed successfully!"); +} + +/// Test decompressing an existing compressed mint to CMint +/// 1. Create compressed mint without CMint +/// 2. Mint tokens to recipients +/// 3. Decompress existing mint to CMint +/// 4. Verify CMint matches compressed mint state (including supply) +#[tokio::test] +#[serial] +async fn test_decompress_existing_mint_to_cmint() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Test parameters + let decimals = 8u8; + let mint_seed = Keypair::new(); + let mint_authority = Keypair::new(); + let freeze_authority = Keypair::new(); + let recipient = Keypair::new(); + let mint_amount = 1_000_000u64; + + // Fund authority + rpc.airdrop_lamports(&mint_authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + // Derive addresses + 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_pda, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // === STEP 1: Create compressed mint WITHOUT CMint === + create_mint( + &mut rpc, + &mint_seed, + decimals, + &mint_authority, + Some(freeze_authority.pubkey()), + None, // No metadata + &payer, + ) + .await + .unwrap(); + + // Verify CMint does NOT exist yet + let cmint_account = rpc.get_account(cmint_pda).await.unwrap(); + assert!(cmint_account.is_none(), "CMint should NOT exist yet"); + + // Verify compressed mint exists with cmint_decompressed = false + let compressed_mint_account = rpc + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let compressed_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_mint_account.data.unwrap().data.as_slice()) + .unwrap(); + assert!( + !compressed_mint.metadata.cmint_decompressed, + "cmint_decompressed should be false before DecompressMint" + ); + + // === STEP 2: Mint tokens to recipient === + let (spl_mint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + mint_to_compressed( + &mut rpc, + spl_mint_pda, + vec![Recipient { + recipient: recipient.pubkey().into(), + amount: mint_amount, + }], + TokenDataVersion::V2, + &mint_authority, + &payer, + ) + .await + .unwrap(); + + // Verify supply updated + let compressed_mint_account = rpc + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let compressed_mint_after_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_mint_account.data.unwrap().data.as_slice()) + .unwrap(); + assert_eq!( + compressed_mint_after_mint.base.supply, mint_amount, + "Supply should be updated after minting" + ); + + // === STEP 3: Decompress existing mint to CMint === + let signature = light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), // decompress_mint = true (creates CMint) + false, // compress_and_close_cmint + vec![], // no new compressed recipients + vec![], // no decompressed recipients + None, // no mint authority update + None, // no freeze authority update + None, // NO new mint - using existing + ) + .await + .unwrap(); + + println!("Decompress existing mint to CMint signature: {}", signature); + + // Verify DecompressMint action results using assert_mint_action + assert_mint_action( + &mut rpc, + compressed_mint_address, + compressed_mint_after_mint, + vec![MintActionType::DecompressMint { + cmint_bump, + rent_payment: 2, // Default rent payment + write_top_up: 0, // Default write top-up + }], + ) + .await; + + println!("Decompress existing mint to CMint test completed successfully!"); +} diff --git a/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs b/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs index 89d814b963..b56bb2b48a 100644 --- a/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs @@ -117,6 +117,8 @@ async fn setup_compression_test(token_amount: u64) -> Result Result<(), RpcError> { &mint_seed, &mint_authority, &payer, + None, // no decompress mint + false, // no close cmint vec![], // no compressed recipients decompressed_recipients, // mint to decompressed CToken ATA None, diff --git a/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs b/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs index 386708990a..c545cc5279 100644 --- a/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs @@ -122,6 +122,8 @@ async fn setup_decompression_test( &mint_seed, &mint_authority, &payer, + None, // no decompress mint + false, // compress_and_close_cmint compressed_recipients, // mint compressed tokens to owner decompressed_recipients, // mint 1 token to decompressed CToken ATA None, // no mint authority update diff --git a/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs b/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs index 7ba08413cf..65c9da7ca5 100644 --- a/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs @@ -135,6 +135,8 @@ async fn setup_no_system_program_cpi_test( &mint_seed, &mint_authority, &payer, + None, // no decompress mint + false, // no close cmint vec![], // no compressed recipients decompressed_recipients, // mint to source CToken ATA (empty if token_amount is 0) None, @@ -744,6 +746,8 @@ async fn test_too_many_mints() { &mint_seed, &mint_authority, &context.payer, + None, // no decompress mint + false, // no close cmint vec![], // no compressed recipients decompressed_recipients, // mint to source CToken ATA None, diff --git a/program-tests/utils/src/assert_ctoken_burn.rs b/program-tests/utils/src/assert_ctoken_burn.rs new file mode 100644 index 0000000000..6d79273ca4 --- /dev/null +++ b/program-tests/utils/src/assert_ctoken_burn.rs @@ -0,0 +1,150 @@ +use anchor_lang::prelude::borsh::BorshDeserialize; +use light_client::rpc::Rpc; +use light_ctoken_interface::state::{extensions::ExtensionStruct, CToken, CompressedMint}; +use light_program_test::LightProgramTest; +use solana_sdk::pubkey::Pubkey; + +/// Assert that a ctoken burn was successful by checking complete account state. +/// Automatically retrieves pre-transaction state from the cached context. +/// +/// # Arguments +/// * `rpc` - RPC client to fetch account data (must be LightProgramTest) +/// * `ctoken_account` - Source CToken account pubkey +/// * `cmint_account` - CMint account pubkey +/// * `burn_amount` - Amount that was burned +/// +/// # Assertions +/// * CToken balance decreased by burn amount +/// * CMint supply decreased by burn amount +/// * Compressible extensions preserved (if present) +/// * Lamport top-ups applied correctly (if compressible) +pub async fn assert_ctoken_burn( + rpc: &mut LightProgramTest, + ctoken_account: Pubkey, + cmint_account: Pubkey, + burn_amount: u64, +) { + // Get pre-transaction state from cache + let ctoken_before = rpc + .get_pre_transaction_account(&ctoken_account) + .expect("CToken account should exist in pre-transaction context"); + let cmint_before = rpc + .get_pre_transaction_account(&cmint_account) + .expect("CMint account should exist in pre-transaction context"); + + // Get post-transaction state + let ctoken_after = rpc + .get_account(ctoken_account) + .await + .expect("Failed to get CToken account after transaction") + .expect("CToken account should exist after transaction"); + let cmint_after = rpc + .get_account(cmint_account) + .await + .expect("Failed to get CMint account after transaction") + .expect("CMint account should exist after transaction"); + + // Parse accounts using Borsh + let ctoken_parsed_before: CToken = + BorshDeserialize::deserialize(&mut ctoken_before.data.as_slice()) + .expect("Failed to deserialize CToken before"); + let ctoken_parsed_after: CToken = + BorshDeserialize::deserialize(&mut ctoken_after.data.as_slice()) + .expect("Failed to deserialize CToken after"); + let cmint_parsed_before: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_before.data.as_slice()) + .expect("Failed to deserialize CMint before"); + let cmint_parsed_after: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_after.data.as_slice()) + .expect("Failed to deserialize CMint after"); + + // Build expected CToken state + let mut expected_ctoken = ctoken_parsed_before.clone(); + expected_ctoken.amount -= burn_amount; + + // Build expected CMint state + let mut expected_cmint = cmint_parsed_before.clone(); + expected_cmint.base.supply -= burn_amount; + + // Assert full CToken struct + assert_eq!( + ctoken_parsed_after, expected_ctoken, + "CToken state mismatch after burn. burn_amount: {}", + burn_amount + ); + + // Assert full CMint struct + assert_eq!( + cmint_parsed_after, expected_cmint, + "CMint state mismatch after burn. burn_amount: {}", + burn_amount + ); + + // Calculate expected lamport changes + let current_slot = rpc.get_slot().await.unwrap(); + + let expected_ctoken_lamport_change = calculate_expected_lamport_change( + rpc, + &ctoken_parsed_before.extensions, + ctoken_before.data.len(), + current_slot, + ctoken_before.lamports, + ) + .await; + + let expected_cmint_lamport_change = calculate_expected_lamport_change( + rpc, + &cmint_parsed_before.extensions, + cmint_before.data.len(), + current_slot, + cmint_before.lamports, + ) + .await; + + let actual_ctoken_lamport_change = ctoken_after.lamports.saturating_sub(ctoken_before.lamports); + let actual_cmint_lamport_change = cmint_after.lamports.saturating_sub(cmint_before.lamports); + + // Assert lamport changes + assert_eq!( + (actual_ctoken_lamport_change, actual_cmint_lamport_change), + ( + expected_ctoken_lamport_change, + expected_cmint_lamport_change + ), + "Lamport changes mismatch after burn" + ); +} + +async fn calculate_expected_lamport_change( + rpc: &mut LightProgramTest, + extensions: &Option>, + data_len: usize, + current_slot: u64, + current_lamports: u64, +) -> u64 { + if let Some(exts) = extensions { + let compressible = exts.iter().find_map(|ext| { + if let ExtensionStruct::Compressible(comp) = ext { + Some(comp) + } else { + None + } + }); + + if let Some(comp) = compressible { + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(data_len) + .await + .unwrap(); + return comp + .calculate_top_up_lamports( + data_len as u64, + current_slot, + current_lamports, + rent_exemption, + ) + .unwrap(); + } + } + 0 +} diff --git a/program-tests/utils/src/assert_ctoken_mint_to.rs b/program-tests/utils/src/assert_ctoken_mint_to.rs new file mode 100644 index 0000000000..26d290fd5e --- /dev/null +++ b/program-tests/utils/src/assert_ctoken_mint_to.rs @@ -0,0 +1,150 @@ +use anchor_lang::prelude::borsh::BorshDeserialize; +use light_client::rpc::Rpc; +use light_ctoken_interface::state::{extensions::ExtensionStruct, CToken, CompressedMint}; +use light_program_test::LightProgramTest; +use solana_sdk::pubkey::Pubkey; + +/// Assert that a ctoken mint_to was successful by checking complete account state. +/// Automatically retrieves pre-transaction state from the cached context. +/// +/// # Arguments +/// * `rpc` - RPC client to fetch account data (must be LightProgramTest) +/// * `ctoken_account` - Destination CToken account pubkey +/// * `cmint_account` - CMint account pubkey +/// * `mint_amount` - Amount that was minted +/// +/// # Assertions +/// * CToken balance increased by mint amount +/// * CMint supply increased by mint amount +/// * Compressible extensions preserved (if present) +/// * Lamport top-ups applied correctly (if compressible) +pub async fn assert_ctoken_mint_to( + rpc: &mut LightProgramTest, + ctoken_account: Pubkey, + cmint_account: Pubkey, + mint_amount: u64, +) { + // Get pre-transaction state from cache + let ctoken_before = rpc + .get_pre_transaction_account(&ctoken_account) + .expect("CToken account should exist in pre-transaction context"); + let cmint_before = rpc + .get_pre_transaction_account(&cmint_account) + .expect("CMint account should exist in pre-transaction context"); + + // Get post-transaction state + let ctoken_after = rpc + .get_account(ctoken_account) + .await + .expect("Failed to get CToken account after transaction") + .expect("CToken account should exist after transaction"); + let cmint_after = rpc + .get_account(cmint_account) + .await + .expect("Failed to get CMint account after transaction") + .expect("CMint account should exist after transaction"); + + // Parse accounts using Borsh + let ctoken_parsed_before: CToken = + BorshDeserialize::deserialize(&mut ctoken_before.data.as_slice()) + .expect("Failed to deserialize CToken before"); + let ctoken_parsed_after: CToken = + BorshDeserialize::deserialize(&mut ctoken_after.data.as_slice()) + .expect("Failed to deserialize CToken after"); + let cmint_parsed_before: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_before.data.as_slice()) + .expect("Failed to deserialize CMint before"); + let cmint_parsed_after: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_after.data.as_slice()) + .expect("Failed to deserialize CMint after"); + + // Build expected CToken state + let mut expected_ctoken = ctoken_parsed_before.clone(); + expected_ctoken.amount += mint_amount; + + // Build expected CMint state + let mut expected_cmint = cmint_parsed_before.clone(); + expected_cmint.base.supply += mint_amount; + + // Assert full CToken struct + assert_eq!( + ctoken_parsed_after, expected_ctoken, + "CToken state mismatch after mint_to. mint_amount: {}", + mint_amount + ); + + // Assert full CMint struct + assert_eq!( + cmint_parsed_after, expected_cmint, + "CMint state mismatch after mint_to. mint_amount: {}", + mint_amount + ); + + // Calculate expected lamport changes + let current_slot = rpc.get_slot().await.unwrap(); + + let expected_ctoken_lamport_change = calculate_expected_lamport_change( + rpc, + &ctoken_parsed_before.extensions, + ctoken_before.data.len(), + current_slot, + ctoken_before.lamports, + ) + .await; + + let expected_cmint_lamport_change = calculate_expected_lamport_change( + rpc, + &cmint_parsed_before.extensions, + cmint_before.data.len(), + current_slot, + cmint_before.lamports, + ) + .await; + + let actual_ctoken_lamport_change = ctoken_after.lamports.saturating_sub(ctoken_before.lamports); + let actual_cmint_lamport_change = cmint_after.lamports.saturating_sub(cmint_before.lamports); + + // Assert lamport changes + assert_eq!( + (actual_ctoken_lamport_change, actual_cmint_lamport_change), + ( + expected_ctoken_lamport_change, + expected_cmint_lamport_change + ), + "Lamport changes mismatch after mint_to" + ); +} + +async fn calculate_expected_lamport_change( + rpc: &mut LightProgramTest, + extensions: &Option>, + data_len: usize, + current_slot: u64, + current_lamports: u64, +) -> u64 { + if let Some(exts) = extensions { + let compressible = exts.iter().find_map(|ext| { + if let ExtensionStruct::Compressible(comp) = ext { + Some(comp) + } else { + None + } + }); + + if let Some(comp) = compressible { + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(data_len) + .await + .unwrap(); + return comp + .calculate_top_up_lamports( + data_len as u64, + current_slot, + current_lamports, + rent_exemption, + ) + .unwrap(); + } + } + 0 +} diff --git a/program-tests/utils/src/assert_mint_action.rs b/program-tests/utils/src/assert_mint_action.rs index 372f526f24..348536fc4a 100644 --- a/program-tests/utils/src/assert_mint_action.rs +++ b/program-tests/utils/src/assert_mint_action.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use anchor_lang::prelude::borsh::BorshDeserialize; use light_client::indexer::Indexer; +use light_compressed_account::compressed_account::CompressedAccountData; use light_ctoken_interface::state::{ extensions::{AdditionalMetadata, ExtensionStruct}, CToken, CompressedMint, @@ -109,29 +110,128 @@ pub async fn assert_mint_action( } } } + MintActionType::DecompressMint { .. } => { + expected_mint.metadata.cmint_decompressed = true; + } + MintActionType::CompressAndCloseCMint { .. } => { + expected_mint.metadata.cmint_decompressed = false; + // Remove Compressible extension + if let Some(ref mut extensions) = expected_mint.extensions { + extensions.retain(|e| !matches!(e, ExtensionStruct::Compressible(_))); + if extensions.is_empty() { + expected_mint.extensions = None; + } + } + } + } + } + // Determine pre and post decompression states + let post_decompressed = expected_mint.metadata.cmint_decompressed; + + // Check for CompressAndCloseCMint action + let has_compress_and_close_cmint = actions + .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) + 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 should have Compressible extension + assert!( + cmint + .extensions + .as_ref() + .map(|exts| exts + .iter() + .any(|e| matches!(e, ExtensionStruct::Compressible(_)))) + .unwrap_or(false), + "CMint should have Compressible extension when decompressed" + ); + + // Compressed account should have zero sentinel values + let actual_mint_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .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" + ); + } else { + // === CASE 3 & 4: Compressed account is source of truth after actions === + // (Either CompressAndCloseCMint happened OR was never decompressed) + let actual_mint_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .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 should NEVER have Compressible extension + // (Compressible only lives in CMint Solana account, not in compressed account) + if let Some(ref extensions) = actual_mint.extensions { + assert!( + !extensions + .iter() + .any(|e| matches!(e, ExtensionStruct::Compressible(_))), + "Compressed mint should NEVER have Compressible extension" + ); } } - // Get actual post-transaction state - let actual_mint_account = rpc - .indexer() - .unwrap() - .get_compressed_account(compressed_mint_address, None) - .await - .unwrap() - .value - .expect("Compressed mint account not found"); - - let actual_mint: CompressedMint = - BorshDeserialize::deserialize(&mut actual_mint_account.data.unwrap().data.as_slice()) - .unwrap(); - - // Single assertion - assert_eq!( - actual_mint, expected_mint, - "Compressed mint state after mint_action should match expected" - ); + // If CompressAndCloseCMint, verify CMint Solana account is closed + if has_compress_and_close_cmint { + let cmint_pda = Pubkey::from(pre_compressed_mint.metadata.mint); + let cmint_account = rpc + .get_account(cmint_pda) + .await + .expect("Failed to fetch CMint account"); + + assert!( + cmint_account.is_none(), + "CMint PDA account should not exist after CompressAndCloseCMint action" + ); + } // Verify CToken accounts for MintToCToken actions for (account_pubkey, total_minted_amount) in ctoken_mints { // Get pre-transaction account state diff --git a/program-tests/utils/src/assert_mint_to_compressed.rs b/program-tests/utils/src/assert_mint_to_compressed.rs index a637a3b238..bc4dca48d8 100644 --- a/program-tests/utils/src/assert_mint_to_compressed.rs +++ b/program-tests/utils/src/assert_mint_to_compressed.rs @@ -105,7 +105,7 @@ pub async fn assert_mint_to_compressed( ); // If mint is decompressed and pre_token_pool_account is provided, validate SPL mint and token pool - if actual_compressed_mint.metadata.spl_mint_initialized { + if actual_compressed_mint.metadata.cmint_decompressed { if let Some(pre_pool_account) = pre_token_pool_account { // Validate SPL mint supply let spl_mint_data = rpc diff --git a/program-tests/utils/src/lib.rs b/program-tests/utils/src/lib.rs index fe0ed981b4..c0df566e50 100644 --- a/program-tests/utils/src/lib.rs +++ b/program-tests/utils/src/lib.rs @@ -22,6 +22,8 @@ pub mod assert_claim; pub mod assert_close_token_account; pub mod assert_compressed_tx; pub mod assert_create_token_account; +pub mod assert_ctoken_burn; +pub mod assert_ctoken_mint_to; pub mod assert_ctoken_transfer; pub mod assert_epoch; pub mod assert_merkle_tree; diff --git a/program-tests/utils/src/mint_assert.rs b/program-tests/utils/src/mint_assert.rs index 50d636c786..37d4ada41e 100644 --- a/program-tests/utils/src/mint_assert.rs +++ b/program-tests/utils/src/mint_assert.rs @@ -45,7 +45,7 @@ pub fn assert_compressed_mint_account( metadata: CompressedMintMetadata { version: 3, mint: spl_mint_pda.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, extensions: expected_extensions, }; diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index ad5608b6c5..3e2e475f7c 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -428,6 +428,45 @@ pub enum ErrorCode { "CompressAndClose by compression authority requires compressed token account in outputs" )] CompressAndCloseOutputMissing, + // CMint (decompressed compressed mint) specific errors + #[msg("Missing mint signer account for mint action")] + MintActionMissingMintSigner, + #[msg("Missing CMint account for decompress mint action")] + MintActionMissingCMintAccount, + #[msg("CMint account already exists")] + CMintAlreadyExists, + #[msg("Invalid CMint account owner")] + InvalidCMintOwner, + #[msg("Failed to deserialize CMint account data")] + CMintDeserializationFailed, + #[msg("Failed to resize CMint account")] + CMintResizeFailed, + // CMint Compressibility errors + #[msg("Invalid rent payment - must be >= 2 (CMint is always compressible)")] + InvalidRentPayment, + #[msg("Missing compressible config account for CMint")] + MissingCompressibleConfig, + #[msg("Missing rent sponsor account for CMint")] + MissingRentSponsor, + #[msg("Rent payment exceeds max funded epochs")] + RentPaymentExceedsMax, + #[msg("Write top-up exceeds maximum allowed")] + WriteTopUpExceedsMaximum, + #[msg("Failed to calculate CMint top-up amount")] + CMintTopUpCalculationFailed, + // CompressAndCloseCMint specific errors + #[msg("CMint is not decompressed")] + CMintNotDecompressed, + #[msg("CMint is missing Compressible extension")] + CMintMissingCompressibleExtension, + #[msg("CMint is not compressible (rent not expired)")] + CMintNotCompressible, + #[msg("Cannot combine DecompressMint and CompressAndCloseCMint in same instruction")] + CannotDecompressAndCloseInSameInstruction, + #[msg("CMint account does not match compressed_mint.metadata.mint")] + InvalidCMintAccount, + #[msg("Mint data required in instruction when not decompressed")] + MintDataRequired, } impl From for ProgramError { diff --git a/programs/compressed-token/program/docs/instructions/MINT_ACTION.md b/programs/compressed-token/program/docs/instructions/MINT_ACTION.md index 0642a1dd57..2bba0aa474 100644 --- a/programs/compressed-token/program/docs/instructions/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/instructions/MINT_ACTION.md @@ -75,16 +75,16 @@ Optional accounts (based on configuration): For execution (when not writing to CPI context): 4. mint - - (mutable) - optional, required if spl_mint_initialized=true + - (mutable) - optional, required for SPL mint supply synchronization - SPL Token 2022 mint account for supply synchronization 5. token_pool_pda - - (mutable) - optional, required if spl_mint_initialized=true + - (mutable) - optional, required for SPL mint supply synchronization - Token pool PDA that holds SPL tokens backing compressed supply - Derivation: [mint, token_pool_index] with token_pool_bump 6. token_program - - non-mutable - optional, required if spl_mint_initialized=true + - non-mutable - optional, required for SPL mint supply synchronization - Must be SPL Token 2022 program (validated in accounts.rs:126) 7-12. Light system accounts (standard set): diff --git a/programs/compressed-token/program/src/ctoken_burn.rs b/programs/compressed-token/program/src/ctoken_burn.rs new file mode 100644 index 0000000000..e3251b15a2 --- /dev/null +++ b/programs/compressed-token/program/src/ctoken_burn.rs @@ -0,0 +1,58 @@ +use anchor_lang::solana_program::{msg, program_error::ProgramError}; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use pinocchio_token_program::processor::burn::process_burn; + +use crate::shared::compressible_top_up::calculate_and_execute_compressible_top_ups; + +/// Process ctoken burn instruction +/// +/// Instruction data format (same as CTokenTransfer/CTokenMintTo): +/// - 8 bytes: amount (legacy, no max_top_up enforcement) +/// - 10 bytes: amount + max_top_up (u16, 0 = no limit) +/// +/// Account layout: +/// 0: source CToken account (writable) +/// 1: CMint account (writable) +/// 2: authority (signer, also payer for top-ups) +#[profile] +#[inline(always)] +pub fn process_ctoken_burn( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + if accounts.len() < 3 { + msg!( + "CToken burn: expected at least 3 accounts received {}", + accounts.len() + ); + return Err(ProgramError::NotEnoughAccountKeys); + } + + if instruction_data.len() < 8 { + return Err(ProgramError::InvalidInstructionData); + } + + // Parse max_top_up + let max_top_up = match instruction_data.len() { + 8 => 0u16, + 10 => u16::from_le_bytes( + instruction_data[8..10] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + // Call pinocchio burn - handles balance/supply updates, authority check, frozen check + process_burn(accounts, &instruction_data[..8]) + .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; + + // Calculate and execute top-ups for both CMint and CToken + // burn account order: [ctoken, cmint, authority] - reverse of mint_to + let ctoken = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + let cmint = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + + calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) +} diff --git a/programs/compressed-token/program/src/ctoken_mint_to.rs b/programs/compressed-token/program/src/ctoken_mint_to.rs new file mode 100644 index 0000000000..453bf0e9b6 --- /dev/null +++ b/programs/compressed-token/program/src/ctoken_mint_to.rs @@ -0,0 +1,58 @@ +use anchor_lang::solana_program::{msg, program_error::ProgramError}; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use pinocchio_token_program::processor::mint_to::process_mint_to; + +use crate::shared::compressible_top_up::calculate_and_execute_compressible_top_ups; + +/// Process ctoken mint_to instruction +/// +/// Instruction data format (same as CTokenTransfer): +/// - 8 bytes: amount (legacy, no max_top_up enforcement) +/// - 10 bytes: amount + max_top_up (u16, 0 = no limit) +/// +/// Account layout: +/// 0: CMint account (writable) +/// 1: destination CToken account (writable) +/// 2: authority (signer, also payer for top-ups) +#[profile] +#[inline(always)] +pub fn process_ctoken_mint_to( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + if accounts.len() < 3 { + msg!( + "CToken mint_to: expected at least 3 accounts received {}", + accounts.len() + ); + return Err(ProgramError::NotEnoughAccountKeys); + } + + if instruction_data.len() < 8 { + return Err(ProgramError::InvalidInstructionData); + } + + // Parse max_top_up (same pattern as ctoken_transfer.rs) + let max_top_up = match instruction_data.len() { + 8 => 0u16, + 10 => u16::from_le_bytes( + instruction_data[8..10] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + // Call pinocchio mint_to - handles supply/balance updates, authority check, frozen check + process_mint_to(accounts, &instruction_data[..8]) + .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; + + // Calculate and execute top-ups for both CMint and CToken + // mint_to account order: [cmint, ctoken, authority] + let cmint = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + + calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) +} diff --git a/programs/compressed-token/program/src/ctoken_transfer.rs b/programs/compressed-token/program/src/ctoken_transfer.rs index 7182a5f049..3a12629eb0 100644 --- a/programs/compressed-token/program/src/ctoken_transfer.rs +++ b/programs/compressed-token/program/src/ctoken_transfer.rs @@ -49,7 +49,7 @@ pub fn process_ctoken_transfer( }; // Only pass the first 8 bytes (amount) to the SPL transfer processor - process_transfer(accounts, &instruction_data[..8]) + process_transfer(accounts, &instruction_data[..8], false) .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; calculate_and_execute_top_up_transfers(accounts, max_top_up) } diff --git a/programs/compressed-token/program/src/extensions/mod.rs b/programs/compressed-token/program/src/extensions/mod.rs index 34564edf6d..487222df77 100644 --- a/programs/compressed-token/program/src/extensions/mod.rs +++ b/programs/compressed-token/program/src/extensions/mod.rs @@ -3,13 +3,10 @@ pub mod token_metadata; // Import from ctoken-types instead of local modules use light_ctoken_interface::{ - instructions::{ - extensions::{ZExtensionInstructionData, ZTokenMetadataInstructionData}, - mint_action::ZAction, - }, + instructions::mint_action::ZAction, state::{ - AdditionalMetadataConfig, ExtensionStructConfig, TokenMetadata, TokenMetadataConfig, - ZAdditionalMetadata, + AdditionalMetadata, AdditionalMetadataConfig, ExtensionStruct, ExtensionStructConfig, + TokenMetadata, TokenMetadataConfig, }, CTokenError, }; @@ -17,24 +14,36 @@ use light_program_profiler::profile; use light_zero_copy::ZeroCopyNew; use spl_pod::solana_msg::msg; +/// Returns true if extension should be included in compressed account output. +#[inline(always)] +pub fn should_include_in_compressed_output(extension: &ExtensionStruct) -> bool { + matches!(extension, ExtensionStruct::TokenMetadata(_)) +} + /// Action-aware version that calculates maximum sizes needed for field updates /// Returns: (has_extensions, extension_configs, additional_data_len) #[profile] pub fn process_extensions_config_with_actions( - extensions: Option<&Vec>, + extensions: Option<&Vec>, actions: &[ZAction], ) -> Result<(bool, Vec, usize), CTokenError> { - if let Some(extensions) = extensions { - let mut additional_mint_data_len = 0; - let mut config_vec = Vec::new(); + let mut additional_mint_data_len = 0; + let mut config_vec = Vec::new(); + // Process existing extensions from state + // NOTE: Compressible extension is NOT included in compressed account output. + // It only lives in the CMint Solana account. + if let Some(extensions) = extensions { for (extension_index, extension) in extensions.iter().enumerate() { + if !should_include_in_compressed_output(extension) { + continue; + } match extension { - ZExtensionInstructionData::TokenMetadata(token_metadata_data) => { + ExtensionStruct::TokenMetadata(token_metadata) => { process_token_metadata_config_with_actions( &mut additional_mint_data_len, &mut config_vec, - token_metadata_data, + token_metadata, actions, extension_index, )? @@ -42,44 +51,48 @@ pub fn process_extensions_config_with_actions( _ => return Err(CTokenError::UnsupportedExtension), } } - Ok((true, config_vec, additional_mint_data_len)) - } else { - Ok((false, Vec::new(), 0)) } + + // NOTE: DecompressMint does NOT add Compressible to compressed account output. + // Compressible extension only lives in the CMint Solana account, not in the compressed account. + // The CMint sync logic handles adding Compressible when writing to CMint. + + let has_extensions = !config_vec.is_empty(); + Ok((has_extensions, config_vec, additional_mint_data_len)) } fn process_token_metadata_config_with_actions( additional_mint_data_len: &mut usize, config_vec: &mut Vec, - token_metadata_data: &ZTokenMetadataInstructionData<'_>, + token_metadata: &TokenMetadata, actions: &[ZAction], extension_index: usize, ) -> Result<(), CTokenError> { // Early validation - no allocations needed - if let Some(ref additional_metadata) = token_metadata_data.additional_metadata { - if additional_metadata.len() > 20 { - msg!( - "Too many additional metadata elements: {} (max 20)", - additional_metadata.len() - ); - return Err(CTokenError::TooManyAdditionalMetadata); - } + if token_metadata.additional_metadata.len() > 20 { + msg!( + "Too many additional metadata elements: {} (max 20)", + token_metadata.additional_metadata.len() + ); + return Err(CTokenError::TooManyAdditionalMetadata); + } - // Check for duplicate keys (O(n²) but acceptable for max 20 items) - for i in 0..additional_metadata.len() { - for j in (i + 1)..additional_metadata.len() { - if additional_metadata[i].key == additional_metadata[j].key { - msg!("Duplicate metadata key found at positions {} and {}", i, j); - return Err(CTokenError::DuplicateMetadataKey); - } + // Check for duplicate keys (O(n^2) but acceptable for max 20 items) + for i in 0..token_metadata.additional_metadata.len() { + for j in (i + 1)..token_metadata.additional_metadata.len() { + if token_metadata.additional_metadata[i].key + == token_metadata.additional_metadata[j].key + { + msg!("Duplicate metadata key found at positions {} and {}", i, j); + return Err(CTokenError::DuplicateMetadataKey); } } } // Single-pass state accumulator - track final sizes directly - let mut final_name_len = token_metadata_data.name.len(); - let mut final_symbol_len = token_metadata_data.symbol.len(); - let mut final_uri_len = token_metadata_data.uri.len(); + let mut final_name_len = token_metadata.name.len(); + let mut final_symbol_len = token_metadata.symbol.len(); + let mut final_uri_len = token_metadata.uri.len(); // Apply actions sequentially to determine final field sizes (last action wins) for action in actions.iter() { @@ -97,7 +110,7 @@ fn process_token_metadata_config_with_actions( // Build metadata config directly without intermediate collections let additional_metadata_configs = build_metadata_config( - token_metadata_data.additional_metadata.as_ref(), + &token_metadata.additional_metadata, actions, extension_index, ); @@ -118,7 +131,7 @@ fn process_token_metadata_config_with_actions( /// Processes all possible keys and determines final state (SPL Token-2022 compatible) #[inline(always)] fn build_metadata_config( - metadata: Option<&Vec>>, + metadata: &[AdditionalMetadata], actions: &[ZAction], extension_index: usize, ) -> Vec { @@ -127,8 +140,7 @@ fn build_metadata_config( let should_add_key = |key: &[u8]| -> bool { // Key exists if it's in original metadata OR added via UpdateMetadataField - let exists_in_original = - metadata.is_some_and(|items| items.iter().any(|item| item.key == key)); + let exists_in_original = metadata.iter().any(|item| item.key == key); let added_via_update = actions.iter().any(|action| { matches!(action, ZAction::UpdateMetadataField(update) if update.extension_index as usize == extension_index @@ -148,30 +160,28 @@ fn build_metadata_config( }; // Process all original metadata keys - if let Some(items) = metadata { - for item in items.iter() { - if should_add_key(item.key) { - let final_value_len = actions - .iter() - .rev() - .find_map(|action| match action { - ZAction::UpdateMetadataField(update) - if update.extension_index as usize == extension_index - && update.field_type == 3 - && update.key == item.key => - { - Some(update.value.len()) - } - _ => None, - }) - .unwrap_or(item.value.len()); - - configs.push(AdditionalMetadataConfig { - key: item.key.len() as u32, - value: final_value_len as u32, - }); - processed_keys.push(item.key); - } + for item in metadata.iter() { + if should_add_key(&item.key) { + let final_value_len = actions + .iter() + .rev() + .find_map(|action| match action { + ZAction::UpdateMetadataField(update) + if update.extension_index as usize == extension_index + && update.field_type == 3 + && update.key == item.key => + { + Some(update.value.len()) + } + _ => None, + }) + .unwrap_or(item.value.len()); + + configs.push(AdditionalMetadataConfig { + key: item.key.len() as u32, + value: final_value_len as u32, + }); + processed_keys.push(&item.key); } } diff --git a/programs/compressed-token/program/src/extensions/processor.rs b/programs/compressed-token/program/src/extensions/processor.rs index 7c7d316703..2083ef34a6 100644 --- a/programs/compressed-token/program/src/extensions/processor.rs +++ b/programs/compressed-token/program/src/extensions/processor.rs @@ -1,9 +1,11 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; -use light_ctoken_interface::state::ZExtensionStructMut; +use light_ctoken_interface::{ + instructions::extensions::ZExtensionInstructionData, state::ZExtensionStructMut, +}; use light_program_profiler::profile; -use crate::extensions::{token_metadata::create_output_token_metadata, ZExtensionInstructionData}; +use crate::extensions::token_metadata::create_output_token_metadata; /// Set extensions state in output compressed account. /// Compute extensions hash chain. diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index ca487edda6..fc0950fc0e 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -10,6 +10,8 @@ pub mod close_token_account; pub mod convert_account_infos; pub mod create_associated_token_account; pub mod create_token_account; +pub mod ctoken_burn; +pub mod ctoken_mint_to; pub mod ctoken_transfer; pub mod extensions; pub mod mint_action; @@ -25,11 +27,13 @@ use create_associated_token_account::{ process_create_associated_token_account, process_create_associated_token_account_idempotent, }; use create_token_account::process_create_token_account; +use ctoken_mint_to::process_ctoken_mint_to; use ctoken_transfer::process_ctoken_transfer; use withdraw_funding_pool::process_withdraw_funding_pool; use crate::{ - convert_account_infos::convert_account_infos, mint_action::processor::process_mint_action, + convert_account_infos::convert_account_infos, ctoken_burn::process_ctoken_burn, + mint_action::processor::process_mint_action, }; pub const LIGHT_CPI_SIGNER: CpiSigner = @@ -44,6 +48,10 @@ pub(crate) const MAX_PACKED_ACCOUNTS: usize = 40; pub enum InstructionType { /// CToken transfer CTokenTransfer = 3, + /// CToken mint_to - mint from decompressed CMint to CToken with top-ups + CTokenMintTo = 7, + /// CToken burn - burn from CToken, update CMint supply, with top-ups + CTokenBurn = 8, /// CToken CloseAccount CloseTokenAccount = 9, /// Create CToken, equivalent to SPL Token InitializeAccount3 @@ -79,6 +87,8 @@ impl From for InstructionType { fn from(value: u8) -> Self { match value { 3 => InstructionType::CTokenTransfer, + 7 => InstructionType::CTokenMintTo, + 8 => InstructionType::CTokenBurn, 9 => InstructionType::CloseTokenAccount, 18 => InstructionType::CreateTokenAccount, 100 => InstructionType::CreateAssociatedCTokenAccount, @@ -114,26 +124,34 @@ pub fn process_instruction( // msg!("CTokenTransfer"); process_ctoken_transfer(accounts, &instruction_data[1..])?; } - InstructionType::CreateAssociatedCTokenAccount => { - msg!("CreateAssociatedCTokenAccount"); - process_create_associated_token_account(accounts, &instruction_data[1..])?; + InstructionType::CTokenMintTo => { + msg!("CTokenMintTo"); + process_ctoken_mint_to(accounts, &instruction_data[1..])?; } - InstructionType::CreateAssociatedTokenAccountIdempotent => { - msg!("CreateAssociatedTokenAccountIdempotent"); - process_create_associated_token_account_idempotent(accounts, &instruction_data[1..])?; + InstructionType::CTokenBurn => { + msg!("CTokenBurn"); + process_ctoken_burn(accounts, &instruction_data[1..])?; + } + InstructionType::CloseTokenAccount => { + msg!("CloseTokenAccount"); + process_close_token_account(accounts, &instruction_data[1..])?; } InstructionType::CreateTokenAccount => { msg!("CreateTokenAccount"); process_create_token_account(accounts, &instruction_data[1..])?; } - InstructionType::CloseTokenAccount => { - msg!("CloseTokenAccount"); - process_close_token_account(accounts, &instruction_data[1..])?; + InstructionType::CreateAssociatedCTokenAccount => { + msg!("CreateAssociatedCTokenAccount"); + process_create_associated_token_account(accounts, &instruction_data[1..])?; } InstructionType::Transfer2 => { msg!("Transfer2"); process_transfer2(accounts, &instruction_data[1..])?; } + InstructionType::CreateAssociatedTokenAccountIdempotent => { + msg!("CreateAssociatedTokenAccountIdempotent"); + process_create_associated_token_account_idempotent(accounts, &instruction_data[1..])?; + } InstructionType::MintAction => { msg!("MintAction"); process_mint_action(accounts, &instruction_data[1..])?; diff --git a/programs/compressed-token/program/src/mint_action/accounts.rs b/programs/compressed-token/program/src/mint_action/accounts.rs index 780c0523aa..36ae0594e6 100644 --- a/programs/compressed-token/program/src/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/mint_action/accounts.rs @@ -1,4 +1,4 @@ -use anchor_compressed_token::{check_spl_token_pool_derivation_with_index, ErrorCode}; +use anchor_compressed_token::ErrorCode; use anchor_lang::solana_program::program_error::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_ctoken_interface::{ @@ -38,15 +38,16 @@ pub struct MintActionAccounts<'info> { pub packed_accounts: ProgramPackedAccounts<'info, AccountInfo>, } -/// Reqired accounts to execute an instruction +/// Required accounts to execute an instruction /// with or without cpi context. pub struct ExecutingAccounts<'info> { - /// Spl mint acccount. - pub mint: Option<&'info AccountInfo>, - /// Ctoken pool pda, spl token account. - pub token_pool_pda: Option<&'info AccountInfo>, - /// Spl token 2022 program. - pub token_program: Option<&'info AccountInfo>, + /// CompressibleConfig account - required when creating CMint (always compressible). + pub compressible_config: Option<&'info AccountInfo>, + /// CMint Solana account (decompressed compressed mint). + /// Required for DecompressMint action and when syncing with existing CMint. + pub cmint: Option<&'info AccountInfo>, + /// Rent sponsor PDA - required when creating CMint (pays for account). + pub rent_sponsor: Option<&'info AccountInfo>, pub system: LightSystemAccounts<'info>, /// Out output queue for the compressed mint account. pub out_output_queue: &'info AccountInfo, @@ -64,17 +65,23 @@ pub struct ExecutingAccounts<'info> { impl<'info> MintActionAccounts<'info> { #[profile] + #[track_caller] pub fn validate_and_parse( accounts: &'info [AccountInfo], config: &AccountsConfig, - cmint_pubkey: &solana_pubkey::Pubkey, + cmint_pubkey: Option<&solana_pubkey::Pubkey>, token_pool_index: u8, token_pool_bump: u8, ) -> Result { let mut iter = AccountIterator::new(accounts); let light_system_program = iter.next_account("light_system_program")?; - let mint_signer = iter.next_option_signer("mint_signer", config.with_mint_signer)?; + // mint_signer needs to sign for create_mint/create_spl_mint, but not for decompress_mint + let mint_signer = if config.mint_signer_must_sign() { + iter.next_option_signer("mint_signer", config.with_mint_signer)? + } else { + iter.next_option("mint_signer", config.with_mint_signer)? + }; // Static non-CPI accounts first // Authority is always required to sign let authority = iter.next_signer("authority")?; @@ -94,10 +101,17 @@ impl<'info> MintActionAccounts<'info> { packed_accounts: ProgramPackedAccounts { accounts: &[] }, }) } else { - let mint = iter.next_option_mut("mint", config.spl_mint_initialized)?; - let token_pool_pda = - iter.next_option_mut("token_pool_pda", config.spl_mint_initialized)?; - let token_program = iter.next_option("token_program", config.spl_mint_initialized)?; + // Parse compressible config when creating or closing CMint + let compressible_config = + iter.next_option("compressible_config", config.needs_compressible_accounts())?; + + // CMint account required if already decompressed (for sync) OR being decompressed/closed + let cmint = iter.next_option_mut("cmint", config.needs_cmint_account())?; + + // Parse rent_sponsor when creating or closing CMint + let rent_sponsor = + iter.next_option_mut("rent_sponsor", config.needs_compressible_accounts())?; + let system = LightSystemAccounts::validate_and_parse( &mut iter, false, @@ -121,14 +135,15 @@ impl<'info> MintActionAccounts<'info> { // Only needed for minting to compressed token accounts let tokens_out_queue = iter.next_option("tokens_out_queue", config.has_mint_to_actions)?; + let mint_accounts = MintActionAccounts { mint_signer, light_system_program, authority, executing: Some(ExecutingAccounts { - mint, - token_pool_pda, - token_program, + compressible_config, + cmint, + rent_sponsor, system, in_merkle_tree, address_merkle_tree, @@ -196,23 +211,19 @@ impl<'info> MintActionAccounts<'info> { } if let Some(executing) = &self.executing { - // mint (optional) - if executing.mint.is_some() { + // compressible_config (optional) - when creating CMint + if executing.compressible_config.is_some() { offset += 1; } - - // token_pool_pda (optional) - if executing.token_pool_pda.is_some() { + // cmint (optional) - comes before rent_sponsor + if executing.cmint.is_some() { offset += 1; } - - // token_program (optional) - if executing.token_program.is_some() { + // rent_sponsor (optional) - when creating CMint + if executing.rent_sponsor.is_some() { offset += 1; } - - // LightSystemAccounts - these are the CPI accounts that start here - // We don't add them to offset since this is where CPI accounts begin + // LightSystemAccounts - CPI accounts start here } // write_to_cpi_context_system - these are the CPI accounts that start here // We don't add them to offset since this is where CPI accounts begin @@ -285,54 +296,26 @@ impl<'info> MintActionAccounts<'info> { false } + /// Get CMint account if present in executing accounts. + pub fn get_cmint(&self) -> Option<&'info AccountInfo> { + self.executing.as_ref().and_then(|exec| exec.cmint) + } + pub fn validate_accounts( &self, - cmint_pubkey: &solana_pubkey::Pubkey, - token_pool_index: u8, - token_pool_bump: u8, + cmint_pubkey: Option<&solana_pubkey::Pubkey>, + _token_pool_index: u8, //TODO: remove + _token_pool_bump: u8, ) -> Result<(), ProgramError> { let accounts = self .executing .as_ref() .ok_or(ProgramError::NotEnoughAccountKeys)?; - // Validate token program is SPL Token 2022 - if let Some(token_program) = accounts.token_program.as_ref() { - if *token_program.key() != spl_token_2022::ID.to_bytes() { - msg!( - "invalid token program {:?} expected {:?}", - solana_pubkey::Pubkey::new_from_array(*token_program.key()), - spl_token_2022::ID - ); - return Err(ProgramError::InvalidAccountData); - } - } - // Validate token pool PDA is correct using provided bump and index - if let Some(token_pool_pda) = accounts.token_pool_pda { - let token_pool_pubkey_solana = - solana_pubkey::Pubkey::new_from_array(*token_pool_pda.key()); - - check_spl_token_pool_derivation_with_index( - &token_pool_pubkey_solana, - cmint_pubkey, - token_pool_index, - Some(token_pool_bump), - ) - .map_err(|_| { - msg!( - "invalid token pool PDA {:?} for mint {:?} with index {} and bump {}", - token_pool_pubkey_solana, - cmint_pubkey, - token_pool_index, - token_pool_bump - ); - ProgramError::InvalidAccountData - })?; - } - - if let Some(mint_account) = accounts.mint { - // Verify mint account matches expected mint - if cmint_pubkey.to_bytes() != *mint_account.key() { + // When cmint_pubkey is provided, verify CMint account matches + // When None (mint data from CMint), skip - CMint is validated when reading its data + if let (Some(cmint_account), Some(expected_pubkey)) = (accounts.cmint, cmint_pubkey) { + if expected_pubkey.to_bytes() != *cmint_account.key() { return Err(ErrorCode::MintAccountMismatch.into()); } } @@ -361,24 +344,55 @@ pub struct AccountsConfig { pub with_cpi_context: bool, /// 2. cpi context.first_set() || cpi context.set() pub write_to_cpi_context: bool, - /// 4. SPL mint is either: - /// 4.1. already initialized - /// 4.2. or is initialized in this instruction - // TODO: SPL token accounts (mint, token_pool_pda, token_program) are required when - // spl_mint_initialized is true, but they are only actually used for MintToCompressed, - // MintToCToken, or CreateSplMint actions. For authority/metadata update actions - // (UpdateMintAuthority, UpdateFreezeAuthority, UpdateMetadataField, etc.), the SPL - // accounts are not needed. This cannot be tested until associated SPL mint is supported. - pub spl_mint_initialized: bool, + /// 4. Whether the compressed mint has been decompressed to a CMint Solana account. + /// When true, the CMint account is the source of truth and must be synced. + pub cmint_decompressed: bool, /// 5. Mint pub has_mint_to_actions: bool, /// 6. Either compressed mint and/or spl mint is created. pub with_mint_signer: bool, /// 7. Compressed mint is created. pub create_mint: bool, + /// 8. Has DecompressMint action + pub has_decompress_mint_action: bool, + /// 9. Has CompressAndCloseCMint action + pub has_compress_and_close_cmint_action: bool, } impl AccountsConfig { + /// Returns true when CMint Solana account is the source of truth for mint data. + /// This is the case when the mint is decompressed (or being decompressed) and not being closed. + /// When true, compressed account uses zero sentinel values (discriminator=[0;8], data_hash=[0;32]). + #[inline(always)] + pub fn cmint_is_source_of_truth(&self) -> bool { + (self.has_decompress_mint_action || self.cmint_decompressed) + && !self.has_compress_and_close_cmint_action + } + + /// Returns true if compressible extension accounts are needed. + /// Required for DecompressMint and CompressAndCloseCMint actions. + #[inline(always)] + pub fn needs_compressible_accounts(&self) -> bool { + self.has_decompress_mint_action || self.has_compress_and_close_cmint_action + } + + /// Returns true if CMint account is needed in the transaction. + /// Required when: already decompressed, decompressing, or compressing and closing CMint. + #[inline(always)] + pub fn needs_cmint_account(&self) -> bool { + self.cmint_decompressed + || self.has_decompress_mint_action + || self.has_compress_and_close_cmint_action + } + + /// Returns true if mint_signer must be a signer. + /// Required for create_mint and create_spl_mint, but NOT for decompress_mint. + /// decompress_mint only needs mint_signer.key() for PDA derivation. + #[inline(always)] + pub fn mint_signer_must_sign(&self) -> bool { + self.with_mint_signer && !self.has_decompress_mint_action + } + /// Initialize AccountsConfig based in instruction data. - #[profile] pub fn new( @@ -410,15 +424,34 @@ impl AccountsConfig { .iter() .any(|action| matches!(action, ZAction::CreateSplMint(_))); - // We need mint signer if create mint, and create spl mint. - let with_mint_signer = parsed_instruction_data.create_mint.is_some() || create_spl_mint; - // Scenarios: - // 1. mint is already decompressed - // 2. mint is decompressed in this instruction - let spl_mint_initialized = - parsed_instruction_data.mint.metadata.spl_mint_initialized() || create_spl_mint; + // Check if DecompressMint action is present + let has_decompress_mint_action = parsed_instruction_data + .actions + .iter() + .any(|action| matches!(action, ZAction::DecompressMint(_))); + + // Check if CompressAndCloseCMint action is present + let has_compress_and_close_cmint_action = parsed_instruction_data + .actions + .iter() + .any(|action| matches!(action, ZAction::CompressAndCloseCMint(_))); - if parsed_instruction_data.mint.metadata.spl_mint_initialized() && create_spl_mint { + // Validation: Cannot combine DecompressMint and CompressAndCloseCMint in the same instruction + if has_decompress_mint_action && has_compress_and_close_cmint_action { + msg!("Cannot combine DecompressMint and CompressAndCloseCMint in the same instruction"); + return Err(ErrorCode::CannotDecompressAndCloseInSameInstruction.into()); + } + + // We need mint signer if create mint, create spl mint, or decompress mint. + // CompressAndCloseCMint does NOT need mint_signer - it verifies CMint by compressed_mint.metadata.mint + let with_mint_signer = parsed_instruction_data.create_mint.is_some() + || create_spl_mint + || has_decompress_mint_action; + // CMint account needed for sync when mint is already decompressed (metadata flag) + // When mint is None, it means CMint is decompressed (data lives in CMint account) + let cmint_decompressed = parsed_instruction_data.mint.is_none(); + + if cmint_decompressed && create_spl_mint { return Err(ProgramError::InvalidInstructionData); } @@ -436,22 +469,28 @@ impl AccountsConfig { msg!("Create spl mint not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } + if has_decompress_mint_action || cmint_decompressed { + msg!("Decompress mint not allowed when writing to cpi context"); + return Err(ErrorCode::CpiContextSetNotUsable.into()); + } let has_mint_to_actions = parsed_instruction_data .actions .iter() .any(|action| matches!(action, ZAction::MintToCompressed(_))); - if spl_mint_initialized && has_mint_to_actions { - msg!("Mint to compressed not allowed if associated spl mint exists when writing to cpi context"); + if cmint_decompressed && has_mint_to_actions { + msg!("Mint to compressed not allowed if cmint decompressed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } Ok(AccountsConfig { with_cpi_context, write_to_cpi_context, - spl_mint_initialized, + cmint_decompressed, has_mint_to_actions, with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), + has_decompress_mint_action, + has_compress_and_close_cmint_action, }) } else { // For MintToCompressed actions @@ -465,10 +504,12 @@ impl AccountsConfig { Ok(AccountsConfig { with_cpi_context, write_to_cpi_context, - spl_mint_initialized, + cmint_decompressed, has_mint_to_actions, with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), + has_decompress_mint_action, + has_compress_and_close_cmint_action, }) } } diff --git a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs new file mode 100644 index 0000000000..1a5cb3d606 --- /dev/null +++ b/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs @@ -0,0 +1,148 @@ +use anchor_compressed_token::ErrorCode; +use anchor_lang::prelude::ProgramError; +use light_ctoken_interface::{ + instructions::mint_action::ZCompressAndCloseCMintAction, + state::{CompressedMint, ExtensionStruct}, +}; +use light_program_profiler::profile; +#[cfg(target_os = "solana")] +use pinocchio::sysvars::{clock::Clock, Sysvar}; +use spl_pod::solana_msg::msg; + +use crate::{ + mint_action::accounts::MintActionAccounts, + shared::{convert_program_error, transfer_lamports::transfer_lamports}, +}; + +/// Processes the CompressAndCloseCMint action by compressing and closing a CMint Solana account. +/// The compressed mint state is always preserved. +/// +/// ## Process Steps +/// 1. **Idempotent Check**: If idempotent flag is set and CMint doesn't exist, succeed silently +/// 2. **State Validation**: Ensure CMint exists (cmint_decompressed = true) +/// 3. **CMint Verification**: Verify CMint account matches compressed_mint.metadata.mint +/// 4. **Extension Validation**: Ensure CMint has Compressible extension +/// 5. **Compressibility Check**: Verify is_compressible() returns true +/// 6. **Lamport Distribution**: ALL lamports -> rent_sponsor +/// 7. **Account Closure**: Assign to system program, resize to 0 +/// 8. **Flag Update**: Set cmint_decompressed = false +/// 9. **Remove Compressible Extension**: Remove from compressed mint extensions +/// +/// ## Note +/// CompressAndCloseCMint is **permissionless** - anyone can compress and close a CMint +/// provided is_compressible() returns true. All lamports are returned to rent_sponsor. +#[profile] +pub fn process_compress_and_close_cmint_action( + action: &ZCompressAndCloseCMintAction, + compressed_mint: &mut CompressedMint, + validated_accounts: &MintActionAccounts, +) -> Result<(), ProgramError> { + // 1. Check idempotent flag - if CMint doesn't exist and idempotent is set, succeed silently + if action.idempotent != 0 && !compressed_mint.metadata.cmint_decompressed { + // CMint doesn't exist, but idempotent flag is set - succeed silently + return Ok(()); + } + + // 2. Check CMint exists (is decompressed) + if !compressed_mint.metadata.cmint_decompressed { + msg!("CMint does not exist (cmint_decompressed = false)"); + return Err(ErrorCode::CMintNotDecompressed.into()); + } + + let executing = validated_accounts + .executing + .as_ref() + .ok_or(ErrorCode::MintActionMissingExecutingAccounts)?; + + let cmint = executing + .cmint + .ok_or(ErrorCode::MintActionMissingCMintAccount)?; + + let rent_sponsor = executing + .rent_sponsor + .ok_or(ErrorCode::MissingRentSponsor)?; + + // 3. Verify CMint account matches compressed_mint.metadata.mint + if cmint.key() != &compressed_mint.metadata.mint.to_bytes() { + msg!("CMint account does not match compressed_mint.metadata.mint"); + return Err(ErrorCode::InvalidCMintAccount.into()); + } + + // 4. Get Compressible extension (required) + let compression_info = compressed_mint + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(info), + _ => None, + }) + }) + .ok_or_else(|| { + msg!("CMint does not have Compressible extension"); + ErrorCode::CMintMissingCompressibleExtension + })?; + + // 5. Verify rent_sponsor matches extension + if rent_sponsor.key() != &compression_info.rent_sponsor { + msg!("Rent sponsor does not match extension"); + return Err(ErrorCode::InvalidRentSponsor.into()); + } + + // 7. Check is_compressible (rent has expired) + #[cfg(target_os = "solana")] + let current_slot = Clock::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .slot; + #[cfg(not(target_os = "solana"))] + let _current_slot = 1u64; + + #[cfg(target_os = "solana")] + { + let is_compressible = compression_info + .is_compressible(cmint.data_len() as u64, current_slot, cmint.lamports()) + .map_err(|_| ProgramError::InvalidAccountData)?; + + if is_compressible.is_none() { + msg!("CMint is not compressible (rent not expired)"); + return Err(ErrorCode::CMintNotCompressible.into()); + } + } + + // 6. Transfer all lamports to rent_sponsor + let cmint_lamports = cmint.lamports(); + if cmint_lamports > 0 { + transfer_lamports(cmint_lamports, cmint, rent_sponsor).map_err(convert_program_error)?; + } + + // 7. Close account (assign to system program, resize to 0) + unsafe { + cmint.assign(&[0u8; 32]); + } + cmint + .resize(0) + .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000))?; + + // 8. Set cmint_decompressed = false + compressed_mint.metadata.cmint_decompressed = false; + + // 9. Remove Compressible extension from compressed mint + let extensions = compressed_mint + .extensions + .as_mut() + .ok_or(ErrorCode::CMintMissingCompressibleExtension)?; + + if extensions.len() == 1 { + // Only Compressible extension exists, just set to None + compressed_mint.extensions = None; + } else { + // Find and remove Compressible extension + let pos = extensions + .iter() + .position(|e| matches!(e, ExtensionStruct::Compressible(_))) + .ok_or(ErrorCode::CMintMissingCompressibleExtension)?; + extensions.remove(pos); + } + + Ok(()) +} diff --git a/programs/compressed-token/program/src/mint_action/actions/create_mint.rs b/programs/compressed-token/program/src/mint_action/actions/create_mint.rs index 9d79a51ab6..0aef09ed01 100644 --- a/programs/compressed-token/program/src/mint_action/actions/create_mint.rs +++ b/programs/compressed-token/program/src/mint_action/actions/create_mint.rs @@ -18,6 +18,12 @@ pub fn process_create_mint_action( cpi_instruction_struct: &mut ZInstructionDataInvokeCpiWithReadOnlyMut<'_>, address_merkle_tree_account_index: u8, ) -> Result<(), ProgramError> { + // Mint data is required for create mint action + let mint = parsed_instruction_data + .mint + .as_ref() + .ok_or(ErrorCode::MintDataRequired)?; + // 1. Derive compressed mint address without bump to ensure // that only one mint per seed can be created. let spl_mint_pda = solana_pubkey::Pubkey::find_program_address( @@ -32,10 +38,7 @@ pub fn process_create_mint_action( .as_ref() .ok_or(ProgramError::InvalidInstructionData)?; - if !pubkey_eq( - &spl_mint_pda, - parsed_instruction_data.mint.metadata.mint.array_ref(), - ) { + if !pubkey_eq(&spl_mint_pda, mint.metadata.mint.array_ref()) { msg!("Invalid mint PDA derivation"); return Err(ErrorCode::MintActionInvalidMintPda.into()); } @@ -77,7 +80,7 @@ pub fn process_create_mint_action( address_merkle_tree_account_index, ); // Validate mint parameters - if parsed_instruction_data.mint.supply != 0 { + if mint.supply != 0 { msg!("Initial supply must be 0 for new mint creation"); return Err(ErrorCode::MintActionInvalidInitialSupply.into()); } @@ -86,22 +89,19 @@ pub fn process_create_mint_action( // Version 3 (ShaFlat) is required for new mints because: // 1. Only SHA256 hashing is implemented for compressed mints // 2. Version 3 is consistent with TokenDataVersion::ShaFlat used for compressed token accounts - if parsed_instruction_data.mint.metadata.version != 3 { - msg!( - "Unsupported mint version {}", - parsed_instruction_data.mint.metadata.version - ); + if mint.metadata.version != 3 { + msg!("Unsupported mint version {}", mint.metadata.version); return Err(ErrorCode::MintActionUnsupportedVersion.into()); } - // Validate spl_mint_initialized is false for new mint creation - if parsed_instruction_data.mint.metadata.spl_mint_initialized != 0 { - msg!("New mint must start without SPL mint initialized"); + // Validate cmint_decompressed is false for new mint creation + if mint.metadata.cmint_decompressed != 0 { + msg!("New mint must start without CMint decompressed"); return Err(ErrorCode::MintActionInvalidCompressionState.into()); } // Validate extensions - only TokenMetadata is supported and at most one extension allowed - if let Some(extensions) = &parsed_instruction_data.mint.extensions { + if let Some(extensions) = &mint.extensions { if extensions.len() != 1 { msg!( "Only one extension allowed for compressed mints, found {}", diff --git a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_mint_account.rs b/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_mint_account.rs deleted file mode 100644 index 0219c573f2..0000000000 --- a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_mint_account.rs +++ /dev/null @@ -1,88 +0,0 @@ -use anchor_lang::solana_program::program_error::ProgramError; -use light_ctoken_interface::{ - instructions::mint_action::ZCompressedMintInstructionData, COMPRESSED_MINT_SEED, -}; -use light_program_profiler::profile; -use pinocchio::{account_info::AccountInfo, instruction::Seed, pubkey::Pubkey}; - -use crate::{ - mint_action::accounts::ExecutingAccounts, - shared::{convert_program_error, create_pda_account, verify_pda}, - LIGHT_CPI_SIGNER, -}; - -/// Creates the mint account manually as a PDA derived from our program but owned by the token program -#[profile] -pub fn create_mint_account( - executing_accounts: &ExecutingAccounts<'_>, - program_id: &Pubkey, - mint_bump: u8, - mint_signer: &AccountInfo, -) -> Result<(), ProgramError> { - let mint_account_size = light_ctoken_interface::MINT_ACCOUNT_SIZE as usize; - let mint_account = executing_accounts - .mint - .ok_or(ProgramError::InvalidAccountData)?; - let _token_program = executing_accounts - .token_program - .ok_or(ProgramError::InvalidAccountData)?; - - // Verify the provided mint account matches the expected PDA - let seeds = &[COMPRESSED_MINT_SEED, mint_signer.key().as_ref()]; - verify_pda(mint_account.key(), seeds, mint_bump, program_id)?; - - // Create account using shared function - let bump_seed = [mint_bump]; - let mint_seeds = [ - Seed::from(COMPRESSED_MINT_SEED), - Seed::from(mint_signer.key().as_ref()), - Seed::from(bump_seed.as_ref()), - ]; - - create_pda_account( - executing_accounts.system.fee_payer, - mint_account, - mint_account_size, - None, // fee_payer is keypair - Some(mint_seeds.as_slice()), // mint is PDA - None, - ) -} - -/// Initializes the mint account using Token-2022's initialize_mint2 instruction -pub fn initialize_mint_account_for_action( - executing_accounts: &ExecutingAccounts<'_>, - mint_data: &ZCompressedMintInstructionData<'_>, -) -> Result<(), ProgramError> { - let mint_account = executing_accounts - .mint - .ok_or(ProgramError::InvalidAccountData)?; - let token_program = executing_accounts - .token_program - .ok_or(ProgramError::InvalidAccountData)?; - - let spl_ix = spl_token_2022::instruction::initialize_mint2( - &solana_pubkey::Pubkey::new_from_array(*token_program.key()), - &solana_pubkey::Pubkey::new_from_array(*mint_account.key()), - // cpi_signer is spl mint authority for compressed mints. - // So that the program can ensure cmint and spl mint supply is consistent. - &solana_pubkey::Pubkey::new_from_array(LIGHT_CPI_SIGNER.cpi_signer), - // Control that the token pool cannot be frozen. - Some(&solana_pubkey::Pubkey::new_from_array( - LIGHT_CPI_SIGNER.cpi_signer, - )), - mint_data.decimals, - )?; - - let initialize_mint_ix = pinocchio::instruction::Instruction { - program_id: token_program.key(), - accounts: &[pinocchio::instruction::AccountMeta::new( - mint_account.key(), - true, - false, - )], - data: &spl_ix.data, - }; - - pinocchio::program::invoke(&initialize_mint_ix, &[mint_account]).map_err(convert_program_error) -} diff --git a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_token_pool.rs b/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_token_pool.rs deleted file mode 100644 index 04622dd2c7..0000000000 --- a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/create_token_pool.rs +++ /dev/null @@ -1,98 +0,0 @@ -use anchor_lang::solana_program::program_error::ProgramError; -use light_program_profiler::profile; -use pinocchio::{ - instruction::{AccountMeta, Seed}, - pubkey::Pubkey, -}; - -use crate::{ - constants::POOL_SEED, mint_action::accounts::ExecutingAccounts, shared::create_pda_account, -}; - -/// Creates the token pool account manually as a PDA derived from our program but owned by the token program -#[profile] -pub fn create_token_pool_account_manual( - executing_accounts: &ExecutingAccounts<'_>, - _program_id: &Pubkey, - token_pool_bump: u8, -) -> Result<(), ProgramError> { - let token_account_size = light_ctoken_interface::BASE_TOKEN_ACCOUNT_SIZE as usize; - - // Get required accounts - let mint_account = executing_accounts - .mint - .ok_or(ProgramError::InvalidAccountData)?; - let token_pool_pda = executing_accounts - .token_pool_pda - .ok_or(ProgramError::InvalidAccountData)?; - let _token_program = executing_accounts - .token_program - .ok_or(ProgramError::InvalidAccountData)?; - - // Find the bump for verification - let mint_key = mint_account.key(); - // let program_id_pubkey = solana_pubkey::Pubkey::new_from_array(*program_id); - // let (expected_token_pool, bump) = solana_pubkey::Pubkey::find_program_address( - // &[POOL_SEED, mint_key.as_ref()], - // &program_id_pubkey, - // ); - - // // Verify the provided token pool account matches the expected PDA - // if token_pool_pda.key() != &expected_token_pool.to_bytes() { - // return Err(ProgramError::InvalidAccountData); - // } - - // Create account using shared function - let bump_seed = [token_pool_bump]; - let pool_seeds = [ - Seed::from(POOL_SEED), - Seed::from(mint_key.as_ref()), - Seed::from(bump_seed.as_ref()), - ]; - - create_pda_account( - executing_accounts.system.fee_payer, - token_pool_pda, - token_account_size, - None, // fee_payer is keypair - Some(pool_seeds.as_slice()), // token_pool is PDA - None, - ) -} - -/// Initializes the token pool account (assumes account already exists) -pub fn initialize_token_pool_account_for_action( - executing_accounts: &ExecutingAccounts<'_>, -) -> Result<(), ProgramError> { - let mint_account = executing_accounts - .mint - .ok_or(ProgramError::InvalidAccountData)?; - let token_pool_pda = executing_accounts - .token_pool_pda - .ok_or(ProgramError::InvalidAccountData)?; - let token_program = executing_accounts - .token_program - .ok_or(ProgramError::InvalidAccountData)?; - - let initialize_account_ix = pinocchio::instruction::Instruction { - program_id: token_program.key(), - accounts: &[ - AccountMeta::new(token_pool_pda.key(), true, false), - AccountMeta::readonly(mint_account.key()), - ], - data: &spl_token_2022::instruction::initialize_account3( - &solana_pubkey::Pubkey::new_from_array(*token_program.key()), - &solana_pubkey::Pubkey::new_from_array(*token_pool_pda.key()), - &solana_pubkey::Pubkey::new_from_array(*mint_account.key()), - &solana_pubkey::Pubkey::new_from_array( - *executing_accounts.system.cpi_authority_pda.key(), - ), - )? - .data, - }; - - match pinocchio::program::invoke(&initialize_account_ix, &[token_pool_pda, mint_account]) { - Ok(()) => Ok(()), - Err(e) => Err(ProgramError::Custom(u64::from(e) as u32)), - } -} diff --git a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/mod.rs b/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/mod.rs deleted file mode 100644 index 572475feb6..0000000000 --- a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod create_mint_account; -mod create_token_pool; -mod process; - -pub use create_mint_account::*; -pub use create_token_pool::*; -pub use process::*; diff --git a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/process.rs b/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/process.rs deleted file mode 100644 index 94096cefd7..0000000000 --- a/programs/compressed-token/program/src/mint_action/actions/create_spl_mint/process.rs +++ /dev/null @@ -1,88 +0,0 @@ -use anchor_compressed_token::ErrorCode; -use anchor_lang::solana_program::program_error::ProgramError; -use light_ctoken_interface::{ - instructions::mint_action::{ZCompressedMintInstructionData, ZCreateSplMintAction}, - CTokenError, -}; -use light_program_profiler::profile; - -use super::{ - create_mint_account, create_token_pool_account_manual, initialize_mint_account_for_action, - initialize_token_pool_account_for_action, -}; -use crate::mint_action::accounts::MintActionAccounts; - -#[profile] -pub fn process_create_spl_mint_action( - create_spl_action: &ZCreateSplMintAction<'_>, - validated_accounts: &MintActionAccounts, - mint_data: &ZCompressedMintInstructionData<'_>, - token_pool_bump: u8, -) -> Result<(), ProgramError> { - let executing_accounts = validated_accounts - .executing - .as_ref() - .ok_or(ErrorCode::MintActionMissingExecutingAccounts)?; - - // Check mint authority if it exists - // If no authority exists anyone should be able to create the associated spl mint. - if let Some(ix_data_mint_authority) = mint_data.mint_authority { - if *validated_accounts.authority.key() != ix_data_mint_authority.to_bytes() { - return Err(ErrorCode::MintActionInvalidMintAuthority.into()); - } - } - - // Verify mint PDA matches the mint field in compressed mint inputs - let expected_mint: [u8; 32] = mint_data.metadata.mint.to_bytes(); - if executing_accounts - .mint - .ok_or(ErrorCode::MintActionMissingMintAccount)? - .key() - != &expected_mint - { - return Err(ErrorCode::MintActionInvalidMintPda.into()); - } - - // 1. Create the mint account manually (PDA derived from our program, owned by token program) - let mint_signer = validated_accounts - .mint_signer - .ok_or(CTokenError::ExpectedMintSignerAccount)?; - create_mint_account( - executing_accounts, - &crate::LIGHT_CPI_SIGNER.program_id, - create_spl_action.mint_bump, - mint_signer, - )?; - - // 2. Initialize the mint account using Token-2022's initialize_mint2 instruction - initialize_mint_account_for_action(executing_accounts, mint_data)?; - - // 3. Create the token pool account manually (PDA derived from our program, owned by token program) - create_token_pool_account_manual( - executing_accounts, - &crate::LIGHT_CPI_SIGNER.program_id, - token_pool_bump, - )?; - - // 4. Initialize the token pool account - initialize_token_pool_account_for_action(executing_accounts)?; - - // 5. Mint the existing supply to the token pool if there's any supply - if mint_data.supply > 0 { - crate::shared::mint_to_token_pool( - executing_accounts - .mint - .ok_or(ErrorCode::MintActionMissingMintAccount)?, - executing_accounts - .token_pool_pda - .ok_or(ErrorCode::MintActionMissingTokenPoolAccount)?, - executing_accounts - .token_program - .ok_or(ErrorCode::MintActionMissingTokenProgram)?, - executing_accounts.system.cpi_authority_pda, - u64::from(mint_data.supply), - )?; - } - - Ok(()) -} diff --git a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs new file mode 100644 index 0000000000..237101b11c --- /dev/null +++ b/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs @@ -0,0 +1,221 @@ +use anchor_compressed_token::ErrorCode; +use anchor_lang::prelude::ProgramError; +use light_compressible::{compression_info::CompressionInfo, rent::RentConfig}; +use light_ctoken_interface::{ + instructions::mint_action::ZDecompressMintAction, + state::{CompressedMint, ExtensionStruct}, + COMPRESSED_MINT_SEED, +}; +use light_program_profiler::profile; +#[cfg(target_os = "solana")] +use pinocchio::sysvars::{clock::Clock, Sysvar}; +use pinocchio::{account_info::AccountInfo, instruction::Seed}; +use pinocchio_system::instructions::Transfer; +use spl_pod::solana_msg::msg; + +use crate::{ + create_token_account::parse_config_account, + mint_action::accounts::MintActionAccounts, + shared::{ + convert_program_error, + create_pda_account::{create_pda_account, verify_pda}, + }, +}; + +/// Processes the DecompressMint action by creating a CMint Solana account +/// from a compressed mint. +/// +/// ## Process Steps +/// 1. **State Validation**: Ensure mint is not already decompressed +/// 2. **Rent Payment Validation**: rent_payment must be >= 2 (CMint is always compressible) +/// 3. **Config Validation**: Validate CompressibleConfig account +/// 4. **Write Top-Up Validation**: write_top_up must not exceed max_top_up +/// 5. **Add Compressible Extension**: Add CompressionInfo to the compressed mint extensions +/// 6. **PDA Verification**: Verify CMint account matches expected PDA derivation +/// 7. **Account Creation**: rent_sponsor pays rent exemption, fee_payer pays Light rent +/// 8. **Flag Update**: Set cmint_decompressed flag (synced at end of MintAction) +/// +/// ## Note +/// DecompressMint is **permissionless** - anyone can call it (they pay for the CMint creation). +/// The authority signer is still required for MintAction, but does not need to match mint_authority. +/// +/// ## Note +/// The CMint account data is NOT serialized here. The sync logic at the end +/// of the MintAction processor will write the output compressed mint to the +/// CMint account. +#[profile] +pub fn process_decompress_mint_action( + action: &ZDecompressMintAction, + compressed_mint: &mut CompressedMint, + validated_accounts: &MintActionAccounts, + mint_signer: &AccountInfo, + fee_payer: &AccountInfo, +) -> Result<(), ProgramError> { + // NOTE: DecompressMint is permissionless - anyone can decompress (they pay for the account) + // No authority check required + + // 1. Check not already decompressed + if compressed_mint.metadata.cmint_decompressed { + msg!("CMint account already exists"); + return Err(ErrorCode::CMintAlreadyExists.into()); + } + + // 2. Validate rent_payment (CMint is ALWAYS compressible) + // rent_payment == 0 is rejected - CMint must be compressible + if action.rent_payment == 0 { + msg!("rent_payment must be >= 2 (CMint is always compressible)"); + return Err(ErrorCode::InvalidRentPayment.into()); + } + // rent_payment == 1 is rejected - epoch boundary edge case + if action.rent_payment == 1 { + msg!("Prefunding for exactly 1 epoch is not allowed. Use 2+ epochs."); + return Err(ErrorCode::OneEpochPrefundingNotAllowed.into()); + } + + let executing = validated_accounts + .executing + .as_ref() + .ok_or(ErrorCode::MintActionMissingExecutingAccounts)?; + + let cmint = executing + .cmint + .ok_or(ErrorCode::MintActionMissingCMintAccount)?; + + // 3. Get and validate CompressibleConfig account + let config_account = executing + .compressible_config + .ok_or(ErrorCode::MissingCompressibleConfig)?; + + let config = parse_config_account(config_account)?; + + // 4. Validate rent_payment doesn't exceed max_funded_epochs + if action.rent_payment > config.rent_config.max_funded_epochs { + msg!( + "rent_payment {} exceeds max_funded_epochs {}", + action.rent_payment, + config.rent_config.max_funded_epochs + ); + return Err(ErrorCode::RentPaymentExceedsMax.into()); + } + + // 5. Validate write_top_up doesn't exceed max_top_up + if action.write_top_up > config.rent_config.max_top_up as u32 { + msg!( + "write_top_up {} exceeds max_top_up {}", + action.write_top_up, + config.rent_config.max_top_up + ); + return Err(ErrorCode::WriteTopUpExceedsMaximum.into()); + } + + // 6. Get rent_sponsor and verify it matches config + let rent_sponsor = executing + .rent_sponsor + .ok_or(ErrorCode::MissingRentSponsor)?; + + if rent_sponsor.key() != &config.rent_sponsor.to_bytes() { + msg!("Rent sponsor account does not match config"); + return Err(ErrorCode::InvalidRentSponsor.into()); + } + + // 7. Get current slot for last_claimed_slot + #[cfg(target_os = "solana")] + let current_slot = Clock::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .slot; + #[cfg(not(target_os = "solana"))] + let current_slot = 1u64; + + // 8. Build Compressible extension and add to compressed_mint + // NOTE: Compressible will be stripped when writing to compressed account, + // but kept when writing to CMint (sync in mint_output.rs) + let compression_info = CompressionInfo { + config_account_version: config.version, + compress_to_pubkey: 0, // Not applicable for CMint + account_version: 3, // ShaFlat version + lamports_per_write: action.write_top_up.into(), + compression_authority: config.compression_authority.to_bytes(), + rent_sponsor: config.rent_sponsor.to_bytes(), + last_claimed_slot: current_slot, + rent_config: RentConfig { + base_rent: config.rent_config.base_rent, + compression_cost: config.rent_config.compression_cost, + lamports_per_byte_per_epoch: config.rent_config.lamports_per_byte_per_epoch, + max_funded_epochs: config.rent_config.max_funded_epochs, + max_top_up: config.rent_config.max_top_up, + }, + }; + + // Add Compressible extension to compressed_mint + let extension = ExtensionStruct::Compressible(compression_info); + if let Some(ref mut extensions) = compressed_mint.extensions { + extensions.push(extension); + } else { + compressed_mint.extensions = Some(vec![extension]); + } + + // 9. Verify PDA derivation + let seeds: [&[u8]; 2] = [COMPRESSED_MINT_SEED, mint_signer.key()]; + verify_pda( + cmint.key(), + &seeds, + action.cmint_bump, + &crate::LIGHT_CPI_SIGNER.program_id, + )?; + + // 10. Calculate account size AFTER adding extension (using borsh serialization) + let account_size = borsh::to_vec(compressed_mint) + .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)? + .len(); + + // 11. Calculate Light Protocol rent (base_rent + bytes * lamports_per_byte * epochs + compression_cost) + let light_rent = config + .rent_config + .get_rent_with_compression_cost(account_size as u64, action.rent_payment as u64); + + // 12. Build seeds for rent_sponsor PDA (to sign the transfer) + let version_bytes = config.version.to_le_bytes(); + let rent_sponsor_bump_bytes = [config.rent_sponsor_bump]; + let rent_sponsor_seeds = [ + Seed::from(b"rent_sponsor".as_ref()), + Seed::from(version_bytes.as_ref()), + Seed::from(rent_sponsor_bump_bytes.as_ref()), + ]; + + // 13. Build seeds for CMint PDA + let cmint_bump_bytes = [action.cmint_bump]; + let cmint_seeds = [ + Seed::from(COMPRESSED_MINT_SEED), + Seed::from(mint_signer.key()), + Seed::from(cmint_bump_bytes.as_ref()), + ]; + + // 14. Create CMint PDA account + // rent_sponsor pays ONLY the rent exemption (minimum_balance) + // additional_lamports = None means create_pda_account only pays rent exemption + create_pda_account( + rent_sponsor, // payer: rent_sponsor PDA + cmint, // account being created + account_size, // size + Some(rent_sponsor_seeds.as_slice()), // payer_seeds: rent_sponsor is PDA + Some(cmint_seeds.as_slice()), // account_seeds: CMint is PDA + None, // rent_sponsor pays ONLY rent exemption + )?; + + // 15. fee_payer pays the Light Protocol rent + Transfer { + from: fee_payer, + to: cmint, + lamports: light_rent, + } + .invoke() + .map_err(convert_program_error)?; + + // 16. Set the cmint_decompressed flag (will be persisted in sync) + compressed_mint.metadata.cmint_decompressed = true; + + // NOTE: Don't serialize here - the sync logic at the end of MintAction + // processor will write the output compressed mint to CMint account + + Ok(()) +} diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs b/programs/compressed-token/program/src/mint_action/actions/mint_to.rs index 9383917a72..ab0629926f 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs +++ b/programs/compressed-token/program/src/mint_action/actions/mint_to.rs @@ -9,10 +9,7 @@ use light_program_profiler::profile; use light_sdk_pinocchio::instruction::ZOutputCompressedAccountWithPackedContextMut; use crate::{ - mint_action::{ - accounts::MintActionAccounts, check_authority, - mint_to_ctoken::handle_spl_mint_initialized_token_pool, - }, + mint_action::{accounts::MintActionAccounts, check_authority}, shared::token_output::set_output_compressed_account, }; @@ -27,8 +24,8 @@ use crate::{ /// 6. **Compressed Account Creation**: Create new compressed token account for each recipient /// /// ## SPL Mint Synchronization -/// When `accounts_config.spl_mint_initialized` is true, an SPL mint exists for this compressed mint. -/// The function maintains consistency between the compressed token supply and the underlying SPL mint supply +/// When `compressed_mint.metadata.cmint_decompressed` is true and an SPL mint exists for this compressed mint, +/// the function maintains consistency between the compressed token supply and the underlying SPL mint supply /// by minting equivalent tokens to a program-controlled token pool account via CPI to SPL Token 2022. #[allow(clippy::too_many_arguments)] #[profile] @@ -60,14 +57,6 @@ pub fn process_mint_to_compressed_action<'a>( .checked_add(compressed_mint.base.supply) .ok_or(ErrorCode::MintActionAmountTooLarge)?; - // Check SPL mint initialization from compressed mint state, not config - handle_spl_mint_initialized_token_pool( - validated_accounts, - compressed_mint.metadata.spl_mint_initialized, - sum_amounts, - mint, - )?; - // Create output token accounts create_output_compressed_token_accounts( action, diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs b/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs index ec21f464a7..91001d5f4b 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs +++ b/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs @@ -7,11 +7,9 @@ use light_ctoken_interface::{ }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; -use spl_pod::solana_msg::msg; use crate::{ mint_action::{accounts::MintActionAccounts, check_authority}, - shared::mint_to_token_pool, transfer2::compression::{compress_or_decompress_ctokens, CTokenCompressionInputs}, }; @@ -39,13 +37,6 @@ pub fn process_mint_to_ctoken_action( .checked_add(amount) .ok_or(ErrorCode::MintActionAmountTooLarge)?; - handle_spl_mint_initialized_token_pool( - validated_accounts, - compressed_mint.metadata.spl_mint_initialized, - amount, - mint, - )?; - // Get the recipient token account from packed accounts using the index let token_account_info = packed_accounts.get_u8(action.account_index, "ctoken mint to recipient")?; @@ -61,42 +52,3 @@ pub fn process_mint_to_ctoken_action( compress_or_decompress_ctokens(inputs, transfer_amount, lamports_budget) } - -#[profile] -pub fn handle_spl_mint_initialized_token_pool( - validated_accounts: &MintActionAccounts, - spl_mint_initialized: bool, - amount: u64, - mint: Pubkey, -) -> Result<(), ProgramError> { - if let Some(system_accounts) = validated_accounts.executing.as_ref() { - // If SPL mint is initialized, mint tokens to the token pool to maintain SPL mint supply consistency - if spl_mint_initialized { - let mint_account = system_accounts - .mint - .ok_or(ErrorCode::MintActionMissingMintAccount)?; - if mint.to_bytes() != *mint_account.key() { - msg!("Mint account mismatch"); - return Err(ErrorCode::MintAccountMismatch.into()); - } - let token_pool_account = system_accounts - .token_pool_pda - .ok_or(ErrorCode::MintActionMissingTokenPoolAccount)?; - let token_program = system_accounts - .token_program - .ok_or(ErrorCode::MintActionMissingTokenProgram)?; - - mint_to_token_pool( - mint_account, - token_pool_account, - token_program, - validated_accounts.cpi_authority()?, - amount, - )?; - } - } else if spl_mint_initialized { - msg!("if SPL mint is initialized, executing accounts must be present"); - return Err(ErrorCode::Transfer2CpiContextWriteInvalidAccess.into()); - } - Ok(()) -} diff --git a/programs/compressed-token/program/src/mint_action/actions/mod.rs b/programs/compressed-token/program/src/mint_action/actions/mod.rs index 06e9a1bbd8..6f12d054b4 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mod.rs +++ b/programs/compressed-token/program/src/mint_action/actions/mod.rs @@ -1,9 +1,12 @@ pub mod authority; +pub mod compress_and_close_cmint; pub mod create_mint; -pub mod create_spl_mint; +pub mod decompress_mint; pub mod mint_to; pub mod mint_to_ctoken; mod process_actions; pub mod update_metadata; pub use authority::check_authority; +pub use compress_and_close_cmint::process_compress_and_close_cmint_action; +pub use decompress_mint::process_decompress_mint_action; pub use process_actions::process_actions; diff --git a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs b/programs/compressed-token/program/src/mint_action/actions/process_actions.rs index 1785ed01dc..3110270cd9 100644 --- a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs +++ b/programs/compressed-token/program/src/mint_action/actions/process_actions.rs @@ -17,6 +17,8 @@ use crate::{ mint_action::{ accounts::MintActionAccounts, check_authority, + compress_and_close_cmint::process_compress_and_close_cmint_action, + decompress_mint::process_decompress_mint_action, mint_to::process_mint_to_compressed_action, mint_to_ctoken::process_mint_to_ctoken_action, queue_indices::QueueIndices, @@ -61,7 +63,7 @@ pub fn process_actions<'a>( validated_accounts, output_accounts_iter, hash_cache, - parsed_instruction_data.mint.metadata.mint, + compressed_mint.metadata.mint, queue_indices.out_token_queue_index, )?; } @@ -93,7 +95,7 @@ pub fn process_actions<'a>( // &parsed_instruction_data.mint, // parsed_instruction_data.token_pool_bump, // )?; - // compressed_mint.metadata.spl_mint_initialized = true; + // compressed_mint.metadata.cmint_decompressed = true; } ZAction::MintToCToken(mint_to_ctoken_action) => { let account_index = mint_to_ctoken_action.account_index as usize; @@ -110,7 +112,7 @@ pub fn process_actions<'a>( compressed_mint, validated_accounts, packed_accounts, - parsed_instruction_data.mint.metadata.mint, + compressed_mint.metadata.mint, &mut transfer_map[account_index], &mut lamports_budget, )?; @@ -136,6 +138,33 @@ pub fn process_actions<'a>( validated_accounts.authority.key(), )?; } + ZAction::DecompressMint(decompress_action) => { + let mint_signer = validated_accounts + .mint_signer + .ok_or(ErrorCode::MintActionMissingMintSigner)?; + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or_else(|| { + msg!("Fee payer required for DecompressMint action"); + ProgramError::NotEnoughAccountKeys + })?; + process_decompress_mint_action( + decompress_action, + compressed_mint, + validated_accounts, + mint_signer, + fee_payer, + )?; + } + ZAction::CompressAndCloseCMint(action) => { + process_compress_and_close_cmint_action( + action, + compressed_mint, + validated_accounts, + )?; + } } } diff --git a/programs/compressed-token/program/src/mint_action/mint_input.rs b/programs/compressed-token/program/src/mint_action/mint_input.rs index 4a5ed53d5d..df32d7d8ea 100644 --- a/programs/compressed-token/program/src/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/mint_action/mint_input.rs @@ -8,31 +8,48 @@ use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; use light_sdk::instruction::PackedMerkleContext; -use crate::constants::COMPRESSED_MINT_DISCRIMINATOR; +use crate::{constants::COMPRESSED_MINT_DISCRIMINATOR, mint_action::accounts::AccountsConfig}; + /// Creates and validates an input compressed mint account. /// This function follows the same pattern as create_output_compressed_mint_account /// but processes existing compressed mint accounts as inputs. /// /// Steps: -/// 1. Set InAccount fields (discriminator, merkle hash_cache, address) -/// 2. Validate the compressed mint data matches expected values -/// 3. Compute data hash using HashCache for caching -/// 4. Return validated CompressedMint data for output processing +/// 1. Determine if CMint is source of truth (use zero values) or data from instruction +/// 2. Set InAccount fields (discriminator, merkle hash, address) #[profile] pub fn create_input_compressed_mint_account( input_compressed_account: &mut ZInAccountMut, mint_instruction_data: &ZMintActionCompressedInstructionData, merkle_context: PackedMerkleContext, -) -> Result { - let compressed_mint = CompressedMint::try_from(&mint_instruction_data.mint)?; - let bytes = compressed_mint - .try_to_vec() - .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; - let input_data_hash = Sha256BE::hash(bytes.as_slice())?; + accounts_config: &AccountsConfig, +) -> Result<(), ProgramError> { + // When CMint was source of truth (input state BEFORE actions), use zero sentinel values + // Use cmint_decompressed directly, not cmint_is_source_of_truth(), because: + // - cmint_is_source_of_truth() tells us the OUTPUT state (after actions) + // - cmint_decompressed tells us the INPUT state (before actions) + // For CompressAndCloseCMint: input has zero values (was decompressed), output has real data + let (discriminator, input_data_hash) = if accounts_config.cmint_decompressed { + ([0u8; 8], [0u8; 32]) + } else { + // Data from instruction - compute hash + let mint_data = mint_instruction_data + .mint + .as_ref() + .ok_or(ProgramError::InvalidInstructionData)?; + let compressed_mint = CompressedMint::try_from(mint_data)?; + let bytes = compressed_mint + .try_to_vec() + .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; + ( + COMPRESSED_MINT_DISCRIMINATOR, + Sha256BE::hash(bytes.as_slice())?, + ) + }; - // 2. Set InAccount fields + // Set InAccount fields input_compressed_account.set( - COMPRESSED_MINT_DISCRIMINATOR, + discriminator, input_data_hash, &merkle_context, mint_instruction_data.root_index, @@ -40,5 +57,5 @@ pub fn create_input_compressed_mint_account( Some(mint_instruction_data.compressed_address.as_ref()), )?; - Ok(compressed_mint) + Ok(()) } diff --git a/programs/compressed-token/program/src/mint_action/mint_output.rs b/programs/compressed-token/program/src/mint_action/mint_output.rs index 6fe0c70980..1af806327f 100644 --- a/programs/compressed-token/program/src/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/mint_action/mint_output.rs @@ -2,21 +2,28 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use borsh::BorshSerialize; use light_compressed_account::instruction_data::data::ZOutputCompressedAccountWithPackedContextMut; +use light_compressible::rent::get_rent_exemption_lamports; use light_ctoken_interface::{ - hash_cache::HashCache, instructions::mint_action::ZMintActionCompressedInstructionData, - state::CompressedMint, + hash_cache::HashCache, + instructions::mint_action::ZMintActionCompressedInstructionData, + state::{CompressedMint, ExtensionStruct}, }; use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; +use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; use spl_pod::solana_msg::msg; use crate::{ constants::COMPRESSED_MINT_DISCRIMINATOR, mint_action::{ - accounts::MintActionAccounts, actions::process_actions, queue_indices::QueueIndices, + accounts::{AccountsConfig, MintActionAccounts}, + actions::process_actions, + queue_indices::QueueIndices, }, + shared::{convert_program_error, transfer_lamports::transfer_lamports}, }; +/// Processes the output compressed mint account and returns the modified mint for CMint sync. #[profile] pub fn process_output_compressed_account<'a>( parsed_instruction_data: &ZMintActionCompressedInstructionData, @@ -25,6 +32,7 @@ pub fn process_output_compressed_account<'a>( hash_cache: &mut HashCache, queue_indices: &QueueIndices, mut compressed_mint: CompressedMint, + accounts_config: &AccountsConfig, ) -> Result<(), ProgramError> { let (mint_account, token_accounts) = split_mint_and_token_accounts(output_compressed_accounts); @@ -38,24 +46,129 @@ pub fn process_output_compressed_account<'a>( &mut compressed_mint, )?; - let data_hash = { - let compressed_account_data = mint_account - .compressed_account - .data - .as_mut() - .ok_or(ErrorCode::MintActionOutputSerializationFailed)?; + // AUTO-SYNC OUTPUT: If CMint account was passed, update it with new state + // SKIP if CompressAndCloseCMint action is present (CMint is being closed, not synced) + if let Some(cmint_account) = validated_accounts.get_cmint() { + if !accounts_config.has_compress_and_close_cmint_action { + // Check if CMint has Compressible extension and handle top-up + if let Some(ref mut extensions) = compressed_mint.extensions { + if let Some(ExtensionStruct::Compressible(ref mut compression_info)) = extensions + .iter_mut() + .find(|e| matches!(e, ExtensionStruct::Compressible(_))) + { + // Get current slot for top-up calculation + let current_slot = Clock::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .slot; + + let num_bytes = cmint_account.data_len() as u64; + let current_lamports = cmint_account.lamports(); + let rent_exemption = get_rent_exemption_lamports(num_bytes) + .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; + + // Calculate top-up amount + let top_up = compression_info + .calculate_top_up_lamports( + num_bytes, + current_slot, + current_lamports, + rent_exemption, + ) + .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; + + if top_up > 0 { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + transfer_lamports(top_up, fee_payer, cmint_account) + .map_err(convert_program_error)?; + } + + // Update last_claimed_slot to current slot + compression_info.last_claimed_slot = current_slot; + } + } + + let serialized = compressed_mint + .try_to_vec() + .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)?; + let required_size = serialized.len(); + + // Resize if needed (e.g., metadata extensions added) + if cmint_account.data_len() < required_size { + cmint_account + .resize(required_size) + .map_err(|_| ErrorCode::CMintResizeFailed)?; + + // Transfer additional lamports for rent if resized + let rent = Rent::get().map_err(|_| ProgramError::UnsupportedSysvar)?; + let required_lamports = rent.minimum_balance(required_size); + if cmint_account.lamports() < required_lamports { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + transfer_lamports( + required_lamports - cmint_account.lamports(), + fee_payer, + cmint_account, + ) + .map_err(convert_program_error)?; + } + } + let mut cmint_data = cmint_account + .try_borrow_mut_data() + .map_err(|_| ProgramError::AccountBorrowFailed)?; + if cmint_data.len() < serialized.len() { + msg!( + "CMint account too small: {} < {}", + cmint_data.len(), + serialized.len() + ); + return Err(ErrorCode::CMintResizeFailed.into()); + } + cmint_data[..serialized.len()].copy_from_slice(&serialized); + } + } + + // When decompressed (CMint is source of truth), use zero values + let cmint_is_source_of_truth = accounts_config.cmint_is_source_of_truth(); + let compressed_account_data = mint_account + .compressed_account + .data + .as_mut() + .ok_or(ErrorCode::MintActionOutputSerializationFailed)?; + + let (discriminator, data_hash) = if cmint_is_source_of_truth { + // Zero sentinel values indicate "data lives in CMint" + // Data buffer is empty (data_len=0), no serialization needed + ([0u8; 8], [0u8; 32]) + } else { + // Serialize compressed mint for compressed account let data = compressed_mint .try_to_vec() .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; if data.len() != compressed_account_data.data.len() { - msg!("Data allocation for output mint account is wrong"); + msg!( + "Data allocation for output mint account is wrong: {} != {}", + data.len(), + compressed_account_data.data.len() + ); return Err(ProgramError::InvalidAccountData); } + + // Copy data and compute hash compressed_account_data .data .copy_from_slice(data.as_slice()); - Sha256BE::hash(compressed_account_data.data)? + ( + COMPRESSED_MINT_DISCRIMINATOR, + Sha256BE::hash(compressed_account_data.data)?, + ) }; // Set mint output compressed account fields except the data. @@ -64,9 +177,10 @@ pub fn process_output_compressed_account<'a>( 0, Some(parsed_instruction_data.compressed_address), queue_indices.output_queue_index, - COMPRESSED_MINT_DISCRIMINATOR, + discriminator, data_hash, )?; + Ok(()) } diff --git a/programs/compressed-token/program/src/mint_action/processor.rs b/programs/compressed-token/program/src/mint_action/processor.rs index c0452177b0..14ba3ec9dd 100644 --- a/programs/compressed-token/program/src/mint_action/processor.rs +++ b/programs/compressed-token/program/src/mint_action/processor.rs @@ -27,21 +27,52 @@ pub fn process_mint_action( ) -> Result<(), ProgramError> { // 1. parse instruction data // 677 CU - let (mut parsed_instruction_data, _) = + let (parsed_instruction_data, _) = MintActionCompressedInstructionData::zero_copy_at(instruction_data) .map_err(|_| ProgramError::InvalidInstructionData)?; // 112 CU write to cpi contex let accounts_config = AccountsConfig::new(&parsed_instruction_data)?; + // Get mint pubkey from instruction data if present + let cmint_pubkey: Option = parsed_instruction_data + .mint + .as_ref() + .map(|m| m.metadata.mint.into()); // Validate and parse let validated_accounts = MintActionAccounts::validate_and_parse( accounts, &accounts_config, - &parsed_instruction_data.mint.metadata.mint.into(), + cmint_pubkey.as_ref(), parsed_instruction_data.token_pool_index, parsed_instruction_data.token_pool_bump, )?; - let (config, mut cpi_bytes, _) = get_zero_copy_configs(&mut parsed_instruction_data)?; + // Get mint data based on source: + // 1. Creating new mint: mint data required in instruction + // 2. Existing compressed mint: mint data in instruction (cmint_decompressed = false) + // 3. CMint is source of truth: read from CMint account (cmint_decompressed = true) + let mint = if parsed_instruction_data.create_mint.is_some() { + // Creating new mint - mint data required in instruction + let mint_data = parsed_instruction_data + .mint + .as_ref() + .ok_or(ErrorCode::MintDataRequired)?; + CompressedMint::try_from(mint_data)? + } else if let Some(mint_data) = parsed_instruction_data.mint.as_ref() { + // Existing compressed mint with data in instruction + CompressedMint::try_from(mint_data)? + } else { + // CMint is source of truth - read from CMint account + let cmint_account = validated_accounts + .get_cmint() + .ok_or(ErrorCode::MintActionMissingCMintAccount)?; + CompressedMint::from_account_info_checked( + &crate::LIGHT_CPI_SIGNER.program_id, + cmint_account, + )? + }; + + let (config, mut cpi_bytes, _) = + get_zero_copy_configs(&parsed_instruction_data, &accounts_config, &mint)?; let (mut cpi_instruction_struct, remaining_bytes) = InstructionDataInvokeCpiWithReadOnly::new_zero_copy(&mut cpi_bytes[8..], config) .map_err(ProgramError::from)?; @@ -70,12 +101,12 @@ pub fn process_mint_action( accounts_config.write_to_cpi_context, )?; - // If create mint - // 1. derive spl mint pda - // 2. set create address - // else - // 1. set input compressed mint account - let mint = if parsed_instruction_data.create_mint.is_some() { + // Get mint data based on instruction type: + // 1. Creating mint: mint data from instruction (must be Some) + // 2. Existing mint with data in instruction: use instruction data + // 3. Existing decompressed mint (CMint): read from CMint account + if parsed_instruction_data.create_mint.is_some() { + // Creating new mint - mint data required in instruction process_create_mint_action( &parsed_instruction_data, validated_accounts @@ -84,12 +115,11 @@ pub fn process_mint_action( .map_err(|_| ErrorCode::MintActionMissingExecutingAccounts)? .key(), &mut cpi_instruction_struct, - // Use the dedicated address_merkle_tree_index when creating the mint queue_indices.address_merkle_tree_index, )?; - CompressedMint::try_from(&parsed_instruction_data.mint)? } else { - // Process input compressed mint account + // Decompressed mint (CMint is source of truth) - data from CMint account + // Set input with zero values (data lives in CMint) create_input_compressed_mint_account( &mut cpi_instruction_struct.input_compressed_accounts[0], &parsed_instruction_data, @@ -99,7 +129,8 @@ pub fn process_mint_action( leaf_index: parsed_instruction_data.leaf_index.into(), prove_by_index: parsed_instruction_data.prove_by_index(), }, - )? + &accounts_config, + )?; }; process_output_compressed_account( @@ -109,6 +140,7 @@ pub fn process_mint_action( &mut hash_cache, &queue_indices, mint, + &accounts_config, )?; let cpi_accounts = validated_accounts.get_cpi_accounts(queue_indices.deduplicated, accounts)?; diff --git a/programs/compressed-token/program/src/mint_action/zero_copy_config.rs b/programs/compressed-token/program/src/mint_action/zero_copy_config.rs index 9020e01a56..933c20536f 100644 --- a/programs/compressed-token/program/src/mint_action/zero_copy_config.rs +++ b/programs/compressed-token/program/src/mint_action/zero_copy_config.rs @@ -3,23 +3,28 @@ use anchor_lang::solana_program::program_error::ProgramError; use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnlyConfig; use light_ctoken_interface::{ instructions::mint_action::{ZAction, ZMintActionCompressedInstructionData}, - state::CompressedMintConfig, + state::{CompressedMint, CompressedMintConfig}, }; use light_program_profiler::profile; use spl_pod::solana_msg::msg; use tinyvec::ArrayVec; -use crate::shared::{ - convert_program_error, - cpi_bytes_size::{ - allocate_invoke_with_read_only_cpi_bytes, compressed_token_data_len, cpi_bytes_config, - mint_data_len, CpiConfigInput, +use crate::{ + mint_action::accounts::AccountsConfig, + shared::{ + convert_program_error, + cpi_bytes_size::{ + allocate_invoke_with_read_only_cpi_bytes, compressed_token_data_len, cpi_bytes_config, + mint_data_len, CpiConfigInput, + }, }, }; #[profile] pub fn get_zero_copy_configs( - parsed_instruction_data: &mut ZMintActionCompressedInstructionData<'_>, + parsed_instruction_data: &ZMintActionCompressedInstructionData<'_>, + accounts_config: &AccountsConfig, + cmint: &CompressedMint, ) -> Result< ( InstructionDataInvokeCpiWithReadOnlyConfig, @@ -28,10 +33,11 @@ pub fn get_zero_copy_configs( ), ProgramError, > { - // Generate output config based on final state after all actions (without modifying instruction data) + // Generate output config based on final state after all actions + // Get extensions from instruction data or CMint account let (_, output_extensions_config, _) = crate::extensions::process_extensions_config_with_actions( - parsed_instruction_data.mint.extensions.as_ref(), + cmint.extensions.as_ref(), &parsed_instruction_data.actions, )?; // Process actions to determine final output state (no instruction data modification) @@ -80,6 +86,9 @@ pub fn get_zero_copy_configs( msg!("Max allowed is 29 compressed token recipients"); return Err(ErrorCode::TooManyMintToRecipients.into()); } + // CMint is source of truth when decompressed and not closing + let cmint_is_source_of_truth = accounts_config.cmint_is_source_of_truth(); + let input = CpiConfigInput { input_accounts: { let mut inputs = ArrayVec::new(); @@ -92,7 +101,13 @@ pub fn get_zero_copy_configs( output_accounts: { let mut outputs = ArrayVec::new(); // First output is always the mint account - outputs.push((true, mint_data_len(&output_mint_config))); + // When CMint is source of truth, use data_len=0 (zero discriminator/hash) + let mint_data_len = if cmint_is_source_of_truth { + 0 + } else { + mint_data_len(&output_mint_config) + }; + outputs.push((true, mint_data_len)); // Add token accounts for recipients for _ in 0..num_recipients { diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs new file mode 100644 index 0000000000..baece4a8e8 --- /dev/null +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -0,0 +1,125 @@ +use anchor_lang::solana_program::program_error::ProgramError; +use light_compressible::rent::get_rent_exemption_lamports; +use light_ctoken_interface::{ + state::{CToken, CompressedMint, ZExtensionStruct}, + CTokenError, BASE_TOKEN_ACCOUNT_SIZE, COMPRESSIBLE_TOKEN_RENT_EXEMPTION, +}; +use light_program_profiler::profile; +use light_zero_copy::traits::ZeroCopyAt; +use pinocchio::account_info::AccountInfo; + +use super::{ + convert_program_error, + transfer_lamports::{multi_transfer_lamports, Transfer}, +}; + +/// Calculate and execute top-up transfers for compressible CMint and CToken accounts. +/// Both accounts are optional - if an account doesn't have compressible extension, it's skipped. +/// +/// # Arguments +/// * `cmint` - The CMint account (may or may not have Compressible extension) +/// * `ctoken` - The CToken account (may or may not have Compressible extension) +/// * `payer` - The fee payer for top-ups +/// * `max_top_up` - Maximum lamports for top-ups combined (0 = no limit) +#[inline(always)] +#[profile] +pub fn calculate_and_execute_compressible_top_ups<'a>( + cmint: &'a AccountInfo, + ctoken: &'a AccountInfo, + payer: &'a AccountInfo, + max_top_up: u16, +) -> Result<(), ProgramError> { + let mut transfers = [ + Transfer { + account: cmint, + amount: 0, + }, + Transfer { + account: ctoken, + amount: 0, + }, + ]; + + let mut current_slot = 0; + // Initialize budget: +1 allows exact match (total == max_top_up) + let mut lamports_budget = (max_top_up as u64).saturating_add(1); + + // Calculate CMint top-up using zero-copy + { + let cmint_data = cmint.try_borrow_data().map_err(convert_program_error)?; + let (mint, _) = CompressedMint::zero_copy_at(&cmint_data) + .map_err(|_| CTokenError::CMintDeserializationFailed)?; + if let Some(ref extensions) = mint.extensions { + for extension in extensions.iter() { + if let ZExtensionStruct::Compressible(ref compression_info) = extension { + if current_slot == 0 { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + current_slot = Clock::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .slot; + } + let rent_exemption = get_rent_exemption_lamports(cmint.data_len() as u64) + .map_err(|_| CTokenError::InvalidAccountData)?; + transfers[0].amount = compression_info + .calculate_top_up_lamports( + cmint.data_len() as u64, + current_slot, + cmint.lamports(), + rent_exemption, + ) + .map_err(|_| CTokenError::InvalidAccountData)?; + lamports_budget = lamports_budget.saturating_sub(transfers[0].amount); + break; + } + } + } + } + + // Calculate CToken top-up (CToken uses zero-copy extensions) + if ctoken.data_len() > BASE_TOKEN_ACCOUNT_SIZE as usize { + let account_data = ctoken.try_borrow_data().map_err(convert_program_error)?; + let (token, _) = CToken::zero_copy_at_checked(&account_data)?; + if let Some(ref extensions) = token.extensions { + for extension in extensions.iter() { + if let ZExtensionStruct::Compressible(compressible_ext) = extension { + if current_slot == 0 { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + current_slot = Clock::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .slot; + } + transfers[1].amount = compressible_ext + .calculate_top_up_lamports( + ctoken.data_len() as u64, + current_slot, + ctoken.lamports(), + COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + ) + .map_err(|_| CTokenError::InvalidAccountData)?; + lamports_budget = lamports_budget.saturating_sub(transfers[1].amount); + break; + } + } + } else { + // Only Compressible extensions are implemented for ctoken accounts. + return Err(CTokenError::InvalidAccountData.into()); + } + } + + // Exit early if no compressible accounts + if current_slot == 0 { + return Ok(()); + } + + if transfers[0].amount == 0 && transfers[1].amount == 0 { + return Ok(()); + } + + // Check budget wasn't exhausted (0 means exceeded max_top_up) + if max_top_up != 0 && lamports_budget == 0 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + + multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; + Ok(()) +} diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index 1b0f097c71..e3a02859d7 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod compressible_top_up; mod convert_program_error; pub mod cpi; pub mod cpi_bytes_size; diff --git a/programs/compressed-token/program/tests/mint.rs b/programs/compressed-token/program/tests/mint.rs index 4557a8396a..8b62286ca3 100644 --- a/programs/compressed-token/program/tests/mint.rs +++ b/programs/compressed-token/program/tests/mint.rs @@ -6,7 +6,8 @@ use light_compressed_account::{ use light_compressed_token::{ constants::COMPRESSED_MINT_DISCRIMINATOR, mint_action::{ - mint_input::create_input_compressed_mint_account, zero_copy_config::get_zero_copy_configs, + accounts::AccountsConfig, mint_input::create_input_compressed_mint_account, + zero_copy_config::get_zero_copy_configs, }, }; use light_ctoken_interface::{ @@ -51,7 +52,7 @@ fn test_rnd_create_compressed_mint_account() { // Generate random supplies let input_supply = rng.gen_range(0..=u64::MAX); let _output_supply = rng.gen_range(0..=u64::MAX); - let spl_mint_initialized = rng.gen_bool(0.1); + let cmint_decompressed = rng.gen_bool(0.1); // Generate random merkle context let merkle_tree_pubkey_index = rng.gen_range(0..=255u8); @@ -116,7 +117,7 @@ fn test_rnd_create_compressed_mint_account() { metadata: CompressedMintMetadata { version, mint: mint_pda, - spl_mint_initialized, + cmint_decompressed, }, mint_authority: Some(mint_authority), freeze_authority, @@ -131,7 +132,7 @@ fn test_rnd_create_compressed_mint_account() { prove_by_index, root_index, compressed_address: compressed_account_address, - mint: mint_instruction_data, + mint: Some(mint_instruction_data.clone()), token_pool_bump: 0, token_pool_index: 0, actions: vec![], // No actions for basic test @@ -142,12 +143,19 @@ fn test_rnd_create_compressed_mint_account() { // Step 4: Serialize instruction data to test zero-copy let serialized_data = borsh::to_vec(&mint_action_data).unwrap(); - let (mut parsed_instruction_data, _) = + let (parsed_instruction_data, _) = MintActionCompressedInstructionData::zero_copy_at(&serialized_data).unwrap(); // Step 5: Use current get_zero_copy_configs API + // Derive AccountsConfig from parsed instruction data (same as processor) + let accounts_config = AccountsConfig::new(&parsed_instruction_data).unwrap(); + + // Derive CompressedMint from instruction data (same as processor) + let mint_data = parsed_instruction_data.mint.as_ref().unwrap(); + let cmint = CompressedMint::try_from(mint_data).unwrap(); + let (config, mut cpi_bytes, output_mint_config) = - get_zero_copy_configs(&mut parsed_instruction_data).unwrap(); + get_zero_copy_configs(&parsed_instruction_data, &accounts_config, &cmint).unwrap(); let (mut cpi_instruction_struct, _) = InstructionDataInvokeCpiWithReadOnly::new_zero_copy(&mut cpi_bytes[8..], config) @@ -169,6 +177,7 @@ fn test_rnd_create_compressed_mint_account() { input_account, &parsed_instruction_data, merkle_context, + &accounts_config, ) .unwrap(); @@ -179,7 +188,7 @@ fn test_rnd_create_compressed_mint_account() { let output_supply = input_supply + rng.gen_range(0..=1000); // Create a modified mint with updated supply for output using original data - let mut output_mint_data = mint_action_data.mint.clone(); + let mut output_mint_data = mint_instruction_data.clone(); output_mint_data.supply = output_supply; // Test 1: Serialize with Borsh @@ -200,8 +209,8 @@ fn test_rnd_create_compressed_mint_account() { assert_eq!(zc_mint.supply.get(), output_mint_data.supply); assert_eq!(zc_mint.decimals, output_mint_data.decimals); assert_eq!( - zc_mint.metadata.spl_mint_initialized != 0, - output_mint_data.metadata.spl_mint_initialized + zc_mint.metadata.cmint_decompressed != 0, + output_mint_data.metadata.cmint_decompressed ); if let (Some(zc_mint_auth), Some(orig_mint_auth)) = ( @@ -368,7 +377,7 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { metadata: CompressedMintMetadata { version: 3u8, mint: Pubkey::new_from_array([3; 32]), - spl_mint_initialized: false, + cmint_decompressed: false, }, extensions: Some(vec![ExtensionStruct::TokenMetadata(token_metadata)]), }; @@ -396,7 +405,7 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { metadata: CompressedMintMetadata { version: zc_mint.metadata.version, mint: zc_mint.metadata.mint, - spl_mint_initialized: zc_mint.metadata.spl_mint_initialized != 0, + cmint_decompressed: zc_mint.metadata.cmint_decompressed != 0, }, extensions: zc_mint.extensions.as_ref().map(|zc_exts| { zc_exts diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index a6923eb3a2..d7feae6706 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -40,7 +40,7 @@ fn random_optional_pubkey(rng: &mut StdRng, probability: f64) -> Option fn random_compressed_mint_metadata(rng: &mut StdRng) -> CompressedMintMetadata { CompressedMintMetadata { version: rng.gen_range(1..=3) as u8, - spl_mint_initialized: rng.gen_bool(0.5), + cmint_decompressed: rng.gen_bool(0.5), mint: random_pubkey(rng), } } @@ -167,7 +167,7 @@ fn generate_random_instruction_data( let mut mint_metadata = random_compressed_mint_metadata(rng); if let Some(spl_init) = force_spl_initialized { - mint_metadata.spl_mint_initialized = spl_init && create_mint.is_none(); + mint_metadata.cmint_decompressed = spl_init && create_mint.is_none(); } // Generate actions @@ -197,7 +197,7 @@ fn generate_random_instruction_data( } else { None }, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: rng.gen_range(0..=1_000_000_000), decimals: rng.gen_range(0..=9), metadata: mint_metadata, @@ -208,7 +208,7 @@ fn generate_random_instruction_data( } else { None }, - }, + }), } } @@ -230,14 +230,14 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun .iter() .any(|action| matches!(action, Action::MintToCompressed(_))); - // 4. create_spl_mint + // 4. create_spl_mint (for with_mint_signer only) let create_spl_mint = data .actions .iter() .any(|action| matches!(action, Action::CreateSplMint(_))); - // 5. spl_mint_initialized - let spl_mint_initialized = data.mint.metadata.spl_mint_initialized || create_spl_mint; + // 5. cmint_decompressed - only based on metadata flag (matches AccountsConfig::new) + let cmint_decompressed = data.mint.as_ref().unwrap().metadata.cmint_decompressed; // 6. with_mint_signer let with_mint_signer = data.create_mint.is_some() || create_spl_mint; @@ -245,10 +245,24 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun // 7. create_mint let create_mint = data.create_mint.is_some(); + // 8. has_decompress_mint_action + let has_decompress_mint_action = data + .actions + .iter() + .any(|action| matches!(action, Action::DecompressMint(_))); + + // 9. has_compress_and_close_cmint_action + let has_compress_and_close_cmint_action = data + .actions + .iter() + .any(|action| matches!(action, Action::CompressAndCloseCMint(_))); + AccountsConfig { with_cpi_context, + has_decompress_mint_action, + has_compress_and_close_cmint_action, write_to_cpi_context, - spl_mint_initialized, + cmint_decompressed, has_mint_to_actions, with_mint_signer, create_mint, @@ -337,12 +351,25 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi .iter() .any(|action| matches!(action, Action::CreateSplMint(_))); - // Check if SPL mint is initialized - let spl_mint_initialized = - instruction_data.mint.metadata.spl_mint_initialized || create_spl_mint; - - // Return true if any of these conditions are met - has_mint_to_ctoken || create_spl_mint || spl_mint_initialized + // Check for MintToCompressed actions + let has_mint_to_actions = instruction_data + .actions + .iter() + .any(|action| matches!(action, Action::MintToCompressed(_))); + + // cmint_decompressed is only from metadata flag (matches AccountsConfig::new) + let cmint_decompressed = instruction_data + .mint + .as_ref() + .unwrap() + .metadata + .cmint_decompressed; + + // Error conditions matching AccountsConfig::new: + // 1. has_mint_to_ctoken (MintToCToken actions not allowed) + // 2. create_spl_mint (CreateSplMint actions not allowed) + // 3. cmint_decompressed && has_mint_to_actions (mint decompressed + MintToCompressed not allowed) + has_mint_to_ctoken || create_spl_mint || (cmint_decompressed && has_mint_to_actions) } else { false } diff --git a/programs/compressed-token/program/tests/mint_action_accounts_validation.rs b/programs/compressed-token/program/tests/mint_action_accounts_validation.rs index aab9e2ea2c..dcc95eddfd 100644 --- a/programs/compressed-token/program/tests/mint_action_accounts_validation.rs +++ b/programs/compressed-token/program/tests/mint_action_accounts_validation.rs @@ -503,7 +503,7 @@ // .unwrap_or(false); // // Check if SPL mint is initialized based on mint/token_pool_pda/token_program presence -// let spl_mint_initialized = self +// let cmint_decompressed = self // .executing // .as_ref() // .map(|e| { @@ -537,7 +537,7 @@ // AccountsConfig { // with_cpi_context, // write_to_cpi_context, -// spl_mint_initialized, +// cmint_decompressed, // has_mint_to_actions, // with_mint_signer, // create_mint, diff --git a/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs b/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs index a9337d9dd3..521f1b311e 100644 --- a/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs +++ b/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs @@ -5,7 +5,7 @@ use light_ctoken_interface::{ self, instructions::{ extensions::ExtensionInstructionData, - mint_action::{CompressedMintInstructionData, CompressedMintWithContext, CpiContext}, + mint_action::{CompressedMintInstructionData, CpiContext}, }, COMPRESSED_MINT_SEED, }; @@ -51,26 +51,18 @@ pub fn create_compressed_mint_cpi( metadata: light_ctoken_interface::state::CompressedMintMetadata { version: input.version, mint: find_cmint_address(&input.mint_signer).0.to_bytes().into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(input.mint_authority.to_bytes().into()), freeze_authority: input.freeze_authority.map(|auth| auth.to_bytes().into()), extensions: input.extensions, }; - let compressed_mint_with_context = CompressedMintWithContext { - address: mint_address, - mint: compressed_mint_instruction_data, - leaf_index: 0, - prove_by_index: false, - root_index: input.address_merkle_tree_root_index, - }; - let mut instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( mint_address, input.address_merkle_tree_root_index, input.proof, - compressed_mint_with_context.mint.clone(), + compressed_mint_instruction_data, ); if let Some(ctx) = cpi_context { @@ -141,7 +133,7 @@ pub fn create_compressed_mint_cpi_write( metadata: light_ctoken_interface::state::CompressedMintMetadata { version: input.version, mint: find_cmint_address(&input.mint_signer).0.to_bytes().into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(input.mint_authority.to_bytes().into()), freeze_authority: input.freeze_authority.map(|auth| auth.to_bytes().into()), diff --git a/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs b/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs index 2c34d7bc8f..16d54fc7fc 100644 --- a/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs +++ b/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs @@ -15,6 +15,10 @@ pub struct MintActionMetaConfig { pub tokens_out_queue: Option, // Output queue for new token accounts pub cpi_context: Option, pub ctoken_accounts: Vec, // For mint_to_ctoken actions + pub cmint: Option, // CMint PDA account for DecompressMint action + pub compressible_config: Option, // CompressibleConfig account (when creating CMint) + pub rent_sponsor: Option, // Rent sponsor PDA (when creating CMint) + pub mint_signer_must_sign: bool, // true for create_mint, false for decompress_mint } impl MintActionMetaConfig { @@ -36,6 +40,10 @@ impl MintActionMetaConfig { tokens_out_queue: None, cpi_context: None, ctoken_accounts: Vec::new(), + cmint: None, + compressible_config: None, + rent_sponsor: None, + mint_signer_must_sign: true, } } @@ -57,6 +65,10 @@ impl MintActionMetaConfig { tokens_out_queue: None, cpi_context: None, ctoken_accounts: Vec::new(), + cmint: None, + compressible_config: None, + rent_sponsor: None, + mint_signer_must_sign: false, } } @@ -81,6 +93,10 @@ impl MintActionMetaConfig { tokens_out_queue: None, cpi_context: Some(cpi_context_pubkey), ctoken_accounts: Vec::new(), + cmint: None, + compressible_config: None, + rent_sponsor: None, + mint_signer_must_sign: false, }) } @@ -94,6 +110,41 @@ impl MintActionMetaConfig { self } + pub fn with_cmint(mut self, cmint: Pubkey) -> Self { + self.cmint = Some(cmint); + self + } + + /// Set the mint_signer account with signing required. + /// Use for create_mint and create_spl_mint actions. + pub fn with_mint_signer(mut self, mint_signer: Pubkey) -> Self { + self.mint_signer = Some(mint_signer); + self.mint_signer_must_sign = true; + self + } + + /// Set the mint_signer account without requiring signature. + /// Use for decompress_mint where only PDA derivation is needed. + pub fn with_mint_signer_no_sign(mut self, mint_signer: Pubkey) -> Self { + self.mint_signer = Some(mint_signer); + self.mint_signer_must_sign = false; + self + } + + /// Configure compressible CMint with config and rent sponsor. + /// CMint is always compressible - this sets all required accounts. + pub fn with_compressible_cmint( + mut self, + cmint: Pubkey, + compressible_config: Pubkey, + rent_sponsor: Pubkey, + ) -> Self { + self.cmint = Some(cmint); + self.compressible_config = Some(compressible_config); + self.rent_sponsor = Some(rent_sponsor); + self + } + /// Get the account metas for a mint action instruction #[profile] pub fn to_account_metas(self) -> Vec { @@ -105,14 +156,32 @@ impl MintActionMetaConfig { false, )); - // mint_signer is present when creating a new mint + // mint_signer is present when creating a new mint or decompressing if let Some(mint_signer) = self.mint_signer { - // mint signer always needs to sign when present - metas.push(AccountMeta::new_readonly(mint_signer, true)); + // mint_signer needs to sign for create_mint/create_spl_mint, not for decompress_mint + metas.push(AccountMeta::new_readonly( + mint_signer, + self.mint_signer_must_sign, + )); } metas.push(AccountMeta::new_readonly(self.authority, true)); + // CompressibleConfig account (when creating compressible CMint) + if let Some(config) = self.compressible_config { + metas.push(AccountMeta::new_readonly(config, false)); + } + + // CMint account is present when decompressing the mint (DecompressMint action) or syncing + if let Some(cmint) = self.cmint { + metas.push(AccountMeta::new(cmint, false)); + } + + // Rent sponsor PDA (when creating compressible CMint) + if let Some(rent_sponsor) = self.rent_sponsor { + metas.push(AccountMeta::new(rent_sponsor, false)); + } + metas.push(AccountMeta::new(self.fee_payer, true)); metas.push(AccountMeta::new_readonly( @@ -148,11 +217,10 @@ impl MintActionMetaConfig { metas.push(AccountMeta::new(self.tree_pubkey, false)); - // input_queue is present when NOT creating a new mint (mint_signer.is_none()) - if self.mint_signer.is_none() { - if let Some(input_queue) = self.input_queue { - metas.push(AccountMeta::new(input_queue, false)); - } + // input_queue is present when operating on an existing compressed mint + // (input_queue is set via new() for existing mints, None via new_create_mint() for new mints) + if let Some(input_queue) = self.input_queue { + metas.push(AccountMeta::new(input_queue, false)); } // tokens_out_queue is present when there are MintToCompressed actions diff --git a/sdk-libs/ctoken-sdk/src/ctoken/burn.rs b/sdk-libs/ctoken-sdk/src/ctoken/burn.rs new file mode 100644 index 0000000000..a36973e5a9 --- /dev/null +++ b/sdk-libs/ctoken-sdk/src/ctoken/burn.rs @@ -0,0 +1,114 @@ +use light_sdk_types::C_TOKEN_PROGRAM_ID; +use solana_account_info::AccountInfo; +use solana_cpi::{invoke, invoke_signed}; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +/// # Burn tokens from a ctoken account: +/// ```rust +/// # use solana_pubkey::Pubkey; +/// # use light_ctoken_sdk::ctoken::BurnCToken; +/// # let source = Pubkey::new_unique(); +/// # let cmint = Pubkey::new_unique(); +/// # let authority = Pubkey::new_unique(); +/// let instruction = BurnCToken { +/// source, +/// cmint, +/// amount: 100, +/// authority, +/// max_top_up: None, +/// }.instruction()?; +/// # Ok::<(), solana_program_error::ProgramError>(()) +/// ``` +pub struct BurnCToken { + /// CToken account to burn from + pub source: Pubkey, + /// CMint account (supply tracking) + pub cmint: Pubkey, + /// Amount of tokens to burn + pub amount: u64, + /// Owner of the CToken account + pub authority: Pubkey, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + /// When set to a non-zero value, includes max_top_up in instruction data + pub max_top_up: Option, +} + +/// # Burn ctoken via CPI: +/// ```rust,no_run +/// # use light_ctoken_sdk::ctoken::BurnCTokenCpi; +/// # use solana_account_info::AccountInfo; +/// # let source: AccountInfo = todo!(); +/// # let cmint: AccountInfo = todo!(); +/// # let authority: AccountInfo = todo!(); +/// BurnCTokenCpi { +/// source, +/// cmint, +/// amount: 100, +/// authority, +/// max_top_up: None, +/// } +/// .invoke()?; +/// # Ok::<(), solana_program_error::ProgramError>(()) +/// ``` +pub struct BurnCTokenCpi<'info> { + pub source: AccountInfo<'info>, + pub cmint: AccountInfo<'info>, + pub amount: u64, + pub authority: AccountInfo<'info>, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + pub max_top_up: Option, +} + +impl<'info> BurnCTokenCpi<'info> { + pub fn instruction(&self) -> Result { + BurnCToken::from(self).instruction() + } + + pub fn invoke(self) -> Result<(), ProgramError> { + let instruction = BurnCToken::from(&self).instruction()?; + let account_infos = [self.source, self.cmint, self.authority]; + invoke(&instruction, &account_infos) + } + + pub fn invoke_signed(self, signer_seeds: &[&[&[u8]]]) -> Result<(), ProgramError> { + let instruction = BurnCToken::from(&self).instruction()?; + let account_infos = [self.source, self.cmint, self.authority]; + invoke_signed(&instruction, &account_infos, signer_seeds) + } +} + +impl<'info> From<&BurnCTokenCpi<'info>> for BurnCToken { + fn from(cpi: &BurnCTokenCpi<'info>) -> Self { + Self { + source: *cpi.source.key, + cmint: *cpi.cmint.key, + amount: cpi.amount, + authority: *cpi.authority.key, + max_top_up: cpi.max_top_up, + } + } +} + +impl BurnCToken { + pub fn instruction(self) -> Result { + Ok(Instruction { + program_id: Pubkey::from(C_TOKEN_PROGRAM_ID), + accounts: vec![ + AccountMeta::new(self.source, false), + AccountMeta::new(self.cmint, false), + AccountMeta::new_readonly(self.authority, true), + ], + data: { + let mut data = vec![8u8]; // CTokenBurn discriminator + data.extend_from_slice(&self.amount.to_le_bytes()); + // Include max_top_up if set (10-byte format) + if let Some(max_top_up) = self.max_top_up { + data.extend_from_slice(&max_top_up.to_le_bytes()); + } + data + }, + }) + } +} diff --git a/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs b/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs index 2cac65a413..803de5b187 100644 --- a/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs +++ b/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs @@ -120,7 +120,7 @@ impl CreateCMint { metadata: light_ctoken_interface::state::CompressedMintMetadata { version: 3, mint: self.params.mint.to_bytes().into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(self.params.mint_authority.to_bytes().into()), freeze_authority: self @@ -261,7 +261,7 @@ impl CreateCompressedMintCpiWrite { metadata: light_ctoken_interface::state::CompressedMintMetadata { version: self.params.version, mint: self.params.mint.to_bytes().into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(self.params.mint_authority.to_bytes().into()), freeze_authority: self diff --git a/sdk-libs/ctoken-sdk/src/ctoken/ctoken_mint_to.rs b/sdk-libs/ctoken-sdk/src/ctoken/ctoken_mint_to.rs new file mode 100644 index 0000000000..1b001a5ca6 --- /dev/null +++ b/sdk-libs/ctoken-sdk/src/ctoken/ctoken_mint_to.rs @@ -0,0 +1,114 @@ +use light_sdk_types::C_TOKEN_PROGRAM_ID; +use solana_account_info::AccountInfo; +use solana_cpi::{invoke, invoke_signed}; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +/// # Mint tokens to a ctoken account (simple 3-account instruction): +/// ```rust +/// # use solana_pubkey::Pubkey; +/// # use light_ctoken_sdk::ctoken::CTokenMintTo; +/// # let cmint = Pubkey::new_unique(); +/// # let destination = Pubkey::new_unique(); +/// # let authority = Pubkey::new_unique(); +/// let instruction = CTokenMintTo { +/// cmint, +/// destination, +/// amount: 100, +/// authority, +/// max_top_up: None, +/// }.instruction()?; +/// # Ok::<(), solana_program_error::ProgramError>(()) +/// ``` +pub struct CTokenMintTo { + /// CMint account (supply tracking) + pub cmint: Pubkey, + /// Destination CToken account to mint to + pub destination: Pubkey, + /// Amount of tokens to mint + pub amount: u64, + /// Mint authority + pub authority: Pubkey, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + /// When set to a non-zero value, includes max_top_up in instruction data + pub max_top_up: Option, +} + +/// # Mint to ctoken via CPI: +/// ```rust,no_run +/// # use light_ctoken_sdk::ctoken::CTokenMintToCpi; +/// # use solana_account_info::AccountInfo; +/// # let cmint: AccountInfo = todo!(); +/// # let destination: AccountInfo = todo!(); +/// # let authority: AccountInfo = todo!(); +/// CTokenMintToCpi { +/// cmint, +/// destination, +/// amount: 100, +/// authority, +/// max_top_up: None, +/// } +/// .invoke()?; +/// # Ok::<(), solana_program_error::ProgramError>(()) +/// ``` +pub struct CTokenMintToCpi<'info> { + pub cmint: AccountInfo<'info>, + pub destination: AccountInfo<'info>, + pub amount: u64, + pub authority: AccountInfo<'info>, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + pub max_top_up: Option, +} + +impl<'info> CTokenMintToCpi<'info> { + pub fn instruction(&self) -> Result { + CTokenMintTo::from(self).instruction() + } + + pub fn invoke(self) -> Result<(), ProgramError> { + let instruction = CTokenMintTo::from(&self).instruction()?; + let account_infos = [self.cmint, self.destination, self.authority]; + invoke(&instruction, &account_infos) + } + + pub fn invoke_signed(self, signer_seeds: &[&[&[u8]]]) -> Result<(), ProgramError> { + let instruction = CTokenMintTo::from(&self).instruction()?; + let account_infos = [self.cmint, self.destination, self.authority]; + invoke_signed(&instruction, &account_infos, signer_seeds) + } +} + +impl<'info> From<&CTokenMintToCpi<'info>> for CTokenMintTo { + fn from(cpi: &CTokenMintToCpi<'info>) -> Self { + Self { + cmint: *cpi.cmint.key, + destination: *cpi.destination.key, + amount: cpi.amount, + authority: *cpi.authority.key, + max_top_up: cpi.max_top_up, + } + } +} + +impl CTokenMintTo { + pub fn instruction(self) -> Result { + Ok(Instruction { + program_id: Pubkey::from(C_TOKEN_PROGRAM_ID), + accounts: vec![ + AccountMeta::new(self.cmint, false), + AccountMeta::new(self.destination, false), + AccountMeta::new_readonly(self.authority, true), + ], + data: { + let mut data = vec![7u8]; // CTokenMintTo discriminator + data.extend_from_slice(&self.amount.to_le_bytes()); + // Include max_top_up if set (10-byte format) + if let Some(max_top_up) = self.max_top_up { + data.extend_from_slice(&max_top_up.to_le_bytes()); + } + data + }, + }) + } +} diff --git a/sdk-libs/ctoken-sdk/src/ctoken/mod.rs b/sdk-libs/ctoken-sdk/src/ctoken/mod.rs index 295e922219..dc98ca674b 100644 --- a/sdk-libs/ctoken-sdk/src/ctoken/mod.rs +++ b/sdk-libs/ctoken-sdk/src/ctoken/mod.rs @@ -65,11 +65,13 @@ //! ``` //! +mod burn; mod close; mod compressible; mod create; mod create_ata; mod create_cmint; +mod ctoken_mint_to; mod decompress; mod mint_to; mod transfer_ctoken; @@ -77,11 +79,13 @@ mod transfer_ctoken_spl; mod transfer_interface; mod transfer_spl_ctoken; +pub use burn::*; pub use close::*; pub use compressible::{CompressibleParams, CompressibleParamsCpi}; pub use create::*; pub use create_ata::*; pub use create_cmint::*; +pub use ctoken_mint_to::*; pub use decompress::DecompressToCtoken; use light_compressible::config::CompressibleConfig; pub use light_ctoken_interface::{ diff --git a/sdk-libs/token-client/Cargo.toml b/sdk-libs/token-client/Cargo.toml index 1605305a1e..0997cfa463 100644 --- a/sdk-libs/token-client/Cargo.toml +++ b/sdk-libs/token-client/Cargo.toml @@ -9,6 +9,7 @@ edition = { workspace = true } # Light Protocol dependencies light-ctoken-types = { workspace = true } light-compressed-account = { workspace = true, features = ["std"] } +light-compressible = { workspace = true, features = ["anchor"] } light-ctoken-interface = { workspace = true } light-sdk = { workspace = true } light-client = { workspace = true, features = ["v2"] } diff --git a/sdk-libs/token-client/src/actions/mint_action.rs b/sdk-libs/token-client/src/actions/mint_action.rs index 1e5f66c845..5e2f59127a 100644 --- a/sdk-libs/token-client/src/actions/mint_action.rs +++ b/sdk-libs/token-client/src/actions/mint_action.rs @@ -3,14 +3,17 @@ use light_client::{ rpc::{Rpc, RpcError}, }; use light_ctoken_interface::instructions::mint_action::Recipient; -use light_ctoken_sdk::compressed_token::create_compressed_mint::derive_cmint_compressed_address; +use light_ctoken_sdk::compressed_token::create_compressed_mint::{ + derive_cmint_compressed_address, find_cmint_address, +}; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; use solana_signer::Signer; use crate::instructions::mint_action::{ - create_mint_action_instruction, MintActionParams, MintActionType, MintToRecipient, + create_mint_action_instruction, DecompressMintParams, MintActionParams, MintActionType, + MintToRecipient, }; /// Executes a mint action that can perform multiple operations in a single instruction @@ -58,7 +61,6 @@ pub async fn mint_action( .await } -// TODO: remove /// Convenience function to execute a comprehensive mint action /// /// This function simplifies calling mint_action by handling common patterns @@ -68,11 +70,15 @@ pub async fn mint_action_comprehensive( mint_seed: &Keypair, authority: &Keypair, payer: &Keypair, + // Whether to decompress the mint to a CMint Solana account (with rent params) + decompress_mint: Option, + // Whether to compress and close the CMint Solana account + compress_and_close_cmint: bool, mint_to_recipients: Vec, mint_to_decompressed_recipients: Vec, update_mint_authority: Option, update_freeze_authority: Option, - // Parameters for mint creation (required if create_spl_mint is true) + // Parameters for mint creation (required when creating a new mint) new_mint: Option, ) -> Result { // Derive addresses @@ -99,9 +105,7 @@ pub async fn mint_action_comprehensive( } if !mint_to_decompressed_recipients.is_empty() { - use light_ctoken_sdk::{ - compressed_token::create_compressed_mint::find_cmint_address, ctoken::derive_ctoken_ata, - }; + use light_ctoken_sdk::ctoken::derive_ctoken_ata; let (spl_mint_pda, _) = find_cmint_address(&mint_seed.pubkey()); @@ -128,9 +132,26 @@ pub async fn mint_action_comprehensive( }); } + // Add DecompressMint action if requested + // Check before moving to use later for mint_signer determination + let has_decompress_mint = decompress_mint.is_some(); + if let Some(decompress_params) = decompress_mint { + let (_, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + actions.push(MintActionType::DecompressMint { + cmint_bump, + rent_payment: decompress_params.rent_payment, + write_top_up: decompress_params.write_top_up, + }); + } + + // Add CompressAndCloseCMint action if requested + if compress_and_close_cmint { + actions.push(MintActionType::CompressAndCloseCMint { idempotent: false }); + } + // Determine if mint_signer is needed - matches onchain logic: - // with_mint_signer = create_mint() | has_CreateSplMint_action - let mint_signer = if new_mint.is_some() { + // with_mint_signer = create_mint() | has_DecompressMint_action + let mint_signer = if new_mint.is_some() || has_decompress_mint { Some(mint_seed) } else { None diff --git a/sdk-libs/token-client/src/instructions/mint_action.rs b/sdk-libs/token-client/src/instructions/mint_action.rs index f9e51e5626..14382da97b 100644 --- a/sdk-libs/token-client/src/instructions/mint_action.rs +++ b/sdk-libs/token-client/src/instructions/mint_action.rs @@ -4,13 +4,15 @@ use light_client::{ rpc::{Rpc, RpcError}, }; use light_compressed_account::instruction_data::traits::LightInstructionData; +use light_compressible::config::CompressibleConfig; use light_ctoken_interface::{ instructions::{ extensions::{token_metadata::TokenMetadataInstructionData, ExtensionInstructionData}, mint_action::{ - CompressedMintWithContext, MintActionCompressedInstructionData, MintToCTokenAction, - MintToCompressedAction, Recipient, RemoveMetadataKeyAction, UpdateAuthority, - UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, + CompressAndCloseCMintAction, CompressedMintWithContext, DecompressMintAction, + MintActionCompressedInstructionData, MintToCTokenAction, MintToCompressedAction, + Recipient, RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, + UpdateMetadataFieldAction, }, }, state::CompressedMint, @@ -64,10 +66,25 @@ pub enum MintActionType { key: Vec, idempotent: u8, }, + /// Decompress the compressed mint to a CMint Solana account. + /// CMint is always compressible - rent_payment must be >= 2. + DecompressMint { + cmint_bump: u8, + /// Rent payment in epochs (prepaid). Must be >= 2. + rent_payment: u8, + /// Lamports allocated for future write operations (top-up per write). + write_top_up: u32, + }, + /// Compress and close a CMint Solana account. The compressed mint state is preserved. + /// Permissionless - anyone can call if is_compressible() returns true (rent expired). + CompressAndCloseCMint { + /// If true, succeed silently when CMint doesn't exist + idempotent: bool, + }, } /// Parameters for creating a new mint -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct NewMint { pub decimals: u8, pub supply: u64, @@ -78,7 +95,7 @@ pub struct NewMint { } /// Parameters for mint action instruction -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct MintActionParams { pub compressed_mint_address: [u8; 32], pub mint_seed: Pubkey, @@ -97,6 +114,18 @@ pub async fn create_mint_action_instruction( // Check if we're creating a new mint let is_creating_mint = params.new_mint.is_some(); + // Check if DecompressMint action is present + let has_decompress_mint = params + .actions + .iter() + .any(|a| matches!(a, MintActionType::DecompressMint { .. })); + + // Check if CompressAndCloseCMint action is present + let has_compress_and_close_cmint = params + .actions + .iter() + .any(|a| matches!(a, MintActionType::CompressAndCloseCMint { .. })); + // Get address tree and output queue info let address_tree_pubkey = rpc.get_address_tree_v2().tree; @@ -127,7 +156,8 @@ pub async fn create_mint_action_instruction( metadata: light_ctoken_interface::state::CompressedMintMetadata { version: new_mint.version, mint: find_cmint_address(¶ms.mint_seed).0.to_bytes().into(), - spl_mint_initialized: false, // Will be set to true if CreateSplMint action is present + // false for new mint - on-chain sets to true after DecompressMint + cmint_decompressed: false, }, mint_authority: Some(new_mint.mint_authority.to_bytes().into()), freeze_authority: new_mint.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -142,7 +172,7 @@ pub async fn create_mint_action_instruction( leaf_index: 0, // Not applicable for creation root_index: rpc_proof_result.addresses[0].root_index, address: params.compressed_mint_address, - mint: mint_data, + mint: Some(mint_data), }; ( @@ -161,13 +191,11 @@ pub async fn create_mint_action_instruction( params.compressed_mint_address )))?; - // Deserialize the compressed mint - let compressed_mint: CompressedMint = BorshDeserialize::deserialize( - &mut compressed_mint_account.data.unwrap().data.as_slice(), - ) - .map_err(|e| { - RpcError::CustomError(format!("Failed to deserialize compressed mint: {}", e)) - })?; + // Try to deserialize the compressed mint - may be None if CMint is source of truth + let compressed_mint: Option = compressed_mint_account + .data + .as_ref() + .and_then(|d| BorshDeserialize::deserialize(&mut d.data.as_slice()).ok()); let rpc_proof_result = rpc .get_validity_proof(vec![compressed_mint_account.hash], vec![], None) @@ -182,7 +210,7 @@ pub async fn create_mint_action_instruction( .root_index() .unwrap_or_default(), address: params.compressed_mint_address, - mint: compressed_mint.try_into().unwrap(), + mint: compressed_mint.map(|m| m.try_into().unwrap()), }; ( @@ -200,7 +228,7 @@ pub async fn create_mint_action_instruction( proof.ok_or_else(|| { RpcError::CustomError("Proof is required for mint creation".to_string()) })?, - compressed_mint_inputs.mint.clone(), + compressed_mint_inputs.mint.unwrap().clone(), ) } else { MintActionCompressedInstructionData::new(compressed_mint_inputs.clone(), proof) @@ -275,6 +303,19 @@ pub async fn create_mint_action_instruction( key, idempotent, }), + MintActionType::DecompressMint { + cmint_bump, + rent_payment, + write_top_up, + } => instruction_data.with_decompress_mint(DecompressMintAction { + cmint_bump, + rent_payment, + write_top_up, + }), + MintActionType::CompressAndCloseCMint { idempotent } => instruction_data + .with_compress_and_close_cmint(CompressAndCloseCMintAction { + idempotent: if idempotent { 1 } else { 0 }, + }), }; } @@ -307,6 +348,33 @@ pub async fn create_mint_action_instruction( config = config.with_ctoken_accounts(ctoken_accounts); } + // 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); + // Get config and rent_sponsor from v1 config PDA + let config_address = CompressibleConfig::ctoken_v1_config_pda(); + let compressible_config: CompressibleConfig = rpc + .get_anchor_account(&config_address) + .await? + .ok_or_else(|| { + RpcError::CustomError(format!( + "CompressibleConfig not found at {}", + config_address + )) + })?; + config = config.with_compressible_cmint( + cmint_pda, + config_address, + compressible_config.rent_sponsor, + ); + // DecompressMint needs mint_signer even when not creating a new mint + // (for PDA derivation of CMint account) + // CompressAndCloseCMint does NOT need mint_signer - it verifies CMint via compressed_mint.metadata.mint + if has_decompress_mint && !is_creating_mint { + config = config.with_mint_signer(params.mint_seed); + } + } + // Get account metas let account_metas = config.to_account_metas(); @@ -323,6 +391,25 @@ pub async fn create_mint_action_instruction( }) } +/// Parameters for decompressing a mint to a CMint Solana account. +/// CMint is always compressible. +#[derive(Debug, Clone)] +pub struct DecompressMintParams { + /// Rent payment in epochs (prepaid). Must be >= 2. + pub rent_payment: u8, + /// Lamports allocated for future write operations (top-up per write). + pub write_top_up: u32, +} + +impl Default for DecompressMintParams { + fn default() -> Self { + Self { + rent_payment: 2, // Minimum valid rent_payment + write_top_up: 0, // No write top-up by default + } + } +} + /// Helper function to create a comprehensive mint action instruction #[allow(clippy::too_many_arguments)] pub async fn create_comprehensive_mint_action_instruction( @@ -330,11 +417,12 @@ pub async fn create_comprehensive_mint_action_instruction( mint_seed: &Keypair, authority: Pubkey, payer: Pubkey, - create_spl_mint: bool, + // Whether to decompress the mint to a CMint Solana account (with rent params) + decompress_mint: Option, mint_to_recipients: Vec<(Pubkey, u64)>, update_mint_authority: Option, update_freeze_authority: Option, - // Parameters for mint creation (required if create_spl_mint is true) + // Parameters for mint creation (required when creating a new mint) new_mint: Option, ) -> Result { // Derive addresses @@ -345,12 +433,6 @@ pub async fn create_comprehensive_mint_action_instruction( // Build actions let mut actions = Vec::new(); - if create_spl_mint { - return Err(RpcError::CustomError( - "CreateSplMint is no longer supported".to_string(), - )); - } - if !mint_to_recipients.is_empty() { let recipients = mint_to_recipients .into_iter() @@ -375,6 +457,16 @@ pub async fn create_comprehensive_mint_action_instruction( }); } + // Add DecompressMint action if requested + if let Some(decompress_params) = decompress_mint { + let (_, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + actions.push(MintActionType::DecompressMint { + cmint_bump, + rent_payment: decompress_params.rent_payment, + write_top_up: decompress_params.write_top_up, + }); + } + create_mint_action_instruction( rpc, MintActionParams { diff --git a/sdk-libs/token-client/src/instructions/mint_to_compressed.rs b/sdk-libs/token-client/src/instructions/mint_to_compressed.rs index 348941bf53..4e05dcc7de 100644 --- a/sdk-libs/token-client/src/instructions/mint_to_compressed.rs +++ b/sdk-libs/token-client/src/instructions/mint_to_compressed.rs @@ -7,14 +7,11 @@ use light_ctoken_interface::{ instructions::mint_action::{CompressedMintWithContext, Recipient}, state::{CompressedMint, TokenDataVersion}, }; -use light_ctoken_sdk::{ - compressed_token::{ - create_compressed_mint::derive_cmint_from_spl_mint, - mint_to_compressed::{ - create_mint_to_compressed_instruction, DecompressedMintConfig, MintToCompressedInputs, - }, +use light_ctoken_sdk::compressed_token::{ + create_compressed_mint::derive_cmint_from_spl_mint, + mint_to_compressed::{ + create_mint_to_compressed_instruction, DecompressedMintConfig, MintToCompressedInputs, }, - spl_interface::{derive_spl_interface_pda, find_spl_interface_pda_with_index}, }; use solana_instruction::Instruction; use solana_pubkey::Pubkey; @@ -42,12 +39,11 @@ pub async fn mint_to_compressed_instruction( compressed_mint_address )))?; - // Deserialize the compressed mint - let compressed_mint: CompressedMint = - BorshDeserialize::deserialize(&mut compressed_mint_account.data.unwrap().data.as_slice()) - .map_err(|e| { - RpcError::CustomError(format!("Failed to deserialize compressed mint: {}", e)) - })?; + // Try to deserialize the compressed mint - may be None if CMint is source of truth + let compressed_mint: Option = compressed_mint_account + .data + .as_ref() + .and_then(|d| BorshDeserialize::deserialize(&mut d.data.as_slice()).ok()); let rpc_proof_result = rpc .get_validity_proof(vec![compressed_mint_account.hash], vec![], None) @@ -57,24 +53,17 @@ pub async fn mint_to_compressed_instruction( // Get state tree info for outputs let state_tree_info = rpc.get_random_state_tree_info()?; - // Create decompressed mint config and token pool if mint is decompressed - let decompressed_mint_config = if compressed_mint.metadata.spl_mint_initialized { - let (spl_interface_pda, _) = find_spl_interface_pda_with_index(&spl_mint_pda, 0); - Some(DecompressedMintConfig { - mint_pda: spl_mint_pda, - token_pool_pda: spl_interface_pda, - token_program: spl_token_2022::ID, - }) - } else { - None - }; + // Check if CMint is decompressed (source of truth) + let cmint_decompressed = compressed_mint + .as_ref() + .map(|m| m.metadata.cmint_decompressed) + .unwrap_or(true); // If no data, assume CMint is source of truth - // Derive spl interface pda if needed for decompressed mints - let spl_interface_pda = if compressed_mint.metadata.spl_mint_initialized { - Some(derive_spl_interface_pda(&spl_mint_pda, 0)) - } else { - None - }; + if cmint_decompressed { + unimplemented!("SPL mint synchronization for decompressed CMint not yet implemented"); + } + let decompressed_mint_config: Option> = None; + let spl_interface_pda: Option = None; // Prepare compressed mint inputs let compressed_mint_inputs = CompressedMintWithContext { @@ -85,7 +74,7 @@ pub async fn mint_to_compressed_instruction( .root_index() .unwrap_or_default(), address: compressed_mint_address, - mint: compressed_mint.try_into().unwrap(), + mint: compressed_mint.map(|m| m.try_into().unwrap()), }; // Create the instruction diff --git a/sdk-libs/token-client/src/instructions/update_compressed_mint.rs b/sdk-libs/token-client/src/instructions/update_compressed_mint.rs index 154cbf9a4b..b7b868b583 100644 --- a/sdk-libs/token-client/src/instructions/update_compressed_mint.rs +++ b/sdk-libs/token-client/src/instructions/update_compressed_mint.rs @@ -88,7 +88,7 @@ pub async fn update_compressed_mint_instruction( prove_by_index: true, // Use index-based proof like mint_to_compressed root_index: 0, // Use 0 like mint_to_compressed address: compressed_mint_account.address.unwrap_or([0u8; 32]), - mint: compressed_mint_instruction_data, + mint: Some(compressed_mint_instruction_data), }; // Create instruction using the existing SDK function diff --git a/sdk-tests/csdk-anchor-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-derived-test/src/lib.rs index 1a6ebdb6c1..37e4aeff2c 100644 --- a/sdk-tests/csdk-anchor-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-derived-test/src/lib.rs @@ -148,7 +148,7 @@ pub mod csdk_anchor_derived_test { compression_params.mint_with_context.address, 0, // root_index for new addresses proof, - compression_params.mint_with_context.mint.clone(), + compression_params.mint_with_context.mint.clone().unwrap(), ) .with_mint_to_compressed(MintToCompressedAction { token_account_version: 3, diff --git a/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs index 4e93b68646..d9c940ca78 100644 --- a/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs @@ -663,18 +663,18 @@ pub async fn create_user_record_and_game_session( prove_by_index: false, root_index: mint_address_tree_info.root_index, address: compressed_mint_address, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: 0, decimals, metadata: CompressedMintMetadata { version: 3, mint: spl_mint.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), extensions: None, - }, + }), }, }, }; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index c3d567ba2b..ac4ca4fcd3 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -189,7 +189,7 @@ pub mod csdk_anchor_full_derived_test { compression_params.mint_with_context.address, 0, // root_index for new addresses proof, - compression_params.mint_with_context.mint.clone(), + compression_params.mint_with_context.mint.clone().unwrap(), ) .with_mint_to_compressed(MintToCompressedAction { token_account_version: 3, diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index 8b02ab4f2d..4c427b933e 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -323,18 +323,18 @@ pub async fn create_user_record_and_game_session( prove_by_index: false, root_index: mint_address_tree_info.root_index, address: compressed_mint_address, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: 0, decimals, metadata: CompressedMintMetadata { version: 3, mint: spl_mint.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), extensions: None, - }, + }), }, }, }; diff --git a/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs b/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs index 1764a26ef9..755a1d2bc0 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs @@ -127,7 +127,7 @@ pub fn create_user_record_and_game_session<'info>( compression_params.mint_with_context.address, 0, // root_index proof, - compression_params.mint_with_context.mint.clone(), + compression_params.mint_with_context.mint.clone().unwrap(), ) .with_mint_to_compressed(MintToCompressedAction::new(vec![ Recipient::new( diff --git a/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs b/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs index 5ef5cdd5c8..b949f58d95 100644 --- a/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs @@ -327,18 +327,18 @@ pub async fn create_user_record_and_game_session( prove_by_index: false, root_index: mint_address_tree_info.root_index, address: compressed_mint_address, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: 0, decimals, metadata: CompressedMintMetadata { version: 3, mint: spl_mint.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), extensions: None, - }, + }), }, }, }; diff --git a/sdk-tests/sdk-ctoken-test/tests/shared.rs b/sdk-tests/sdk-ctoken-test/tests/shared.rs index c2edfa9e78..3f728be9f3 100644 --- a/sdk-tests/sdk-ctoken-test/tests/shared.rs +++ b/sdk-tests/sdk-ctoken-test/tests/shared.rs @@ -145,7 +145,7 @@ pub async fn setup_create_compressed_mint( .root_index .root_index() .unwrap_or_default(), - mint: compressed_mint.try_into().unwrap(), + mint: Some(compressed_mint.try_into().unwrap()), }; // Build mint params with first recipient diff --git a/sdk-tests/sdk-ctoken-test/tests/test_mint_to_ctoken.rs b/sdk-tests/sdk-ctoken-test/tests/test_mint_to_ctoken.rs index 25d59bba1a..2dc52dd4e7 100644 --- a/sdk-tests/sdk-ctoken-test/tests/test_mint_to_ctoken.rs +++ b/sdk-tests/sdk-ctoken-test/tests/test_mint_to_ctoken.rs @@ -111,7 +111,7 @@ async fn test_mint_to_ctoken() { .root_index .root_index() .unwrap_or_default(), // Will be updated with validity proof - mint: compressed_mint.try_into().unwrap(), + mint: Some(compressed_mint.try_into().unwrap()), }; // Build instruction data for wrapper program let mint_to_data = MintToCTokenData { @@ -346,7 +346,7 @@ async fn test_mint_to_ctoken_invoke_signed() { .root_index .root_index() .unwrap_or_default(), - mint: compressed_mint.try_into().unwrap(), + mint: Some(compressed_mint.try_into().unwrap()), }; // Build instruction data for wrapper program diff --git a/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs b/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs index 92e36f4564..2691c18584 100644 --- a/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs +++ b/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs @@ -20,7 +20,7 @@ pub fn process_mint_action<'a, 'info>( input.compressed_mint_with_context.address, input.compressed_mint_with_context.root_index, light_compressed_account::instruction_data::compressed_proof::CompressedProof::default(), // Dummy proof for CPI write - input.compressed_mint_with_context.mint.clone(), + input.compressed_mint_with_context.mint.clone().unwrap(), ); // Add MintToCompressed action diff --git a/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs b/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs index 11d0939ff8..e34ec3c079 100644 --- a/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs +++ b/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs @@ -22,7 +22,7 @@ pub fn process_mint_action<'a, 'info>( input.compressed_mint_with_context.address, input.compressed_mint_with_context.root_index, compressed_proof, - input.compressed_mint_with_context.mint.clone(), + input.compressed_mint_with_context.mint.clone().unwrap(), ) .with_mint_to_compressed(MintToCompressedAction { token_account_version: 2, diff --git a/sdk-tests/sdk-token-test/tests/ctoken_pda.rs b/sdk-tests/sdk-token-test/tests/ctoken_pda.rs index 6fd3e29e28..20e73f0c76 100644 --- a/sdk-tests/sdk-token-test/tests/ctoken_pda.rs +++ b/sdk-tests/sdk-token-test/tests/ctoken_pda.rs @@ -195,18 +195,18 @@ pub async fn create_mint( prove_by_index: false, root_index: rpc_result.addresses[0].root_index, address: compressed_mint_address, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: 0, decimals, metadata: CompressedMintMetadata { version: 3, mint: mint.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: freeze_authority.map(|fa| fa.into()), extensions: metadata.map(|m| vec![light_ctoken_interface::instructions::extensions::ExtensionInstructionData::TokenMetadata(m)]), - }, + }), }; let token_recipients = vec![Recipient::new( diff --git a/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs b/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs index 01853dd6b1..3c1cc454a1 100644 --- a/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs +++ b/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs @@ -115,6 +115,8 @@ async fn setup_decompress_full_test(num_inputs: usize) -> (LightProgramTest, Tes &mint_seed, &payer, &payer, + None, // decompress_mint + false, // compress_and_close_cmint compressed_recipients, Vec::new(), None, @@ -390,7 +392,7 @@ async fn test_decompress_full_cpi_with_context() { leaf_index: compressed_mint_account.leaf_index, root_index: 0, address: compressed_mint_address, - mint: compressed_mint.try_into().unwrap(), + mint: Some(compressed_mint.try_into().unwrap()), }; let packed_tree_info = rpc_result.pack_tree_infos(&mut remaining_accounts); let mint_params = MintCompressedTokensCpiWriteParams { diff --git a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs index 0d4b1f0044..3525d2e05f 100644 --- a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs +++ b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs @@ -267,18 +267,18 @@ pub async fn create_mint( prove_by_index: false, root_index: rpc_result.addresses[0].root_index, address: compressed_mint_address, - mint: CompressedMintInstructionData { + mint: Some(CompressedMintInstructionData { supply: 0, decimals, metadata: CompressedMintMetadata { version: 3, mint: mint.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: freeze_authority.map(|fa| fa.into()), extensions: metadata.map(|m| vec![light_ctoken_interface::instructions::extensions::ExtensionInstructionData::TokenMetadata(m)]), - }, + }), }; let token_recipients = vec![Recipient::new( diff --git a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs index cfbfea6ace..af7608c848 100644 --- a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs +++ b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs @@ -239,7 +239,7 @@ async fn mint_compressed_tokens( metadata: CompressedMintMetadata { version: 3, mint: mint_pda.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, extensions: None, }; @@ -252,7 +252,7 @@ async fn mint_compressed_tokens( leaf_index: compressed_mint_account.leaf_index, root_index: 0, address: compressed_mint_account.address.unwrap(), - mint: expected_compressed_mint.try_into().unwrap(), + mint: Some(expected_compressed_mint.try_into().unwrap()), }, proof: None, recipients: vec![Recipient { diff --git a/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs b/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs index 1d6c92c8e8..b4ffa6cf21 100644 --- a/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs +++ b/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs @@ -126,7 +126,7 @@ async fn test_compress_full_and_close() { metadata: CompressedMintMetadata { version: 3, mint: mint_pda.into(), - spl_mint_initialized: false, + cmint_decompressed: false, }, extensions: None, }; @@ -136,7 +136,7 @@ async fn test_compress_full_and_close() { leaf_index: compressed_mint_account.leaf_index, root_index: 0, address: compressed_mint_address, - mint: expected_compressed_mint.try_into().unwrap(), + mint: Some(expected_compressed_mint.try_into().unwrap()), }; let mint_instruction = create_mint_to_compressed_instruction( From 8a4a48ddf7e4e0cd540fe0f377dc1c6458c81c39 Mon Sep 17 00:00:00 2001 From: ananas Date: Thu, 18 Dec 2025 00:55:03 +0000 Subject: [PATCH 2/2] fix: rebase issues --- .../mint_action/instruction_data.rs | 17 +++++++++-------- program-tests/utils/src/assert_ctoken_burn.rs | 1 + .../utils/src/assert_ctoken_mint_to.rs | 1 + .../actions/compress_and_close_cmint.rs | 3 ++- .../src/mint_action/actions/decompress_mint.rs | 7 +++++-- .../program/src/mint_action/mint_output.rs | 3 ++- .../program/src/shared/compressible_top_up.rs | 2 ++ 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs index 7737d168d6..6499133276 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs @@ -9,8 +9,8 @@ use super::{ use crate::{ instructions::extensions::{ExtensionInstructionData, ZExtensionInstructionData}, state::{ - AdditionalMetadata, BaseMint, CompressedMint, CompressedMintMetadata, ExtensionStruct, - TokenMetadata, + AdditionalMetadata, BaseMint, CompressedMint, CompressedMintMetadata, + CompressibleExtension, ExtensionStruct, TokenMetadata, }, AnchorDeserialize, AnchorSerialize, CTokenError, }; @@ -133,8 +133,8 @@ impl TryFrom for CompressedMintInstructionData { }, )) } - ExtensionStruct::Compressible(compression_info) => { - Ok(ExtensionInstructionData::Compressible(compression_info)) + ExtensionStruct::Compressible(compressible_ext) => { + Ok(ExtensionInstructionData::Compressible(compressible_ext.info)) } _ => { Err(CTokenError::UnsupportedExtension) @@ -193,9 +193,10 @@ impl<'a> TryFrom<&ZCompressedMintInstructionData<'a>> for CompressedMint { })) } ZExtensionInstructionData::Compressible(compression_info) => { - // Convert zero-copy CompressionInfo to owned CompressionInfo - Ok(ExtensionStruct::Compressible( - light_compressible::compression_info::CompressionInfo { + // Convert zero-copy CompressionInfo to owned CompressibleExtension + Ok(ExtensionStruct::Compressible(CompressibleExtension { + compression_only: false, + info: light_compressible::compression_info::CompressionInfo { config_account_version: compression_info .config_account_version .into(), @@ -220,7 +221,7 @@ impl<'a> TryFrom<&ZCompressedMintInstructionData<'a>> for CompressedMint { max_top_up: compression_info.rent_config.max_top_up.into(), }, }, - )) + })) } _ => Err(CTokenError::UnsupportedExtension), }) diff --git a/program-tests/utils/src/assert_ctoken_burn.rs b/program-tests/utils/src/assert_ctoken_burn.rs index 6d79273ca4..de750e9700 100644 --- a/program-tests/utils/src/assert_ctoken_burn.rs +++ b/program-tests/utils/src/assert_ctoken_burn.rs @@ -137,6 +137,7 @@ async fn calculate_expected_lamport_change( .await .unwrap(); return comp + .info .calculate_top_up_lamports( data_len as u64, current_slot, diff --git a/program-tests/utils/src/assert_ctoken_mint_to.rs b/program-tests/utils/src/assert_ctoken_mint_to.rs index 26d290fd5e..5ba9c3eb68 100644 --- a/program-tests/utils/src/assert_ctoken_mint_to.rs +++ b/program-tests/utils/src/assert_ctoken_mint_to.rs @@ -137,6 +137,7 @@ async fn calculate_expected_lamport_change( .await .unwrap(); return comp + .info .calculate_top_up_lamports( data_len as u64, current_slot, diff --git a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs index 1a5cb3d606..9d198c4d2e 100644 --- a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs @@ -84,7 +84,7 @@ pub fn process_compress_and_close_cmint_action( })?; // 5. Verify rent_sponsor matches extension - if rent_sponsor.key() != &compression_info.rent_sponsor { + if rent_sponsor.key() != &compression_info.info.rent_sponsor { msg!("Rent sponsor does not match extension"); return Err(ErrorCode::InvalidRentSponsor.into()); } @@ -100,6 +100,7 @@ pub fn process_compress_and_close_cmint_action( #[cfg(target_os = "solana")] { let is_compressible = compression_info + .info .is_compressible(cmint.data_len() as u64, current_slot, cmint.lamports()) .map_err(|_| ProgramError::InvalidAccountData)?; diff --git a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs index 237101b11c..84d87e22c5 100644 --- a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::ProgramError; use light_compressible::{compression_info::CompressionInfo, rent::RentConfig}; use light_ctoken_interface::{ instructions::mint_action::ZDecompressMintAction, - state::{CompressedMint, ExtensionStruct}, + state::{CompressedMint, CompressibleExtension, ExtensionStruct}, COMPRESSED_MINT_SEED, }; use light_program_profiler::profile; @@ -147,7 +147,10 @@ pub fn process_decompress_mint_action( }; // Add Compressible extension to compressed_mint - let extension = ExtensionStruct::Compressible(compression_info); + let extension = ExtensionStruct::Compressible(CompressibleExtension { + compression_only: false, + info: compression_info, + }); if let Some(ref mut extensions) = compressed_mint.extensions { extensions.push(extension); } else { diff --git a/programs/compressed-token/program/src/mint_action/mint_output.rs b/programs/compressed-token/program/src/mint_action/mint_output.rs index 1af806327f..ac2902104d 100644 --- a/programs/compressed-token/program/src/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/mint_action/mint_output.rs @@ -68,6 +68,7 @@ pub fn process_output_compressed_account<'a>( // Calculate top-up amount let top_up = compression_info + .info .calculate_top_up_lamports( num_bytes, current_slot, @@ -87,7 +88,7 @@ pub fn process_output_compressed_account<'a>( } // Update last_claimed_slot to current slot - compression_info.last_claimed_slot = current_slot; + compression_info.info.last_claimed_slot = current_slot; } } diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index baece4a8e8..e43021919b 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -61,6 +61,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( let rent_exemption = get_rent_exemption_lamports(cmint.data_len() as u64) .map_err(|_| CTokenError::InvalidAccountData)?; transfers[0].amount = compression_info + .info .calculate_top_up_lamports( cmint.data_len() as u64, current_slot, @@ -89,6 +90,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( .slot; } transfers[1].amount = compressible_ext + .info .calculate_top_up_lamports( ctoken.data_len() as u64, current_slot,