diff --git a/Cargo.lock b/Cargo.lock index fb42bc283c..edbd0ea7a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5072,7 +5072,7 @@ dependencies = [ [[package]] name = "pinocchio-token-interface" version = "0.0.0" -source = "git+https://github.com/Lightprotocol/token?rev=1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73#1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73" +source = "git+https://github.com/Lightprotocol/token?rev=9ea04560a039d1a44f0411b5eaa7c0b79ed575ab#9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" dependencies = [ "pinocchio", "pinocchio-pubkey", @@ -5081,7 +5081,7 @@ dependencies = [ [[package]] name = "pinocchio-token-program" version = "0.1.0" -source = "git+https://github.com/Lightprotocol/token?rev=1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73#1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73" +source = "git+https://github.com/Lightprotocol/token?rev=9ea04560a039d1a44f0411b5eaa7c0b79ed575ab#9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" dependencies = [ "pinocchio", "pinocchio-log", diff --git a/Cargo.toml b/Cargo.toml index 26d1df64dc..daef1112c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,7 +232,7 @@ groth16-solana = { version = "0.2.0" } bytemuck = { version = "1.19.0" } arrayvec = "0.7" tinyvec = "1.10.0" -pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73" } +pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" } # Math and crypto num-bigint = "0.4.6" tabled = "0.20" diff --git a/js/compressed-token/src/v3/instructions/create-mint.ts b/js/compressed-token/src/v3/instructions/create-mint.ts index cd15abb504..e8d502f1aa 100644 --- a/js/compressed-token/src/v3/instructions/create-mint.ts +++ b/js/compressed-token/src/v3/instructions/create-mint.ts @@ -126,9 +126,6 @@ export function encodeCreateMintInstructionData( leafIndex: 0, proveByIndex: false, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -144,6 +141,7 @@ export function encodeCreateMintInstructionData( version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: splMintPda, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintAuthority, freezeAuthority: params.freezeAuthority, 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 61bae4e274..427cec5af5 100644 --- a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts @@ -53,9 +53,6 @@ function encodeCompressedMintToInstructionData( leafIndex: params.leafIndex, proveByIndex: true, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [ @@ -78,6 +75,7 @@ function encodeCompressedMintToInstructionData( version: params.mintData.version, cmintDecompressed: params.mintData.cmintDecompressed, mint: params.mintData.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintData.mintAuthority, freezeAuthority: params.mintData.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/mint-to.ts b/js/compressed-token/src/v3/instructions/mint-to.ts index 56b5aef32d..f72c4a399e 100644 --- a/js/compressed-token/src/v3/instructions/mint-to.ts +++ b/js/compressed-token/src/v3/instructions/mint-to.ts @@ -51,9 +51,6 @@ function encodeMintToCTokenInstructionData( leafIndex: params.leafIndex, proveByIndex: true, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [ @@ -73,6 +70,7 @@ function encodeMintToCTokenInstructionData( version: params.mintData.version, cmintDecompressed: params.mintData.cmintDecompressed, mint: params.mintData.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintData.mintAuthority, freezeAuthority: params.mintData.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/update-metadata.ts b/js/compressed-token/src/v3/instructions/update-metadata.ts index 5ceee40814..2673aa22af 100644 --- a/js/compressed-token/src/v3/instructions/update-metadata.ts +++ b/js/compressed-token/src/v3/instructions/update-metadata.ts @@ -100,9 +100,6 @@ function encodeUpdateMetadataInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proof === null, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [convertActionToBorsh(params.action)], @@ -115,6 +112,7 @@ function encodeUpdateMetadataInstructionData( version: mintInterface.mintContext!.version, cmintDecompressed: mintInterface.mintContext!.cmintDecompressed, mint: mintInterface.mintContext!.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: mintInterface.mint.mintAuthority, freezeAuthority: mintInterface.mint.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts index 7e09096911..be962d804c 100644 --- a/js/compressed-token/src/v3/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -73,9 +73,6 @@ function encodeUpdateMintInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proveByIndex, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [action], @@ -89,6 +86,7 @@ function encodeUpdateMintInstructionData( cmintDecompressed: params.mintInterface.mintContext!.cmintDecompressed, mint: params.mintInterface.mintContext!.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintInterface.mint.mintAuthority, freezeAuthority: params.mintInterface.mint.freezeAuthority, 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 6e5ac89456..8ad1ec9bc2 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -37,8 +37,6 @@ export const UpdateAuthorityLayout = struct([ option(publicKey(), 'newAuthority'), ]); -export const CreateSplMintActionLayout = struct([u8('mintBump')]); - export const MintToCTokenActionLayout = struct([ u8('accountIndex'), u64('amount'), @@ -74,7 +72,6 @@ export const ActionLayout = rustEnum([ MintToCompressedActionLayout.replicate('mintToCompressed'), UpdateAuthorityLayout.replicate('updateMintAuthority'), UpdateAuthorityLayout.replicate('updateFreezeAuthority'), - CreateSplMintActionLayout.replicate('createSplMint'), MintToCTokenActionLayout.replicate('mintToCToken'), UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), @@ -145,6 +142,7 @@ export const CompressedMintMetadataLayout = struct([ u8('version'), bool('cmintDecompressed'), publicKey('mint'), + array(u8(), 32, 'compressedAddress'), ]); export const CompressedMintInstructionDataLayout = struct([ @@ -160,9 +158,6 @@ export const MintActionCompressedInstructionDataLayout = struct([ u32('leafIndex'), bool('proveByIndex'), u16('rootIndex'), - array(u8(), 32, 'compressedAddress'), - u8('tokenPoolBump'), - u8('tokenPoolIndex'), u16('maxTopUp'), option(CreateMintLayout, 'createMint'), vec(ActionLayout, 'actions'), @@ -176,7 +171,6 @@ const ActionLayoutV1 = rustEnum([ MintToCompressedActionLayout.replicate('mintToCompressed'), UpdateAuthorityLayout.replicate('updateMintAuthority'), UpdateAuthorityLayout.replicate('updateFreezeAuthority'), - CreateSplMintActionLayout.replicate('createSplMint'), MintToCTokenActionLayout.replicate('mintToCToken'), UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), @@ -233,10 +227,6 @@ export interface UpdateAuthority { newAuthority: PublicKey | null; } -export interface CreateSplMintAction { - mintBump: number; -} - export interface MintToCTokenAction { accountIndex: number; amount: bigint; @@ -274,7 +264,6 @@ export type Action = | { mintToCompressed: MintToCompressedAction } | { updateMintAuthority: UpdateAuthority } | { updateFreezeAuthority: UpdateAuthority } - | { createSplMint: CreateSplMintAction } | { mintToCToken: MintToCTokenAction } | { updateMetadataField: UpdateMetadataFieldAction } | { updateMetadataAuthority: UpdateMetadataAuthorityAction } @@ -320,6 +309,7 @@ export interface CompressedMintMetadata { version: number; cmintDecompressed: boolean; mint: PublicKey; + compressedAddress: number[]; } export interface CompressedMintInstructionData { @@ -335,9 +325,6 @@ export interface MintActionCompressedInstructionData { leafIndex: number; proveByIndex: boolean; rootIndex: number; - compressedAddress: number[]; - tokenPoolBump: number; - tokenPoolIndex: number; maxTopUp: number; createMint: CreateMint | null; actions: Action[]; diff --git a/js/compressed-token/src/v3/layout/layout-mint.ts b/js/compressed-token/src/v3/layout/layout-mint.ts index 485169b65f..2fbd990592 100644 --- a/js/compressed-token/src/v3/layout/layout-mint.ts +++ b/js/compressed-token/src/v3/layout/layout-mint.ts @@ -157,12 +157,16 @@ export interface CompressionInfo { rentSponsor: PublicKey; /** Last slot rent was claimed */ lastClaimedSlot: bigint; + /** Rent exemption lamports paid at account creation */ + rentExemptionPaid: number; + /** Reserved for future use */ + reserved: number; /** Rent configuration */ rentConfig: RentConfig; } /** Byte length of CompressionInfo */ -export const COMPRESSION_INFO_SIZE = 88; // 2 + 1 + 1 + 4 + 32 + 32 + 8 + 8 +export const COMPRESSION_INFO_SIZE = 96; // 2 + 1 + 1 + 4 + 32 + 32 + 8 + 4 + 4 + 8 /** * Calculate the byte length of a TokenMetadata extension from buffer. @@ -256,6 +260,12 @@ function deserializeCompressionInfo( const lastClaimedSlot = buffer.readBigUInt64LE(offset); offset += 8; + // Read rent_exemption_paid (u32) and _reserved (u32) + const rentExemptionPaid = buffer.readUInt32LE(offset); + offset += 4; + const reserved = buffer.readUInt32LE(offset); + offset += 4; + // Read RentConfig (8 bytes) const baseRent = buffer.readUInt16LE(offset); offset += 2; @@ -284,6 +294,8 @@ function deserializeCompressionInfo( compressionAuthority, rentSponsor, lastClaimedSlot, + rentExemptionPaid, + reserved, rentConfig, }; @@ -416,6 +428,12 @@ function serializeCompressionInfo(compression: CompressionInfo): Buffer { buffer.writeBigUInt64LE(compression.lastClaimedSlot, offset); offset += 8; + // Write rent_exemption_paid (u32) and _reserved (u32) + buffer.writeUInt32LE(compression.rentExemptionPaid, offset); + offset += 4; + buffer.writeUInt32LE(compression.reserved, offset); + offset += 4; + // Write RentConfig (8 bytes) buffer.writeUInt16LE(compression.rentConfig.baseRent, offset); offset += 2; diff --git a/js/compressed-token/src/v3/layout/layout-transfer2.ts b/js/compressed-token/src/v3/layout/layout-transfer2.ts index ad260a64aa..293aee4007 100644 --- a/js/compressed-token/src/v3/layout/layout-transfer2.ts +++ b/js/compressed-token/src/v3/layout/layout-transfer2.ts @@ -190,6 +190,8 @@ const CompressionInfoLayout = struct([ array(u8(), 32, 'compressionAuthority'), array(u8(), 32, 'rentSponsor'), u64('lastClaimedSlot'), + u32('rentExemptionPaid'), + u32('reserved'), RentConfigLayout.replicate('rentConfig'), ]); @@ -255,6 +257,8 @@ function serializeExtensionInstructionData( ), rentSponsor: Array.from(ext.data.rentSponsor.toBytes()), lastClaimedSlot: bn(ext.data.lastClaimedSlot.toString()), + rentExemptionPaid: ext.data.rentExemptionPaid, + reserved: ext.data.reserved, rentConfig: ext.data.rentConfig, }; offset += CompressionInfoLayout.encode(data, buffer, offset); diff --git a/js/compressed-token/tests/e2e/unwrap.test.ts b/js/compressed-token/tests/e2e/unwrap.test.ts index 7819948e5f..97ad4d811d 100644 --- a/js/compressed-token/tests/e2e/unwrap.test.ts +++ b/js/compressed-token/tests/e2e/unwrap.test.ts @@ -94,6 +94,7 @@ describe('createUnwrapInstruction', () => { mint, BigInt(1000), tokenPoolInfo!, + TEST_TOKEN_DECIMALS, ); expect(ix).toBeDefined(); @@ -125,6 +126,7 @@ describe('createUnwrapInstruction', () => { mint, BigInt(500), tokenPoolInfo!, + TEST_TOKEN_DECIMALS, feePayer.publicKey, ); 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 ff323f41da..23b444e074 100644 --- a/js/compressed-token/tests/unit/layout-mint-action.test.ts +++ b/js/compressed-token/tests/unit/layout-mint-action.test.ts @@ -17,9 +17,6 @@ describe('layout-mint-action', () => { leafIndex: 100, proveByIndex: true, rootIndex: 5, - compressedAddress: Array(32).fill(1), - tokenPoolBump: 255, - tokenPoolIndex: 0, maxTopUp: 1000, createMint: null, actions: [], @@ -32,6 +29,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(1), }, mintAuthority: mint, freezeAuthority: null, @@ -49,7 +47,6 @@ describe('layout-mint-action', () => { expect(decoded.leafIndex).toBe(100); expect(decoded.proveByIndex).toBe(true); expect(decoded.rootIndex).toBe(5); - expect(decoded.tokenPoolBump).toBe(255); expect(decoded.maxTopUp).toBe(1000); expect(decoded.actions.length).toBe(0); expect(decoded.mint.decimals).toBe(9); @@ -74,9 +71,6 @@ describe('layout-mint-action', () => { leafIndex: 50, proveByIndex: false, rootIndex: 10, - compressedAddress: Array(32).fill(2), - tokenPoolBump: 254, - tokenPoolIndex: 1, maxTopUp: 500, createMint: null, actions: [mintToCompressedAction], @@ -89,6 +83,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: false, mint, + compressedAddress: Array(32).fill(2), }, mintAuthority: mint, freezeAuthority: null, @@ -126,9 +121,6 @@ describe('layout-mint-action', () => { leafIndex: 0, proveByIndex: true, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 253, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [mintToCTokenAction], @@ -141,6 +133,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(0), }, mintAuthority: mint, freezeAuthority: null, @@ -174,9 +167,6 @@ describe('layout-mint-action', () => { leafIndex: 10, proveByIndex: true, rootIndex: 2, - compressedAddress: Array(32).fill(5), - tokenPoolBump: 250, - tokenPoolIndex: 0, maxTopUp: 100, createMint: null, actions: [updateAction], @@ -189,6 +179,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(5), }, mintAuthority: mint, freezeAuthority: null, @@ -203,62 +194,6 @@ describe('layout-mint-action', () => { expect('updateMintAuthority' in decoded.actions[0]).toBe(true); }); - it('should encode and decode with createSplMint action', () => { - const mint = Keypair.generate().publicKey; - - const createSplMintAction: Action = { - createSplMint: { - mintBump: 254, - }, - }; - - const data: MintActionCompressedInstructionData = { - leafIndex: 0, - proveByIndex: false, - rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 255, - tokenPoolIndex: 0, - maxTopUp: 0, - createMint: { - readOnlyAddressTrees: [1, 2, 3, 4], - readOnlyAddressTreeRootIndices: [10, 20, 30, 40], - }, - actions: [createSplMintAction], - proof: { - a: Array(32).fill(1), - b: Array(64).fill(2), - c: Array(32).fill(3), - }, - cpiContext: null, - mint: { - supply: 0n, - decimals: 9, - metadata: { - version: 1, - cmintDecompressed: false, - mint, - }, - mintAuthority: mint, - freezeAuthority: null, - extensions: null, - }, - }; - - const encoded = encodeMintActionInstructionData(data); - const decoded = decodeMintActionInstructionData(encoded); - - expect(decoded.actions.length).toBe(1); - expect('createSplMint' in decoded.actions[0]).toBe(true); - - const action = decoded.actions[0] as { - createSplMint: { mintBump: number }; - }; - expect(action.createSplMint.mintBump).toBe(254); - expect(decoded.createMint).not.toBe(null); - expect(decoded.proof).not.toBe(null); - }); - it('should encode and decode with multiple actions', () => { const mint = Keypair.generate().publicKey; const recipient = Keypair.generate().publicKey; @@ -281,9 +216,6 @@ describe('layout-mint-action', () => { leafIndex: 5, proveByIndex: true, rootIndex: 1, - compressedAddress: Array(32).fill(7), - tokenPoolBump: 200, - tokenPoolIndex: 2, maxTopUp: 50, createMint: null, actions, @@ -296,6 +228,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(7), }, mintAuthority: mint, freezeAuthority: null, @@ -317,9 +250,6 @@ describe('layout-mint-action', () => { leafIndex: 0, proveByIndex: true, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 255, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [], @@ -332,6 +262,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(0), }, mintAuthority: mint, freezeAuthority: null, @@ -353,9 +284,6 @@ describe('layout-mint-action', () => { leafIndex: 0, proveByIndex: true, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 255, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [], @@ -378,6 +306,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(0), }, mintAuthority: mint, freezeAuthority: null, diff --git a/js/compressed-token/tests/unit/mint-action-layout.test.ts b/js/compressed-token/tests/unit/mint-action-layout.test.ts index 564012e314..2e124393eb 100644 --- a/js/compressed-token/tests/unit/mint-action-layout.test.ts +++ b/js/compressed-token/tests/unit/mint-action-layout.test.ts @@ -27,9 +27,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 42, - compressedAddress: Array.from(new Uint8Array(32).fill(1)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -49,6 +46,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from( + new Uint8Array(32).fill(1), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -65,11 +65,6 @@ describe('MintActionCompressedInstructionData Layout', () => { expect(decoded.leafIndex).toBe(instructionData.leafIndex); expect(decoded.proveByIndex).toBe(instructionData.proveByIndex); expect(decoded.rootIndex).toBe(instructionData.rootIndex); - expect(decoded.compressedAddress).toEqual( - instructionData.compressedAddress, - ); - expect(decoded.tokenPoolBump).toBe(instructionData.tokenPoolBump); - expect(decoded.tokenPoolIndex).toBe(instructionData.tokenPoolIndex); expect(decoded.maxTopUp).toBe(instructionData.maxTopUp); expect(decoded.createMint).toEqual(instructionData.createMint); expect(decoded.actions).toEqual([]); @@ -77,6 +72,9 @@ describe('MintActionCompressedInstructionData Layout', () => { expect(decoded.cpiContext).toBeNull(); expect(decoded.mint).toBeDefined(); expect(decoded.mint!.decimals).toBe(9); + expect(decoded.mint!.metadata.compressedAddress).toEqual( + instructionData.mint!.metadata.compressedAddress, + ); }); it('should encode createMint without proof (null proof)', () => { @@ -87,9 +85,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 0, - compressedAddress: Array.from(new Uint8Array(32).fill(0)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -105,6 +100,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from( + new Uint8Array(32).fill(0), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -128,9 +126,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 100, - compressedAddress: Array.from(new Uint8Array(32).fill(5)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -150,6 +145,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from( + new Uint8Array(32).fill(5), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: freezeAuthority.publicKey, @@ -174,9 +172,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 50, - compressedAddress: Array.from(new Uint8Array(32).fill(6)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -196,6 +191,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from( + new Uint8Array(32).fill(6), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -236,9 +234,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -254,6 +249,7 @@ describe('MintActionCompressedInstructionData Layout', () => { version: 0, cmintDecompressed: false, mint: mintSigner, + compressedAddress: Array(32).fill(0), }, mintAuthority: mintAuthority, freezeAuthority: null, @@ -286,26 +282,17 @@ describe('MintActionCompressedInstructionData Layout', () => { // Next 2 bytes should be rootIndex (0 as u16 little-endian) expect(encoded1.slice(6, 8)).toEqual(Buffer.from([0, 0])); - // Next 32 bytes should be compressedAddress (all zeros) - expect(encoded1.slice(8, 40)).toEqual(Buffer.alloc(32, 0)); - - // tokenPoolBump at byte 40 - expect(encoded1[40]).toBe(0); - - // tokenPoolIndex at byte 41 - expect(encoded1[41]).toBe(0); - - // maxTopUp at bytes 42-43 (u16 little-endian) - expect(encoded1.slice(42, 44)).toEqual(Buffer.from([0, 0])); + // maxTopUp at bytes 8-9 (u16 little-endian) + expect(encoded1.slice(8, 10)).toEqual(Buffer.from([0, 0])); - // createMint Option: byte 44 should be 1 (Some) - expect(encoded1[44]).toBe(1); + // createMint Option: byte 10 should be 1 (Some) + expect(encoded1[10]).toBe(1); - // createMint.readOnlyAddressTrees: bytes 45-48 - expect(encoded1.slice(45, 49)).toEqual(Buffer.from([0, 0, 0, 0])); + // createMint.readOnlyAddressTrees: bytes 11-14 + expect(encoded1.slice(11, 15)).toEqual(Buffer.from([0, 0, 0, 0])); - // createMint.readOnlyAddressTreeRootIndices: bytes 49-56 (4 x u16) - expect(encoded1.slice(49, 57)).toEqual( + // createMint.readOnlyAddressTreeRootIndices: bytes 15-22 (4 x u16) + expect(encoded1.slice(15, 23)).toEqual( Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), ); }); diff --git a/js/stateless.js/src/devnet-compat.ts b/js/stateless.js/src/devnet-compat.ts index 18c8a73c99..c00695c338 100644 --- a/js/stateless.js/src/devnet-compat.ts +++ b/js/stateless.js/src/devnet-compat.ts @@ -21,4 +21,3 @@ export function setDevnetCompat(enabled: boolean): void { export function isDevnetCompat(): boolean { return _useDevnetFormat; } - diff --git a/program-libs/compressible/docs/SOLANA_RENT.md b/program-libs/compressible/docs/SOLANA_RENT.md index 12f494d171..4d2cdcb7ad 100644 --- a/program-libs/compressible/docs/SOLANA_RENT.md +++ b/program-libs/compressible/docs/SOLANA_RENT.md @@ -42,7 +42,7 @@ Light Protocol's rent system is designed for **compressible token accounts** wit | **Exemption** | Permanent with sufficient balance | Temporary, epoch-by-epoch | | **Collection** | Automatic by runtime | Manual via Claim instruction | | **Distribution** | 50% burned, 50% to validators | 100% to rent recipient (protocol) | -| **Rent-Specific Data** | None (uses account balance) | 88 bytes (CompressionInfo) | +| **Rent-Specific Data** | None (uses account balance) | 96 bytes (CompressionInfo) | | **Compression** | N/A | Incentivized with 11,000 lamport bonus | ### Rent Calculation Comparison diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index 4bb20e196b..9d4c4b57dc 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -8,10 +8,7 @@ use zerocopy::U64; use crate::{ config::CompressibleConfig, error::CompressibleError, - rent::{ - get_last_funded_epoch, get_rent_exemption_lamports, AccountRentState, RentConfig, - SLOTS_PER_EPOCH, - }, + rent::{get_last_funded_epoch, AccountRentState, RentConfig, SLOTS_PER_EPOCH}, AnchorDeserialize, AnchorSerialize, }; @@ -49,6 +46,12 @@ pub struct CompressionInfo { pub rent_sponsor: [u8; 32], /// Last slot rent was claimed from this account. pub last_claimed_slot: u64, + /// Rent exemption lamports paid at account creation. + /// Used instead of querying the Rent sysvar to ensure rent sponsor + /// gets back exactly what they paid regardless of future rent changes. + pub rent_exemption_paid: u32, + /// Reserved for future use. + pub _reserved: u32, /// Rent function parameters, /// used to calculate whether the account is compressible. pub rent_config: RentConfig, @@ -70,7 +73,8 @@ macro_rules! impl_is_compressible { current_slot: u64, current_lamports: u64, ) -> Result, CompressibleError> { - let rent_exemption_lamports = get_rent_exemption_lamports(bytes)?; + let rent_exemption_paid: u32 = self.rent_exemption_paid.into(); + let rent_exemption_lamports: u64 = rent_exemption_paid as u64; Ok(crate::rent::AccountRentState { num_bytes: bytes, current_slot, @@ -95,9 +99,10 @@ macro_rules! impl_is_compressible { num_bytes: u64, current_slot: u64, current_lamports: u64, - rent_exemption_lamports: u64, ) -> Result { let lamports_per_write: u32 = self.lamports_per_write.into(); + let rent_exemption_paid: u32 = self.rent_exemption_paid.into(); + let rent_exemption_lamports: u64 = rent_exemption_paid as u64; // Calculate rent status using AccountRentState let state = crate::rent::AccountRentState { @@ -174,8 +179,9 @@ impl ZCompressionInfoMut<'_> { num_bytes: u64, current_slot: u64, current_lamports: u64, - rent_exemption_lamports: u64, ) -> Result, CompressibleError> { + let rent_exemption_paid: u32 = self.rent_exemption_paid.into(); + let rent_exemption_lamports: u64 = rent_exemption_paid as u64; let state = AccountRentState { num_bytes, current_slot, @@ -234,14 +240,7 @@ impl ZCompressionInfoMut<'_> { return Err(CompressibleError::InvalidVersion); } - let rent_exemption_lamports = get_rent_exemption_lamports(bytes)?; - - let claim_result = self.claim( - bytes, - current_slot, - current_lamports, - rent_exemption_lamports, - )?; + let claim_result = self.claim(bytes, current_slot, current_lamports)?; // Update RentConfig after claim calculation (even if claim_result is None) self.rent_config.set(&config_account.rent_config); diff --git a/program-libs/compressible/src/rent/config.rs b/program-libs/compressible/src/rent/config.rs index 36e720a65d..1b0a5ca35a 100644 --- a/program-libs/compressible/src/rent/config.rs +++ b/program-libs/compressible/src/rent/config.rs @@ -6,10 +6,10 @@ use crate::{AnchorDeserialize, AnchorSerialize}; pub const COMPRESSION_COST: u16 = 10_000; pub const COMPRESSION_INCENTIVE: u16 = 1000; -pub const BASE_RENT: u16 = 128; -pub const RENT_PER_BYTE: u8 = 1; -// Epoch duration: 1.5 hours, 90 minutes * 60 seconds / 0.4 seconds per slot = 13,500 slots per epoch -pub const SLOTS_PER_EPOCH: u64 = 13500; +pub const BASE_RENT: u16 = 128; // TODO: multiply by 10 +pub const RENT_PER_BYTE: u8 = 1; // TODO: multiply by 10 + // Epoch duration: 1.5 hours, 90 minutes * 60 seconds / 0.4 seconds per slot = 13,500 slots per epoch +pub const SLOTS_PER_EPOCH: u64 = 13500; // TODO: multiply by 10 /// Trait for accessing rent configuration parameters. /// diff --git a/program-libs/compressible/tests/compression_info.rs b/program-libs/compressible/tests/compression_info.rs index 2ccf10619e..f729b099d8 100644 --- a/program-libs/compressible/tests/compression_info.rs +++ b/program-libs/compressible/tests/compression_info.rs @@ -21,6 +21,7 @@ pub fn get_rent_exemption_lamports(_num_bytes: u64) -> u64 { #[test] fn test_claim_method() { // Test the claim method updates state correctly + let rent_exemption = get_rent_exemption_lamports(TEST_BYTES) as u32; let extension_data = CompressionInfo { account_version: 3, config_account_version: 1, @@ -29,6 +30,8 @@ fn test_claim_method() { last_claimed_slot: 0, lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption, + _reserved: 0, rent_config: test_rent_config(), }; @@ -46,12 +49,7 @@ fn test_claim_method() { get_rent_exemption_lamports(TEST_BYTES) ); let claimed = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed.unwrap(), @@ -75,7 +73,6 @@ fn test_claim_method() { TEST_BYTES, current_slot, current_lamports - claimed.unwrap_or(0), - get_rent_exemption_lamports(TEST_BYTES), ) .unwrap(); assert_eq!(claimed_again, None, "Should not claim again in same epoch"); @@ -84,12 +81,7 @@ fn test_claim_method() { let current_slot = SLOTS_PER_EPOCH * 3 + 100; let current_lamports = current_lamports - claimed.unwrap_or(0) + RENT_PER_EPOCH - 1; let claimed_again_in_third_epoch = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed_again_in_third_epoch, None, @@ -101,12 +93,7 @@ fn test_claim_method() { let current_slot = SLOTS_PER_EPOCH * 3 + 100; let current_lamports = current_lamports - claimed.unwrap_or(0) + RENT_PER_EPOCH; let claimed_again_in_third_epoch = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed_again_in_third_epoch, @@ -119,12 +106,7 @@ fn test_claim_method() { let current_slot = SLOTS_PER_EPOCH * 4 + 100; let current_lamports = current_lamports - claimed.unwrap_or(0) + 10 * RENT_PER_EPOCH; let claimed_again_in_third_epoch = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed_again_in_third_epoch, @@ -149,6 +131,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: 0, // Created in epoch 0 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -174,6 +158,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH - 1, // Created in epoch 0 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -199,6 +185,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH, // Created in epoch 1 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -221,6 +209,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH * 2, // Created in epoch 2 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -243,6 +233,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: 0, lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -263,6 +255,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH * 5, // Created in epoch 5 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -287,6 +281,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: 0, lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -312,6 +308,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH * 3, // Epoch 3 lamports_per_write: 100, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; diff --git a/program-libs/compressible/tests/consistency.rs b/program-libs/compressible/tests/consistency.rs index a31d3babba..58dd9932dc 100644 --- a/program-libs/compressible/tests/consistency.rs +++ b/program-libs/compressible/tests/consistency.rs @@ -54,12 +54,7 @@ impl RentState { rent_exemption_lamports, ); let top_up = compression_info - .calculate_top_up_lamports( - state.num_bytes, - state.current_slot, - state.current_lamports, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(state.num_bytes, state.current_slot, state.current_lamports) .unwrap(); Self { @@ -182,6 +177,8 @@ fn test_consistency_16_epochs_progression() { last_claimed_slot, lamports_per_write, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption_lamports as u32, + _reserved: 0, rent_config: test_rent_config(), }; diff --git a/program-libs/compressible/tests/top_up.rs b/program-libs/compressible/tests/top_up.rs index 1d32105652..245565f0bf 100644 --- a/program-libs/compressible/tests/top_up.rs +++ b/program-libs/compressible/tests/top_up.rs @@ -257,6 +257,8 @@ fn test_calculate_top_up_lamports() { last_claimed_slot: test_case.last_claimed_slot, lamports_per_write: test_case.lamports_per_write, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption_lamports as u32, + _reserved: 0, rent_config: test_rent_config(), }; @@ -265,7 +267,6 @@ fn test_calculate_top_up_lamports() { TEST_BYTES, test_case.current_slot, test_case.current_lamports, - rent_exemption_lamports, ) .unwrap(); @@ -297,6 +298,8 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { last_claimed_slot: start_slot, lamports_per_write, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption_lamports as u32, + _reserved: 0, rent_config: test_rent_config(), // max_funded_epochs = 2 }; @@ -305,12 +308,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { let current_slot = start_slot + (SLOTS_PER_EPOCH * epoch_offset); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - current_slot, - initial_lamports, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, current_slot, initial_lamports) .unwrap(); assert_eq!( @@ -325,12 +323,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { // Epoch 14 - should require top-up (only 1 epoch funded ahead < max_funded_epochs=2) let epoch_14_slot = start_slot + (SLOTS_PER_EPOCH * 14); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - epoch_14_slot, - initial_lamports, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, epoch_14_slot, initial_lamports) .unwrap(); assert_eq!( @@ -346,12 +339,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { let current_slot = start_slot + (SLOTS_PER_EPOCH * epoch_offset); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - current_slot, - lamports_after_write, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, current_slot, lamports_after_write) .unwrap(); assert_eq!( @@ -364,12 +352,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { // Epoch 16 - should require top-up again let epoch_16_slot = start_slot + (SLOTS_PER_EPOCH * 16); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - epoch_16_slot, - lamports_after_write, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, epoch_16_slot, lamports_after_write) .unwrap(); assert_eq!( diff --git a/program-libs/ctoken-interface/src/constants.rs b/program-libs/ctoken-interface/src/constants.rs index 4ecbae37a8..30f10eeeea 100644 --- a/program-libs/ctoken-interface/src/constants.rs +++ b/program-libs/ctoken-interface/src/constants.rs @@ -7,7 +7,7 @@ pub const CTOKEN_PROGRAM_ID: [u8; 32] = /// Account size constants /// Size of a CToken account with embedded compression info (no extensions). /// CTokenZeroCopy includes: SPL token layout (165) + account_type (1) + decimal_option_prefix (1) -/// + decimals (1) + compression_only (1) + CompressionInfo (88) + has_extensions (1) +/// + decimals (1) + compression_only (1) + CompressionInfo (96) + has_extensions (1) pub use crate::state::BASE_TOKEN_ACCOUNT_SIZE; /// Extension metadata overhead: Vec length (4) - added when any extensions are present diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index e2a19d683e..4d5983b55d 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -188,6 +188,24 @@ pub enum CTokenError { "Decompress has withheld_transfer_fee but destination lacks TransferFeeAccount extension" )] DecompressWithheldFeeWithoutExtension, + + #[error("Missing required payer account")] + MissingPayer, + + #[error("Failed to borrow account data")] + BorrowFailed, + + #[error("CToken account has invalid owner")] + InvalidCTokenOwner, + + #[error("Decompress amount mismatch between compression instruction and input token data")] + DecompressAmountMismatch, + + #[error("Compression index exceeds maximum allowed value")] + CompressionIndexOutOfBounds, + + #[error("ATA derivation failed or mismatched for is_ata compressed token")] + InvalidAtaDerivation, } impl From for u32 { @@ -253,6 +271,12 @@ impl From for u32 { CTokenError::MintMismatch => 18058, CTokenError::DecompressDelegatedAmountWithoutDelegate => 18059, CTokenError::DecompressWithheldFeeWithoutExtension => 18060, + CTokenError::MissingPayer => 18061, + CTokenError::BorrowFailed => 18062, + CTokenError::InvalidCTokenOwner => 18063, + CTokenError::DecompressAmountMismatch => 18064, + CTokenError::CompressionIndexOutOfBounds => 18065, + CTokenError::InvalidAtaDerivation => 18066, 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/compressible.rs b/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs index 1a6c92c67f..496006eb5a 100644 --- a/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs +++ b/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs @@ -18,8 +18,9 @@ pub struct CompressibleExtensionInstructionData { /// Rent payment in epochs. /// Paid once at initialization. pub rent_payment: u8, - /// Placeholder for future use. If true, the compressed token account cannot be transferred, - /// only decompressed. Currently unused - always set to 0. + /// If non-zero, the compressed token account cannot be transferred, only decompressed. + /// Required for mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook). + /// Must be set for compressible ATAs. pub compression_only: u8, pub write_top_up: u32, pub compress_to_account_pubkey: Option, diff --git a/program-libs/ctoken-interface/src/instructions/extensions/mod.rs b/program-libs/ctoken-interface/src/instructions/extensions/mod.rs index dc4a5b8936..69e77eae1f 100644 --- a/program-libs/ctoken-interface/src/instructions/extensions/mod.rs +++ b/program-libs/ctoken-interface/src/instructions/extensions/mod.rs @@ -1,7 +1,9 @@ pub mod compressed_only; pub mod compressible; pub mod token_metadata; -pub use compressed_only::CompressedOnlyExtensionInstructionData; +pub use compressed_only::{ + CompressedOnlyExtensionInstructionData, ZCompressedOnlyExtensionInstructionData, +}; pub use compressible::{CompressToPubkey, CompressibleExtensionInstructionData}; use light_compressible::compression_info::CompressionInfo; use light_zero_copy::ZeroCopy; @@ -51,3 +53,14 @@ pub enum ExtensionInstructionData { /// Position 32 matches ExtensionStruct::Compressible Compressible(CompressionInfo), } + +/// Find the CompressedOnly extension from a TLV slice. +#[inline(always)] +pub fn find_compressed_only<'a>( + tlv: &'a [ZExtensionInstructionData<'a>], +) -> Option<&'a ZCompressedOnlyExtensionInstructionData<'a>> { + tlv.iter().find_map(|ext| match ext { + ZExtensionInstructionData::CompressedOnly(data) => Some(data), + _ => None, + }) +} 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 31c9202468..8ca9fb9ff7 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs @@ -22,6 +22,7 @@ impl InstructionDiscriminator for MintActionCompressedInstructionData { impl LightInstructionData for MintActionCompressedInstructionData {} impl MintActionCompressedInstructionData { + /// Create instruction data from CompressedMintWithContext (for existing mints) pub fn new( mint_with_context: CompressedMintWithContext, proof: Option, @@ -30,9 +31,6 @@ impl MintActionCompressedInstructionData { leaf_index: mint_with_context.leaf_index, prove_by_index: mint_with_context.prove_by_index, root_index: mint_with_context.root_index, - compressed_address: mint_with_context.address, - token_pool_bump: 0, - token_pool_index: 0, max_top_up: 0, // No limit by default create_mint: None, actions: Vec::new(), @@ -42,19 +40,16 @@ impl MintActionCompressedInstructionData { } } + /// Create instruction data for new mint creation pub fn new_mint( - compressed_address: [u8; 32], - root_index: u16, + address_merkle_tree_root_index: u16, proof: CompressedProof, mint: CompressedMintInstructionData, ) -> Self { Self { - leaf_index: 0, - prove_by_index: false, - root_index, - compressed_address, - token_pool_bump: 0, - token_pool_index: 0, + leaf_index: 0, // New mint has no existing leaf + prove_by_index: false, // Using address proof, not validity proof + root_index: address_merkle_tree_root_index, max_top_up: 0, // No limit by default create_mint: Some(CreateMint::default()), actions: Vec::new(), @@ -64,19 +59,16 @@ impl MintActionCompressedInstructionData { } } + /// Create instruction data for new mint creation via CPI context write pub fn new_mint_write_to_cpi_context( - compressed_address: [u8; 32], - root_index: u16, + address_merkle_tree_root_index: u16, mint: CompressedMintInstructionData, cpi_context: CpiContext, ) -> Self { Self { - leaf_index: 0, - prove_by_index: false, - root_index, - compressed_address, - token_pool_bump: 0, - token_pool_index: 0, + leaf_index: 0, // New mint has no existing leaf + prove_by_index: false, // Using address proof, not validity proof + root_index: address_merkle_tree_root_index, max_top_up: 0, // No limit by default create_mint: Some(CreateMint::default()), actions: Vec::new(), diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs b/program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs deleted file mode 100644 index 8b141eaad6..0000000000 --- a/program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs +++ /dev/null @@ -1,9 +0,0 @@ -use light_zero_copy::ZeroCopy; - -use crate::{AnchorDeserialize, AnchorSerialize}; - -#[repr(C)] -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, ZeroCopy)] -pub struct CreateSplMintAction { - pub mint_bump: 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 index 3a64546250..6940ff4ed8 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs @@ -12,9 +12,7 @@ use crate::{AnchorDeserialize, AnchorSerialize}; 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. + /// Rent payment in epochs (prepaid). pub rent_payment: u8, /// Lamports allocated for future write operations (top-up per write). /// Must not exceed config.rent_config.max_top_up. 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 9f4cab62f6..79bc78ff9a 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 @@ -3,8 +3,8 @@ use light_compressible::compression_info::CompressionInfo; use light_zero_copy::ZeroCopy; use super::{ - CompressAndCloseCMintAction, CpiContext, CreateSplMintAction, DecompressMintAction, - MintToCTokenAction, MintToCompressedAction, RemoveMetadataKeyAction, UpdateAuthority, + CompressAndCloseCMintAction, CpiContext, DecompressMintAction, MintToCTokenAction, + MintToCompressedAction, RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, }; use crate::{ @@ -25,11 +25,6 @@ pub enum Action { UpdateMintAuthority(UpdateAuthority), /// Update freeze authority of a compressed mint account. UpdateFreezeAuthority(UpdateAuthority), - /// Create an spl mint for a cmint. - /// - existing supply is minted to a token pool account. - /// - mint and freeze authority are a ctoken pda. - /// - is an spl-token-2022 mint account. - CreateSplMint(CreateSplMintAction), /// Mint ctokens from a cmint to a ctoken solana account /// (tokens are not compressed but not spl tokens). MintToCToken(MintToCTokenAction), @@ -55,15 +50,6 @@ pub struct MintActionCompressedInstructionData { /// If mint already exists, root index of validity proof /// If proof by index not used. pub root_index: u16, - /// Address of the compressed account the mint is stored in. - /// Derived from the associated spl mint pubkey. - pub compressed_address: [u8; 32], - /// Used to check token pool derivation. - /// Only required if associated spl mint exists and actions contain mint actions. - pub token_pool_bump: u8, - /// Used to check token pool derivation. - /// Only required if associated spl mint exists and actions contain mint actions. - pub token_pool_index: u8, /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) pub max_top_up: u16, pub create_mint: Option, @@ -214,8 +200,9 @@ impl<'a> TryFrom<&ZCompressedMintInstructionData<'a>> for CompressedMint { version: instruction_data.metadata.version, cmint_decompressed: instruction_data.metadata.cmint_decompressed != 0, mint: instruction_data.metadata.mint, + compressed_address: instruction_data.metadata.compressed_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: crate::state::mint::ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), 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 d9e4de2a52..7ba2438bfd 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs @@ -1,7 +1,6 @@ mod builder; mod compress_and_close_cmint; mod cpi_context; -mod create_spl_mint; mod decompress_mint; mod instruction_data; mod mint_to_compressed; @@ -11,7 +10,6 @@ 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::*; diff --git a/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs b/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs index 081940068a..03e89551f1 100644 --- a/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs +++ b/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs @@ -13,7 +13,7 @@ pub enum CompressionMode { Compress, Decompress, /// Compresses ctoken account and closes it - /// Signer must be owner or rent authority, if rent authority ctoken account must be compressible + /// Signer must be rent authority, ctoken account must be compressible /// Not implemented for spl token accounts. CompressAndClose, } diff --git a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs index 83f6ee55f0..20e1e5e0ea 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs @@ -65,8 +65,16 @@ impl CToken { pub fn amount_from_slice(data: &[u8]) -> Result { const AMOUNT_OFFSET: usize = 64; // 32 (mint) + 32 (owner) - if data.len() < AMOUNT_OFFSET + 8 { - return Err(ZeroCopyError::Size); + check_token_account(data)?; + + #[inline(always)] + fn check_token_account(bytes: &[u8]) -> Result<(), ZeroCopyError> { + if bytes.len() == 165 || (bytes.len() > 165 && bytes[165] == ACCOUNT_TYPE_TOKEN_ACCOUNT) + { + Ok(()) + } else { + Err(ZeroCopyError::InvalidConversion) + } } let amount_bytes = &data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8]; diff --git a/program-libs/ctoken-interface/src/state/ctoken/mod.rs b/program-libs/ctoken-interface/src/state/ctoken/mod.rs index 0cc5b7edf4..af14abbcf0 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/mod.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/mod.rs @@ -1,8 +1,10 @@ mod borsh; mod ctoken_struct; mod size; +mod top_up; mod zero_copy; pub use ctoken_struct::*; pub use size::*; +pub use top_up::*; pub use zero_copy::*; diff --git a/program-libs/ctoken-interface/src/state/ctoken/size.rs b/program-libs/ctoken-interface/src/state/ctoken/size.rs index e53a6da551..e4c1dcba99 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/size.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/size.rs @@ -7,8 +7,8 @@ use crate::{ /// Calculates the size of a ctoken account based on which extensions are present. /// -/// Note: Compression info is now embedded in the base struct (CTokenZeroCopyMeta), -/// so there's no separate compressible extension parameter. +/// Note: Compressible extension is required if the T22 mint has restricted extensions +/// (Pausable, PermanentDelegate, TransferFee, TransferHook). /// /// # Arguments /// * `extensions` - Optional slice of extension configs diff --git a/program-libs/ctoken-interface/src/state/ctoken/top_up.rs b/program-libs/ctoken-interface/src/state/ctoken/top_up.rs new file mode 100644 index 0000000000..629b148353 --- /dev/null +++ b/program-libs/ctoken-interface/src/state/ctoken/top_up.rs @@ -0,0 +1,80 @@ +//! Optimized top-up lamports calculation for CToken accounts. + +use light_compressible::compression_info::CompressionInfo; +use light_program_profiler::profile; +#[cfg(target_os = "solana")] +use pinocchio::account_info::AccountInfo; + +use super::ACCOUNT_TYPE_TOKEN_ACCOUNT; +use crate::state::ExtensionType; + +/// Minimum size for CToken with Compressible extension as first extension. +/// 176 (offset to CompressionInfo) + 96 (CompressionInfo size) = 272 +pub const MIN_SIZE_WITH_COMPRESSIBLE: usize = COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE; + +/// Offset to CompressionInfo when Compressible is first extension. +/// 165 (base) + 1 (account_type) + 1 (Option) + 4 (Vec len) + 1 (ext disc) + 4 (ext header) = 176 +const COMPRESSION_INFO_OFFSET: usize = 176; + +/// Size of CompressionInfo struct. +/// 2 (config_account_version) + 1 (compress_to_pubkey) + 1 (account_version) + +/// 4 (lamports_per_write) + 32 (compression_authority) + 32 (rent_sponsor) + +/// 8 (last_claimed_slot) + 4 (rent_exemption_paid) + 4 (_reserved) + 8 (rent_config) = 96 +const COMPRESSION_INFO_SIZE: usize = 96; + +/// Offset to account_type field. +const ACCOUNT_TYPE_OFFSET: usize = 165; + +/// Offset to Option discriminator field. +const OPTION_DISCRIMINATOR_OFFSET: usize = 166; + +/// Offset to first extension discriminator. +const FIRST_EXT_DISCRIMINATOR_OFFSET: usize = 171; + +/// Option discriminator value for Some. +const OPTION_SOME: u8 = 1; + +/// Calculate top-up lamports directly from CToken account bytes. +/// Returns None if account doesn't have Compressible extension as first extension. +#[inline(always)] +#[profile] +pub fn top_up_lamports_from_slice( + data: &[u8], + current_lamports: u64, + current_slot: u64, +) -> Option { + if data.len() < MIN_SIZE_WITH_COMPRESSIBLE + || data[ACCOUNT_TYPE_OFFSET] != ACCOUNT_TYPE_TOKEN_ACCOUNT + || data[OPTION_DISCRIMINATOR_OFFSET] != OPTION_SOME + || data[FIRST_EXT_DISCRIMINATOR_OFFSET] != ExtensionType::Compressible as u8 + { + return None; + } + + let info: &CompressionInfo = bytemuck::from_bytes( + &data[COMPRESSION_INFO_OFFSET..COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE], + ); + + info.calculate_top_up_lamports(data.len() as u64, current_slot, current_lamports) + .ok() +} + +/// Calculate top-up lamports from an AccountInfo. +/// Returns None if account doesn't have Compressible extension as first extension. +/// Note: Does not verify account owner. Fetches clock/rent sysvars internally if needed. +/// Pass `current_slot` as 0 to fetch from Clock sysvar; non-zero values are used directly. +#[cfg(target_os = "solana")] +#[inline(always)] +#[profile] +pub fn top_up_lamports_from_account_info_unchecked( + account_info: &AccountInfo, + current_slot: &mut u64, +) -> Option { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + let data = account_info.try_borrow_data().ok()?; + let current_lamports = account_info.lamports(); + if *current_slot == 0 { + *current_slot = Clock::get().ok()?.slot; + } + top_up_lamports_from_slice(&data, current_lamports, *current_slot) +} diff --git a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs index 623f23cc34..49dc27104a 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs @@ -110,6 +110,11 @@ impl<'a> ZeroCopyNew<'a> for CToken { bytes: &'a mut [u8], config: Self::ZeroCopyConfig, ) -> Result<(Self::Output, &'a mut [u8]), light_zero_copy::errors::ZeroCopyError> { + // Check that the account is not already initialized (state byte at offset 108) + const STATE_OFFSET: usize = 108; + if bytes.len() > STATE_OFFSET && bytes[STATE_OFFSET] != 0 { + return Err(light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed); + } // Use derived new_zero_copy for base struct (config type is () for fixed-size struct) let (mut base, mut remaining) = >::new_zero_copy(bytes, ())?; @@ -159,7 +164,9 @@ impl<'a> ZeroCopyNew<'a> for CToken { } else { (ACCOUNT_TYPE_TOKEN_ACCOUNT, None) }; - + if !remaining.is_empty() { + return Err(light_zero_copy::errors::ZeroCopyError::Size); + } Ok(( ZCTokenMut { base, @@ -181,7 +188,7 @@ impl<'a> ZeroCopyAt<'a> for CToken { let (base, bytes) = >::zero_copy_at(bytes)?; // Check if there are extensions by looking at account_type byte at position 165 - if !bytes.is_empty() && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT { + if !bytes.is_empty() { let account_type = bytes[0]; // Skip account_type byte let bytes = &bytes[1..]; @@ -221,7 +228,7 @@ impl<'a> ZeroCopyAtMut<'a> for CToken { let (base, bytes) = >::zero_copy_at_mut(bytes)?; // Check if there are extensions by looking at account_type byte at position 165 - if !bytes.is_empty() && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT { + if !bytes.is_empty() { let account_type = bytes[0]; // Skip account_type byte let bytes = &mut bytes[1..]; @@ -345,6 +352,12 @@ impl<'a> ZCTokenMut<'a> { // Getters on ZCTokenZeroCopyMeta (immutable) impl ZCTokenZeroCopyMeta<'_> { + /// Checks if account is uninitialized (state == 0) + #[inline(always)] + pub fn is_uninitialized(&self) -> bool { + self.state == 0 + } + /// Checks if account is initialized (state == 1) #[inline(always)] pub fn is_initialized(&self) -> bool { @@ -390,6 +403,12 @@ impl ZCTokenZeroCopyMeta<'_> { // Getters on ZCTokenZeroCopyMetaMut (mutable) impl ZCTokenZeroCopyMetaMut<'_> { + /// Checks if account is uninitialized (state == 0) + #[inline(always)] + pub fn is_uninitialized(&self) -> bool { + self.state == 0 + } + /// Checks if account is initialized (state == 1) #[inline(always)] pub fn is_initialized(&self) -> bool { @@ -506,6 +525,79 @@ impl CToken { Ok((ctoken, remaining)) } + + /// Deserialize a CToken from account info with validation using zero-copy. + /// + /// Checks: + /// 1. Account is owned by the CTOKEN program + /// 2. Account is initialized (state != 0) + /// 3. Account type is ACCOUNT_TYPE_TOKEN_ACCOUNT (byte 165 == 2) + /// 4. No trailing bytes after the CToken structure + /// + /// Safety: The returned ZCToken references the account data which is valid + /// for the duration of the transaction. The caller must ensure the account + /// is not modified through other means while this reference exists. + #[inline(always)] + pub fn from_account_info_checked<'a>( + account_info: &pinocchio::account_info::AccountInfo, + ) -> Result, crate::error::CTokenError> { + // 1. Check program ownership + if !account_info.is_owned_by(&crate::CTOKEN_PROGRAM_ID) { + return Err(crate::error::CTokenError::InvalidCTokenOwner); + } + + let data = account_info + .try_borrow_data() + .map_err(|_| crate::error::CTokenError::BorrowFailed)?; + + // Extend lifetime to 'a - safe because account data lives for transaction duration + let data_slice: &'a [u8] = + unsafe { core::slice::from_raw_parts(data.as_ptr(), data.len()) }; + + let (ctoken, remaining) = CToken::zero_copy_at_checked(data_slice)?; + + // 4. Check no trailing bytes + if !remaining.is_empty() { + return Err(crate::error::CTokenError::InvalidAccountData); + } + + Ok(ctoken) + } + + /// Mutable version of from_account_info_checked. + /// Deserialize a CToken from account info with validation using zero-copy. + /// + /// Checks: + /// 1. Account is owned by the CTOKEN program + /// 2. Account is initialized (state != 0) + /// 3. Account type is ACCOUNT_TYPE_TOKEN_ACCOUNT (byte 165 == 2) + /// 4. No trailing bytes after the CToken structure + #[inline(always)] + pub fn from_account_info_mut_checked<'a>( + account_info: &pinocchio::account_info::AccountInfo, + ) -> Result, crate::error::CTokenError> { + // 1. Check program ownership + if !account_info.is_owned_by(&crate::CTOKEN_PROGRAM_ID) { + return Err(crate::error::CTokenError::InvalidCTokenOwner); + } + + let mut data = account_info + .try_borrow_mut_data() + .map_err(|_| crate::error::CTokenError::BorrowFailed)?; + + // Extend lifetime to 'a - safe because account data lives for transaction duration + let data_slice: &'a mut [u8] = + unsafe { core::slice::from_raw_parts_mut(data.as_mut_ptr(), data.len()) }; + + let (ctoken, remaining) = CToken::zero_copy_at_mut_checked(data_slice)?; + + // 4. Check no trailing bytes + if !remaining.is_empty() { + return Err(crate::error::CTokenError::InvalidAccountData); + } + + Ok(ctoken) + } } #[cfg(feature = "test-only")] diff --git a/program-libs/ctoken-interface/src/state/extensions/compressible.rs b/program-libs/ctoken-interface/src/state/extensions/compressible.rs index e98d61b3bc..52ef5979fd 100644 --- a/program-libs/ctoken-interface/src/state/extensions/compressible.rs +++ b/program-libs/ctoken-interface/src/state/extensions/compressible.rs @@ -98,4 +98,10 @@ impl ZCompressibleExtensionMut<'_> { } } } + + /// Returns whether this account is an ATA + #[inline(always)] + pub fn is_ata(&self) -> bool { + self.is_ata != 0 + } } 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 63f41d729b..ca6fec13f3 100644 --- a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs +++ b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs @@ -3,10 +3,13 @@ use light_compressed_account::Pubkey; use light_compressible::compression_info::CompressionInfo; use light_hasher::{sha256::Sha256BE, Hasher}; use light_zero_copy::{ZeroCopy, ZeroCopyMut}; +use pinocchio::account_info::AccountInfo; #[cfg(feature = "solana")] use solana_msg::msg; -use crate::{state::ExtensionStruct, AnchorDeserialize, AnchorSerialize, CTokenError}; +use crate::{ + state::ExtensionStruct, AnchorDeserialize, AnchorSerialize, CTokenError, CTOKEN_PROGRAM_ID, +}; /// AccountType::Mint discriminator value pub const ACCOUNT_TYPE_MINT: u8 = 1; @@ -17,7 +20,7 @@ pub struct CompressedMint { pub base: BaseMint, pub metadata: CompressedMintMetadata, /// Reserved bytes for T22 layout compatibility (padding to reach byte 165) - pub reserved: [u8; 49], + pub reserved: [u8; 17], /// Account type discriminator at byte 165 (1 = Mint, 2 = Account) pub account_type: u8, /// Compression info embedded directly in the mint @@ -30,7 +33,7 @@ impl Default for CompressedMint { Self { base: BaseMint::default(), metadata: CompressedMintMetadata::default(), - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -71,6 +74,8 @@ pub struct CompressedMintMetadata { pub cmint_decompressed: bool, /// Pda with seed address of compressed mint pub mint: Pubkey, + /// Address of the compressed account the mint is stored in. + pub compressed_address: [u8; 32], } impl CompressedMint { @@ -93,12 +98,9 @@ impl CompressedMint { /// /// 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 { + pub fn from_account_info_checked(account_info: &AccountInfo) -> Result { // 1. Check program ownership - if !account_info.is_owned_by(program_id) { + if !account_info.is_owned_by(&CTOKEN_PROGRAM_ID) { #[cfg(feature = "solana")] msg!("CMint account has invalid owner"); return Err(CTokenError::InvalidCMintOwner); @@ -119,6 +121,12 @@ impl CompressedMint { return Err(CTokenError::CMintNotInitialized); } + if !mint.is_cmint_account() { + #[cfg(feature = "solana")] + msg!("CMint account is not a CMint account"); + return Err(CTokenError::MintMismatch); + } + Ok(mint) } diff --git a/program-libs/ctoken-interface/src/state/mint/mod.rs b/program-libs/ctoken-interface/src/state/mint/mod.rs index 7684303b0e..63484b75f5 100644 --- a/program-libs/ctoken-interface/src/state/mint/mod.rs +++ b/program-libs/ctoken-interface/src/state/mint/mod.rs @@ -1,6 +1,8 @@ mod borsh; mod compressed_mint; +mod top_up; mod zero_copy; pub use compressed_mint::*; +pub use top_up::*; pub use zero_copy::*; diff --git a/program-libs/ctoken-interface/src/state/mint/top_up.rs b/program-libs/ctoken-interface/src/state/mint/top_up.rs new file mode 100644 index 0000000000..f5d92057e1 --- /dev/null +++ b/program-libs/ctoken-interface/src/state/mint/top_up.rs @@ -0,0 +1,86 @@ +//! Optimized top-up lamports calculation for CMint accounts. + +use light_compressible::compression_info::CompressionInfo; +use light_program_profiler::profile; +use light_zero_copy::traits::ZeroCopyAt; +#[cfg(target_os = "solana")] +use pinocchio::account_info::AccountInfo; + +use super::compressed_mint::ACCOUNT_TYPE_MINT; + +/// Minimum size for CMint with CompressionInfo. +/// 166 (offset to CompressionInfo) + 96 (CompressionInfo size) = 262 +pub const CMINT_MIN_SIZE_WITH_COMPRESSION: usize = COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE; + +/// Offset to CompressionInfo in CMint. +/// 82 (BaseMint) + 66 (metadata) + 17 (reserved) + 1 (account_type) = 166 +const COMPRESSION_INFO_OFFSET: usize = 166; + +/// Size of CompressionInfo struct (96 bytes). +const COMPRESSION_INFO_SIZE: usize = 96; + +/// Offset to account_type field. +const ACCOUNT_TYPE_OFFSET: usize = 165; + +/// Calculate top-up lamports directly from CMint account bytes. +/// Returns None if account is not a valid CMint. +#[inline(always)] +#[profile] +pub fn cmint_top_up_lamports_from_slice( + data: &[u8], + current_lamports: u64, + current_slot: u64, +) -> Option { + if data.len() < CMINT_MIN_SIZE_WITH_COMPRESSION + || data[ACCOUNT_TYPE_OFFSET] != ACCOUNT_TYPE_MINT + { + return None; + } + + let (info, _) = CompressionInfo::zero_copy_at( + &data[COMPRESSION_INFO_OFFSET..COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE], + ) + .ok()?; + + info.calculate_top_up_lamports(data.len() as u64, current_slot, current_lamports) + .ok() +} + +/// Calculate top-up lamports from a CMint AccountInfo. +/// Verifies account owner is the CToken program. Returns None if owner mismatch or invalid. +/// Pass `current_slot` as 0 to fetch from Clock sysvar; non-zero values are used directly. +#[cfg(target_os = "solana")] +#[inline(always)] +#[profile] +pub fn cmint_top_up_lamports_from_account_info( + account_info: &AccountInfo, + current_slot: &mut u64, +) -> Option { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + + // Check owner is CToken program + if !account_info.is_owned_by(&crate::CTOKEN_PROGRAM_ID) { + return None; + } + + let data = account_info.try_borrow_data().ok()?; + + if data.len() < CMINT_MIN_SIZE_WITH_COMPRESSION + || data[ACCOUNT_TYPE_OFFSET] != ACCOUNT_TYPE_MINT + { + return None; + } + + let current_lamports = account_info.lamports(); + if *current_slot == 0 { + *current_slot = Clock::get().ok()?.slot; + } + + let (info, _) = CompressionInfo::zero_copy_at( + &data[COMPRESSION_INFO_OFFSET..COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE], + ) + .ok()?; + + info.calculate_top_up_lamports(data.len() as u64, *current_slot, current_lamports) + .ok() +} diff --git a/program-libs/ctoken-interface/src/state/mint/zero_copy.rs b/program-libs/ctoken-interface/src/state/mint/zero_copy.rs index e2f3ff6919..055e44fa04 100644 --- a/program-libs/ctoken-interface/src/state/mint/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/mint/zero_copy.rs @@ -45,7 +45,7 @@ struct CompressedMintZeroCopyMeta { // CompressedMintMetadata pub metadata: CompressedMintMetadata, /// Reserved bytes for T22 layout compatibility (padding to reach byte 165) - pub reserved: [u8; 49], + pub reserved: [u8; 17], /// Account type discriminator at byte 165 (1 = Mint, 2 = Account) pub account_type: u8, /// Compression info embedded directly in the mint @@ -107,6 +107,11 @@ impl<'a> ZeroCopyNew<'a> for CompressedMint { bytes: &'a mut [u8], config: Self::ZeroCopyConfig, ) -> Result<(Self::Output, &'a mut [u8]), light_zero_copy::errors::ZeroCopyError> { + // Check that the account is not already initialized (is_initialized byte at offset 45) + const IS_INITIALIZED_OFFSET: usize = 45; // 4 + 32 + 8 + 1 = 45 + if bytes.len() > IS_INITIALIZED_OFFSET && bytes[IS_INITIALIZED_OFFSET] != 0 { + return Err(light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed); + } // Use derived new_zero_copy for meta struct let meta_config = CompressedMintZeroCopyMetaConfig { metadata: (), @@ -422,6 +427,7 @@ impl ZCompressedMintMut<'_> { self.base.metadata.version = ix_data.metadata.version; self.base.metadata.mint = ix_data.metadata.mint; self.base.metadata.cmint_decompressed = if cmint_decompressed { 1 } else { 0 }; + self.base.metadata.compressed_address = ix_data.metadata.compressed_address; // Set base fields self.base.supply = ix_data.supply; diff --git a/program-libs/ctoken-interface/src/token_2022_extensions.rs b/program-libs/ctoken-interface/src/token_2022_extensions.rs index 30755073ae..f36f8857b3 100644 --- a/program-libs/ctoken-interface/src/token_2022_extensions.rs +++ b/program-libs/ctoken-interface/src/token_2022_extensions.rs @@ -74,7 +74,7 @@ pub struct MintExtensionFlags { } impl MintExtensionFlags { - pub fn num_extensions(&self) -> usize { + pub fn num_token_account_extensions(&self) -> usize { let mut count = 0; if self.has_pausable { count += 1; diff --git a/program-libs/ctoken-interface/tests/compressed_mint.rs b/program-libs/ctoken-interface/tests/compressed_mint.rs index 9f520e1201..13e2f90a06 100644 --- a/program-libs/ctoken-interface/tests/compressed_mint.rs +++ b/program-libs/ctoken-interface/tests/compressed_mint.rs @@ -2,6 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use light_compressed_account::Pubkey; use light_compressible::compression_info::CompressionInfo; use light_ctoken_interface::state::{ + cmint_top_up_lamports_from_slice, extensions::{AdditionalMetadata, ExtensionStruct, TokenMetadata}, BaseMint, CompressedMint, CompressedMintConfig, CompressedMintMetadata, ACCOUNT_TYPE_MINT, }; @@ -77,8 +78,9 @@ fn generate_random_compressed_mint(rng: &mut impl Rng, with_extensions: bool) -> version: 3, mint, cmint_decompressed: rng.gen_bool(0.5), + compressed_address: rng.gen(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions, @@ -146,6 +148,7 @@ fn test_compressed_mint_borsh_zerocopy_compatibility() { } else { 0 }; + zc_mint.base.metadata.compressed_address = original_mint.metadata.compressed_address; // account_type is already set in new_zero_copy // Set compression fields zc_mint.base.compression.config_account_version = @@ -257,8 +260,9 @@ fn test_compressed_mint_edge_cases() { version: 3, mint: Pubkey::from([0xff; 32]), cmint_decompressed: false, + compressed_address: [0u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -290,6 +294,7 @@ fn test_compressed_mint_edge_cases() { zc_mint.base.metadata.version = mint_no_auth.metadata.version; zc_mint.base.metadata.mint = mint_no_auth.metadata.mint; zc_mint.base.metadata.cmint_decompressed = 0; + zc_mint.base.metadata.compressed_address = mint_no_auth.metadata.compressed_address; // account_type is already set in new_zero_copy // Set compression fields zc_mint.base.compression.config_account_version = @@ -334,8 +339,9 @@ fn test_compressed_mint_edge_cases() { version: 255, mint: Pubkey::from([0xbb; 32]), cmint_decompressed: true, + compressed_address: [0xcc; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -361,8 +367,9 @@ fn test_base_mint_in_compressed_mint_spl_format() { version: 3, mint: Pubkey::from([3; 32]), cmint_decompressed: false, + compressed_address: [4u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -385,3 +392,70 @@ fn test_base_mint_in_compressed_mint_spl_format() { let base_mint = BaseMint::deserialize(&mut base_mint_bytes.to_vec().as_slice()).unwrap(); assert_eq!(mint.base, base_mint); } + +#[test] +fn test_compressed_mint_new_zero_copy_fails_if_already_initialized() { + let config = CompressedMintConfig { extensions: None }; + let byte_len = CompressedMint::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; byte_len]; + + // First initialization should succeed + let _ = CompressedMint::new_zero_copy(&mut buffer, config.clone()) + .expect("First init should succeed"); + + // Second initialization should fail because account is already initialized + let result = CompressedMint::new_zero_copy(&mut buffer, config); + assert!( + result.is_err(), + "new_zero_copy should fail if account is already initialized" + ); + assert_eq!( + result.unwrap_err(), + light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed + ); +} + +/// Test that cmint_top_up_lamports_from_slice produces identical results to full deserialization. +#[test] +fn test_cmint_top_up_lamports_matches_full_deserialization() { + // Create a CMint using zero-copy + let config = CompressedMintConfig { extensions: None }; + let byte_len = CompressedMint::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; byte_len]; + let (mut cmint, _) = CompressedMint::new_zero_copy(&mut buffer, config).unwrap(); + + // Set known values in CompressionInfo + cmint.base.compression.lamports_per_write = 1000.into(); + cmint.base.compression.last_claimed_slot = 13500.into(); // Epoch 1 + cmint.base.compression.rent_exemption_paid = 50_000.into(); + cmint.base.compression.rent_config.base_rent = 128.into(); + cmint.base.compression.rent_config.compression_cost = 11000.into(); + cmint + .base + .compression + .rent_config + .lamports_per_byte_per_epoch = 1; + cmint.base.compression.rent_config.max_funded_epochs = 2; + + // Test parameters + let current_slot = 27000u64; // Epoch 2 + let current_lamports = 100_000u64; + + // Calculate using optimized function + let optimized_result = + cmint_top_up_lamports_from_slice(&buffer, current_lamports, current_slot) + .expect("Should return Some"); + + // Calculate using full deserialization + let (cmint_read, _) = CompressedMint::zero_copy_at(&buffer).unwrap(); + let full_deser_result = cmint_read + .base + .compression + .calculate_top_up_lamports(buffer.len() as u64, current_slot, current_lamports) + .expect("Should succeed"); + + assert_eq!( + optimized_result, full_deser_result, + "Optimized result should match full deserialization" + ); +} diff --git a/program-libs/ctoken-interface/tests/cross_deserialization.rs b/program-libs/ctoken-interface/tests/cross_deserialization.rs index e1d90d7854..07705b3fc1 100644 --- a/program-libs/ctoken-interface/tests/cross_deserialization.rs +++ b/program-libs/ctoken-interface/tests/cross_deserialization.rs @@ -29,8 +29,9 @@ fn create_test_cmint() -> CompressedMint { version: 3, mint: Pubkey::new_from_array([2; 32]), cmint_decompressed: false, + compressed_address: [5u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo { config_account_version: 1, @@ -40,6 +41,8 @@ fn create_test_cmint() -> CompressedMint { compression_authority: [3u8; 32], rent_sponsor: [4u8; 32], last_claimed_slot: 100, + rent_exemption_paid: 0, + _reserved: 0, rent_config: RentConfig { base_rent: 0, compression_cost: 0, @@ -77,6 +80,8 @@ fn create_test_ctoken_with_extension() -> CToken { compression_authority: [3u8; 32], rent_sponsor: [4u8; 32], last_claimed_slot: 100, + rent_exemption_paid: 0, + _reserved: 0, rent_config: RentConfig { base_rent: 0, compression_cost: 0, diff --git a/program-libs/ctoken-interface/tests/ctoken/mod.rs b/program-libs/ctoken-interface/tests/ctoken/mod.rs index bc3c1fcb23..5ba623dc6d 100644 --- a/program-libs/ctoken-interface/tests/ctoken/mod.rs +++ b/program-libs/ctoken-interface/tests/ctoken/mod.rs @@ -2,4 +2,5 @@ pub mod failing; pub mod randomized_solana_ctoken; pub mod size; pub mod spl_compat; +pub mod top_up; pub mod zero_copy_new; diff --git a/program-libs/ctoken-interface/tests/ctoken/top_up.rs b/program-libs/ctoken-interface/tests/ctoken/top_up.rs new file mode 100644 index 0000000000..f331e8c804 --- /dev/null +++ b/program-libs/ctoken-interface/tests/ctoken/top_up.rs @@ -0,0 +1,77 @@ +//! Test that top_up_lamports_from_slice produces identical results to full deserialization. + +use light_compressed_account::Pubkey; +use light_ctoken_interface::state::{ + top_up_lamports_from_slice, CToken, CompressedTokenConfig, CompressibleExtensionConfig, + CompressionInfoConfig, ExtensionStructConfig, +}; +use light_zero_copy::traits::{ZeroCopyAt, ZeroCopyNew}; + +#[test] +fn test_top_up_lamports_matches_full_deserialization() { + // Create a CToken with Compressible extension + let config = CompressedTokenConfig { + mint: Pubkey::default(), + owner: Pubkey::default(), + state: 1, + extensions: Some(vec![ExtensionStructConfig::Compressible( + CompressibleExtensionConfig { + info: CompressionInfoConfig { rent_config: () }, + }, + )]), + }; + + let size = CToken::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; size]; + let (mut ctoken, _) = CToken::new_zero_copy(&mut buffer, config).unwrap(); + + // Set known values in CompressionInfo via zero-copy + let ext = ctoken.extensions.as_mut().unwrap(); + let compressible = ext + .iter_mut() + .find_map(|e| match e { + light_ctoken_interface::state::ZExtensionStructMut::Compressible(c) => Some(c), + _ => None, + }) + .unwrap(); + + // Set test values + compressible.info.lamports_per_write = 1000.into(); + compressible.info.last_claimed_slot = 13500.into(); // Epoch 1 + compressible.info.rent_exemption_paid = 50_000.into(); + compressible.info.rent_config.base_rent = 128.into(); + compressible.info.rent_config.compression_cost = 11000.into(); + compressible.info.rent_config.lamports_per_byte_per_epoch = 1; + compressible.info.rent_config.max_funded_epochs = 2; + + // Test parameters + let current_slot = 27000u64; // Epoch 2 + let current_lamports = 100_000u64; + + // Calculate using optimized function + let optimized_result = top_up_lamports_from_slice(&buffer, current_lamports, current_slot) + .expect("Should return Some"); + + // Calculate using full deserialization + let (ctoken_read, _) = CToken::zero_copy_at(&buffer).unwrap(); + let compressible_read = ctoken_read + .extensions + .as_ref() + .unwrap() + .iter() + .find_map(|e| match e { + light_ctoken_interface::state::ZExtensionStruct::Compressible(c) => Some(c), + _ => None, + }) + .unwrap(); + + let full_deser_result = compressible_read + .info + .calculate_top_up_lamports(buffer.len() as u64, current_slot, current_lamports) + .expect("Should succeed"); + + assert_eq!( + optimized_result, full_deser_result, + "Optimized result should match full deserialization" + ); +} diff --git a/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs b/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs index 97c4572e84..22effe6182 100644 --- a/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs +++ b/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs @@ -111,3 +111,24 @@ fn test_compressed_token_byte_len_consistency() { assert!(size_with_ext > size_no_ext); } + +#[test] +fn test_new_zero_copy_fails_if_already_initialized() { + let config = default_config(); + let required_size = CToken::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; required_size]; + + // First initialization should succeed + let _ = CToken::new_zero_copy(&mut buffer, config.clone()).expect("First init should succeed"); + + // Second initialization should fail because account is already initialized + let result = CToken::new_zero_copy(&mut buffer, config); + assert!( + result.is_err(), + "new_zero_copy should fail if account is already initialized" + ); + assert_eq!( + result.unwrap_err(), + light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed + ); +} 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 02b7a8331f..34feaed87f 100644 --- a/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs +++ b/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs @@ -103,8 +103,9 @@ fn generate_random_mint() -> CompressedMint { rng.fill(&mut bytes); Pubkey::from(bytes) }, + compressed_address: rng.gen(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions, @@ -165,6 +166,7 @@ fn compare_mint_borsh_vs_zero_copy(original: &CompressedMint, borsh_bytes: &[u8] version: zc_mint.base.metadata.version, cmint_decompressed: zc_mint.base.metadata.cmint_decompressed != 0, mint: zc_mint.base.metadata.mint, + compressed_address: zc_mint.base.metadata.compressed_address, }, reserved: *zc_mint.base.reserved, account_type: zc_mint.base.account_type, @@ -189,6 +191,7 @@ fn compare_mint_borsh_vs_zero_copy(original: &CompressedMint, borsh_bytes: &[u8] version: zc_mint_mut.base.metadata.version, cmint_decompressed: zc_mint_mut.base.metadata.cmint_decompressed != 0, mint: zc_mint_mut.base.metadata.mint, + compressed_address: zc_mint_mut.base.metadata.compressed_address, }, reserved: *zc_mint_mut.base.reserved, account_type: *zc_mint_mut.base.account_type, @@ -252,8 +255,9 @@ fn generate_mint_with_extensions() -> CompressedMint { version: 3, cmint_decompressed: rng.gen_bool(0.5), mint: Pubkey::from(rng.gen::<[u8; 32]>()), + compressed_address: rng.gen(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(token_metadata)]), @@ -286,8 +290,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: false, mint: Pubkey::from([2u8; 32]), + compressed_address: [0u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(TokenMetadata { @@ -315,8 +320,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: true, mint: Pubkey::from([0xbbu8; 32]), + compressed_address: [0xddu8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(TokenMetadata { @@ -357,8 +363,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: false, mint: Pubkey::from([4u8; 32]), + compressed_address: [5u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(TokenMetadata { @@ -386,8 +393,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: true, mint: Pubkey::from([7u8; 32]), + compressed_address: [8u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index c7adddb3b2..6a08faa6d4 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -38,6 +38,8 @@ use super::shared::{set_ctoken_account_state, setup_extensions_test}; const DECOMPRESS_DESTINATION_MISMATCH: u32 = 18057; /// Expected error code for MintMismatch const MINT_MISMATCH: u32 = 18058; +/// Expected error code for DecompressAmountMismatch +const DECOMPRESS_AMOUNT_MISMATCH: u32 = 18064; /// Setup context for ATA CompressOnly tests struct AtaCompressedTokenContext { @@ -781,6 +783,536 @@ async fn test_decompress_skips_delegate_if_destination_has_delegate() { ); } +/// Test that decompress with mismatched amount fails for ATA. +/// The compression_amount in the instruction must match the input token data amount. +#[tokio::test] +#[serial] +async fn test_ata_decompress_with_mismatched_amount_fails() { + use borsh::BorshSerialize; + use light_compressed_account::compressed_account::PackedMerkleContext; + use light_ctoken_interface::{ + instructions::transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiInputTokenDataWithContext, + }, + TRANSFER2, + }; + use light_ctoken_sdk::compressed_token::transfer2::account_metas::{ + get_transfer2_instruction_account_metas, Transfer2AccountsMetaConfig, + }; + use light_sdk::instruction::PackedAccounts; + use solana_sdk::instruction::Instruction; + + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA + let create_dest_ix = CreateAssociatedCTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_pubkey, + ) + .with_compressible(CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // Build instruction data directly to control compressions without SDK adding change outputs + let compressed_account = &context.compressed_account; + let mut packed_accounts = PackedAccounts::default(); + + // Add merkle tree and output queue + let merkle_tree = compressed_account.account.tree_info.tree; + let queue = compressed_account.account.tree_info.queue; + let tree_index = packed_accounts.insert_or_get(merkle_tree); + let queue_index = packed_accounts.insert_or_get(queue); + + // Add mint and wallet owner (for signing and TLV owner_index) + let mint_index = packed_accounts.insert_or_get_read_only(compressed_account.token.mint); + let wallet_owner_index = + packed_accounts.insert_or_get_config(context.owner.pubkey(), true, false); + + // Add CToken ATA recipient account - this is also the compressed token owner for ATAs + let ctoken_ata_index = packed_accounts.insert_or_get_config(context.ata_pubkey, false, true); + + // Create input token data with FULL amount (what merkle proof verifies) + // For ATA compressed tokens, owner is the ATA pubkey (not wallet) + let has_delegate = compressed_account.token.delegate.is_some(); + let delegate_index = if has_delegate { + packed_accounts + .insert_or_get_read_only(compressed_account.token.delegate.unwrap_or_default()) + } else { + 0 + }; + + let input_token_data = vec![MultiInputTokenDataWithContext { + owner: ctoken_ata_index, // ATA pubkey is the compressed token owner + amount: compressed_account.token.amount, // Full amount for merkle proof + has_delegate, + delegate: delegate_index, + mint: mint_index, + version: 3, // ShaFlat + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_index, + queue_pubkey_index: queue_index, + leaf_index: compressed_account.account.leaf_index, + prove_by_index: true, + }, + root_index: 0, + }]; + + // Create compression with WRONG amount (mismatch!) + // Input has full amount but compression claims only half + let wrong_decompress_amount = context.amount / 2; + let compressions = vec![ + Compression { + mode: CompressionMode::Decompress, + amount: wrong_decompress_amount, // WRONG: doesn't match input amount + mint: mint_index, + source_or_recipient: ctoken_ata_index, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 9, + }, + Compression { + mode: CompressionMode::Decompress, + amount: wrong_decompress_amount, // WRONG: doesn't match input amount + mint: mint_index, + source_or_recipient: ctoken_ata_index, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 9, + }, + ]; + + // Build in_tlv for CompressedOnly extension + // owner_index in TLV is the wallet owner (who can sign), not the ATA + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: context.ata_bump, + owner_index: wallet_owner_index, + }, + )]]; + + // Build instruction data directly + let instruction_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: queue_index, + proof: + light_compressed_account::instruction_data::compressed_proof::ValidityProof::default() + .into(), + in_token_data: input_token_data, + out_token_data: vec![], // No compressed outputs + in_lamports: None, + out_lamports: None, + in_tlv: Some(in_tlv), + out_tlv: None, + compressions: Some(compressions), + cpi_context: None, + max_top_up: 0, + }; + + // Serialize instruction data + let serialized = instruction_data.try_to_vec().unwrap(); + let mut data = Vec::with_capacity(1 + serialized.len()); + data.push(TRANSFER2); + data.extend(serialized); + + // Get account metas + let (account_metas, _, _) = packed_accounts.to_account_metas(); + let meta_config = Transfer2AccountsMetaConfig::new(context.payer.pubkey(), account_metas); + let instruction_account_metas = get_transfer2_instruction_account_metas(meta_config); + + let decompress_ix = Instruction { + program_id: light_ctoken_interface::CTOKEN_PROGRAM_ID.into(), + accounts: instruction_account_metas, + data, + }; + + let result = context + .rpc + .create_and_send_transaction( + &[decompress_ix], + &context.payer.pubkey(), + &[&context.payer, &context.owner], + ) + .await; + + assert_rpc_error(result, 0, DECOMPRESS_AMOUNT_MISMATCH).unwrap(); +} + +/// Test that multiple compress-decompress cycles work correctly for the same ATA. +/// Creates the same ATA twice, each time compressing it, then decompresses both +/// compressed accounts back to the ATA in a single Transfer2 instruction. +#[tokio::test] +#[serial] +async fn test_ata_multiple_compress_decompress_cycles() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Create mint with Pausable extension (restricted, requires compression_only) + let extensions = &[ExtensionType::Pausable]; + let (mint_keypair, _) = + create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; + let mint_pubkey = mint_keypair.pubkey(); + + // Create SPL Token-2022 account and mint tokens for funding + let spl_account = + create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let total_mint_amount = 10_000_000_000u64; + mint_spl_tokens_22( + &mut rpc, + &payer, + &mint_pubkey, + &spl_account, + total_mint_amount, + ) + .await; + + // Setup wallet owner and derive ATA + let wallet = Keypair::new(); + let (ata_pubkey, ata_bump) = derive_ctoken_ata(&wallet.pubkey(), &mint_pubkey); + + let amount1 = 100_000_000u64; + let amount2 = 200_000_000u64; + + // ========== CYCLE 1 ========== + println!("=== Cycle 1: Create ATA, fund, compress ==="); + + // Create ATA with compression_only=true + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, // Immediately compressible + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Transfer tokens from SPL to ATA + let has_restricted = extensions + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); + + let transfer_ix1 = TransferSplToCtoken { + amount: amount1, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination_ctoken_account: ata_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix1], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Warp to trigger compression + rpc.warp_epoch_forward(30).await.unwrap(); + + // Verify ATA is closed + let ata_after_cycle1 = rpc.get_account(ata_pubkey).await.unwrap(); + assert!( + ata_after_cycle1.is_none(), + "ATA should be closed after cycle 1 compression" + ); + + // ========== CYCLE 2 ========== + println!("=== Cycle 2: Create ATA again, fund, compress ==="); + + // Create ATA again (same address) + let create_ata_ix2 = + CreateAssociatedCTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix2], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Transfer tokens from SPL to ATA + let transfer_ix2 = TransferSplToCtoken { + amount: amount2, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination_ctoken_account: ata_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix2], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Warp to trigger compression + rpc.warp_epoch_forward(30).await.unwrap(); + + // Verify ATA is closed again + let ata_after_cycle2 = rpc.get_account(ata_pubkey).await.unwrap(); + assert!( + ata_after_cycle2.is_none(), + "ATA should be closed after cycle 2 compression" + ); + + // ========== VERIFY COMPRESSED ACCOUNTS ========== + println!("=== Verifying compressed accounts ==="); + + // For ATAs with compression_only=true, the compressed account owner is the ATA pubkey + let compressed_accounts = rpc + .get_compressed_token_accounts_by_owner(&ata_pubkey, None, None) + .await + .unwrap() + .value + .items; + + assert_eq!( + compressed_accounts.len(), + 2, + "Should have 2 compressed token accounts (one from each cycle)" + ); + + // Verify both have CompressedOnly extension with is_ata=1 + for (i, account) in compressed_accounts.iter().enumerate() { + let has_compressed_only_with_is_ata = account + .token + .tlv + .as_ref() + .map(|tlv| { + tlv.iter() + .any(|ext| matches!(ext, ExtensionStruct::CompressedOnly(e) if e.is_ata == 1)) + }) + .unwrap_or(false); + + assert!( + has_compressed_only_with_is_ata, + "Compressed account {} should have CompressedOnly extension with is_ata=1", + i + ); + + // Verify owner is ATA pubkey + let owner_bytes: [u8; 32] = account.token.owner.to_bytes(); + assert_eq!( + owner_bytes, + ata_pubkey.to_bytes(), + "Compressed account {} owner should be ATA pubkey", + i + ); + } + + // Verify amounts + let amounts: Vec = compressed_accounts.iter().map(|a| a.token.amount).collect(); + assert!( + amounts.contains(&amount1) && amounts.contains(&amount2), + "Should have compressed accounts with amounts {} and {}, got {:?}", + amount1, + amount2, + amounts + ); + + // ========== DECOMPRESS BOTH ========== + println!("=== Decompressing both to same ATA ==="); + + // Create ATA again (destination for decompress) + let create_ata_ix3 = + CreateAssociatedCTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 2, // More epochs so it won't be compressed immediately + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .idempotent() + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix3], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Build Transfer2 with TWO Decompress operations to the same ATA + // Each decompress needs a unique compression_index + let in_tlv1 = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, // First decompress + is_ata: true, + bump: ata_bump, + owner_index: 0, // Will be updated by create_generic_transfer2_instruction + }, + )]]; + + let in_tlv2 = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 1, // Second decompress - different index + is_ata: true, + bump: ata_bump, + owner_index: 0, // Will be updated by create_generic_transfer2_instruction + }, + )]]; + + let decompress_ix = create_generic_transfer2_instruction( + &mut rpc, + vec![ + Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[0].clone()], + decompress_amount: compressed_accounts[0].token.amount, + solana_token_account: ata_pubkey, + amount: compressed_accounts[0].token.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv1), + }), + Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[1].clone()], + decompress_amount: compressed_accounts[1].token.amount, + solana_token_account: ata_pubkey, + amount: compressed_accounts[1].token.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv2), + }), + ], + payer.pubkey(), + true, + ) + .await + .unwrap(); + + // For ATA decompress, wallet owner signs (not ATA pubkey) + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer, &wallet]) + .await + .unwrap(); + + // ========== VERIFY FINAL STATE ========== + println!("=== Verifying final state ==="); + + // Verify ATA has combined balance + use borsh::BorshDeserialize; + use light_ctoken_interface::state::CToken; + + let ata_account = rpc.get_account(ata_pubkey).await.unwrap().unwrap(); + let ata_ctoken = CToken::deserialize(&mut &ata_account.data[..]).unwrap(); + + assert_eq!( + ata_ctoken.amount, + amount1 + amount2, + "ATA should have combined balance of {} + {} = {}, got {}", + amount1, + amount2, + amount1 + amount2, + ata_ctoken.amount + ); + + // Verify no more compressed token accounts + let remaining = rpc + .get_compressed_token_accounts_by_owner(&ata_pubkey, None, None) + .await + .unwrap() + .value + .items; + + assert!( + remaining.is_empty(), + "All compressed accounts should be consumed, got {} remaining", + remaining.len() + ); + + println!( + "Successfully completed ATA multiple compress-decompress cycles test. Final balance: {}", + ata_ctoken.amount + ); +} + /// Test that non-ATA CompressOnly decompress keeps current owner-match behavior. #[tokio::test] #[serial] diff --git a/program-tests/compressed-token-test/tests/compress_only/default_state.rs b/program-tests/compressed-token-test/tests/compress_only/default_state.rs index 7374d63200..918c530776 100644 --- a/program-tests/compressed-token-test/tests/compress_only/default_state.rs +++ b/program-tests/compressed-token-test/tests/compress_only/default_state.rs @@ -62,12 +62,12 @@ async fn test_create_ctoken_with_frozen_default_state() { .await .unwrap(); - // Verify account was created with correct size (266 bytes = 166 base + 7 metadata + 90 compressible + 3 markers) + // Verify account was created with correct size (274 bytes = 166 base + 7 metadata + 98 compressible + 3 markers) let account = rpc.get_account(account_pubkey).await.unwrap().unwrap(); assert_eq!( account.data.len(), - 266, - "CToken account should be 266 bytes" + 274, + "CToken account should be 274 bytes" ); // Deserialize the CToken account using borsh diff --git a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs index c8021d4c5c..9d6f9f5ff3 100644 --- a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs +++ b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs @@ -83,7 +83,7 @@ async fn test_approve_fails() { 100, None, "non_existent_account", - 6000, // Pinocchio token program error - account doesn't exist + 6153, // NotRentExempt (SPL Token code 0 -> ErrorCode::NotRentExempt) ) .await; } @@ -253,7 +253,7 @@ async fn test_revoke_fails() { &owner, None, "non_existent_account", - 6000, // Pinocchio token program error - account doesn't exist + 6153, // NotRentExempt (SPL Token code 0 -> ErrorCode::NotRentExempt) ) .await; } @@ -475,170 +475,3 @@ async fn test_approve_revoke_compressible() -> Result<(), RpcError> { println!("Successfully tested approve and revoke with compressible CToken"); Ok(()) } - -// ============================================================================ -// Approve Checked Tests -// ============================================================================ - -use light_ctoken_sdk::ctoken::ApproveCTokenChecked; -use light_program_test::utils::assert::assert_rpc_error; - -use super::shared::setup_account_test_with_spl_mint; - -/// Test approve checked with correct decimals succeeds -#[tokio::test] -#[serial] -async fn test_approve_checked_success() { - let mut context = setup_account_test_with_spl_mint(9).await.unwrap(); - let payer_pubkey = context.payer.pubkey(); - let mint = context.mint_pubkey; - let delegate = Keypair::new(); - let token_account_keypair = Keypair::new(); - - // Create a token account directly (without assertion that expects specific structure) - let compressible_params = CompressibleParams { - compressible_config: context.compressible_config, - rent_sponsor: context.rent_sponsor, - pre_pay_num_epochs: 2, - lamports_per_write: Some(100), - compress_to_account_pubkey: None, - token_account_version: TokenDataVersion::ShaFlat, - compression_only: false, - }; - - let create_ix = CreateCTokenAccount::new( - payer_pubkey, - token_account_keypair.pubkey(), - mint, - context.owner_keypair.pubkey(), - ) - .with_compressible(compressible_params) - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[create_ix], - &payer_pubkey, - &[&context.payer, &token_account_keypair], - ) - .await - .unwrap(); - - // Fund owner for compressible top-up - context - .rpc - .airdrop_lamports(&context.owner_keypair.pubkey(), 1_000_000_000) - .await - .unwrap(); - - let approve_ix = ApproveCTokenChecked { - token_account: token_account_keypair.pubkey(), - mint, - delegate: delegate.pubkey(), - owner: context.owner_keypair.pubkey(), - amount: 100, - decimals: 9, // Correct decimals - max_top_up: None, - } - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[approve_ix], - &payer_pubkey, - &[&context.payer, &context.owner_keypair], - ) - .await - .unwrap(); - - // Verify delegation was set - assert_ctoken_approve( - &mut context.rpc, - token_account_keypair.pubkey(), - delegate.pubkey(), - 100, - ) - .await; - - println!("test_approve_checked_success: passed"); -} - -/// Test approve checked with wrong decimals fails -#[tokio::test] -#[serial] -async fn test_approve_checked_wrong_decimals() { - let mut context = setup_account_test_with_spl_mint(9).await.unwrap(); - let payer_pubkey = context.payer.pubkey(); - let mint = context.mint_pubkey; - let delegate = Keypair::new(); - let token_account_keypair = Keypair::new(); - - // Create a token account directly (without assertion that expects specific structure) - let compressible_params = CompressibleParams { - compressible_config: context.compressible_config, - rent_sponsor: context.rent_sponsor, - pre_pay_num_epochs: 2, - lamports_per_write: Some(100), - compress_to_account_pubkey: None, - token_account_version: TokenDataVersion::ShaFlat, - compression_only: false, - }; - - let create_ix = CreateCTokenAccount::new( - payer_pubkey, - token_account_keypair.pubkey(), - mint, - context.owner_keypair.pubkey(), - ) - .with_compressible(compressible_params) - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[create_ix], - &payer_pubkey, - &[&context.payer, &token_account_keypair], - ) - .await - .unwrap(); - - // Fund owner for compressible top-up - context - .rpc - .airdrop_lamports(&context.owner_keypair.pubkey(), 1_000_000_000) - .await - .unwrap(); - - // Try to approve with wrong decimals (8 instead of 9) - let approve_ix = ApproveCTokenChecked { - token_account: token_account_keypair.pubkey(), - mint, - delegate: delegate.pubkey(), - owner: context.owner_keypair.pubkey(), - amount: 100, - decimals: 8, // Wrong decimals - max_top_up: None, - } - .instruction() - .unwrap(); - - let result = context - .rpc - .create_and_send_transaction( - &[approve_ix], - &payer_pubkey, - &[&context.payer, &context.owner_keypair], - ) - .await; - - // Should fail because cached decimals (9) mismatch instruction decimals (8) - // When CToken has cached decimals, we return InvalidInstructionData (code 2) - assert_rpc_error(result, 0, 2).unwrap(); - println!("test_approve_checked_wrong_decimals: passed"); -} diff --git a/program-tests/compressed-token-test/tests/ctoken/burn.rs b/program-tests/compressed-token-test/tests/ctoken/burn.rs index d27e1fe0e6..847365b6ec 100644 --- a/program-tests/compressed-token-test/tests/ctoken/burn.rs +++ b/program-tests/compressed-token-test/tests/ctoken/burn.rs @@ -103,12 +103,12 @@ async fn test_burn_success_cases() { // Burn Failure Cases // ============================================================================ -/// Error codes used in burn validation +/// Error codes used in burn validation (mapped to ErrorCode enum variants) mod error_codes { - /// Insufficient funds to complete the operation (SPL Token code 1) - pub const INSUFFICIENT_FUNDS: u32 = 1; - /// Authority doesn't match token account owner (SPL Token code 4) - pub const OWNER_MISMATCH: u32 = 4; + /// Insufficient funds to complete the operation (SplInsufficientFunds = 6154) + pub const INSUFFICIENT_FUNDS: u32 = 6154; + /// Authority doesn't match token account owner (OwnerMismatch = 6075) + pub const OWNER_MISMATCH: u32 = 6075; } #[tokio::test] @@ -142,8 +142,8 @@ async fn test_burn_fails() { ) .await; - // Non-existent CMint returns GenericError (code 0) - assert_rpc_error(result, 0, 0).unwrap(); + // Non-existent CMint returns NotRentExempt (SPL Token code 0 -> 6153) + assert_rpc_error(result, 0, 6153).unwrap(); println!("test_burn_fails: wrong mint passed"); } @@ -172,8 +172,8 @@ async fn test_burn_fails() { ) .await; - // Non-existent CToken account returns GenericError (code 0) - assert_rpc_error(result, 0, 0).unwrap(); + // Non-existent CToken account returns NotRentExempt (SPL Token code 0 -> 6153) + assert_rpc_error(result, 0, 6153).unwrap(); println!("test_burn_fails: non-existent account passed"); } @@ -399,8 +399,8 @@ async fn setup_burn_test() -> BurnTestContext { use light_ctoken_sdk::ctoken::BurnCTokenChecked; -/// MintDecimalsMismatch error code (SPL Token code 18) -const MINT_DECIMALS_MISMATCH: u32 = 18; +/// MintDecimalsMismatch error code (SplMintDecimalsMismatch = 6166) +const MINT_DECIMALS_MISMATCH: u32 = 6166; #[tokio::test] #[serial] diff --git a/program-tests/compressed-token-test/tests/ctoken/create.rs b/program-tests/compressed-token-test/tests/ctoken/create.rs index 19b5b180c4..6082cead8e 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create.rs @@ -89,6 +89,59 @@ async fn test_create_compressible_token_account_instruction() { create_and_assert_token_account(&mut context, compressible_data, "No lamports_per_write") .await; } + + // Test 6: Maximum prepaid epochs (255) - boundary test + { + context.token_account_keypair = Keypair::new(); + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: payer_pubkey, // Use payer as rent sponsor for large epoch payment + num_prepaid_epochs: 255, // Maximum u8 value + lamports_per_write: Some(100), + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + create_and_assert_token_account(&mut context, compressible_data, "max_prepaid_epochs_255") + .await; + } + + // Test 7: Exactly max_top_up for lamports_per_write - boundary test + { + context.token_account_keypair = Keypair::new(); + let max_top_up = RentConfig::default().max_top_up as u32; + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(max_top_up), // Exactly at limit (should succeed) + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + create_and_assert_token_account( + &mut context, + compressible_data, + "exactly_max_top_up_lamports_per_write", + ) + .await; + } + + // Test 8: Zero lamports_per_write - edge case + { + context.token_account_keypair = Keypair::new(); + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(0), // Zero (should succeed) + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + create_and_assert_token_account(&mut context, compressible_data, "zero_lamports_per_write") + .await; + } } #[tokio::test] @@ -453,4 +506,192 @@ async fn test_create_compressible_token_account_failing() { // Should fail with MissingRequiredSignature (8) light_program_test::utils::assert::assert_rpc_error(result, 0, 8).unwrap(); } + + // Test 10: Non-compressible account for mint with restricted extensions + // Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) + // require the compression_only marker which is part of the Compressible extension. + // Error: 6115 (MissingCompressibleConfig) + { + use forester_utils::instructions::create_account::create_account_instruction; + use light_test_utils::mint_2022::create_mint_22_with_extension_types; + use solana_sdk::instruction::{AccountMeta, Instruction}; + use spl_token_2022::extension::ExtensionType; + + println!("Test 10: Non-compressible account for mint with restricted extensions"); + + // Create a Token-2022 mint with TransferFeeConfig (a restricted extension) + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, // decimals + &[ExtensionType::TransferFeeConfig], + ) + .await; + let mint_with_restricted_ext = mint_keypair.pubkey(); + + // Pre-allocate 200-byte token account owned by ctoken program + let token_account_keypair = Keypair::new(); + let token_account_pubkey = token_account_keypair.pubkey(); + let account_size = 200usize; + + let create_account_ix = create_account_instruction( + &payer_pubkey, + account_size, + context + .rpc + .get_minimum_balance_for_rent_exemption(account_size) + .await + .unwrap(), + &light_compressed_token::ID, + Some(&token_account_keypair), + ); + + context + .rpc + .create_and_send_transaction( + &[create_account_ix], + &payer_pubkey, + &[&context.payer, &token_account_keypair], + ) + .await + .unwrap(); + + // Build manual instruction for non-compressible path: + // - discriminator: 18 (InitializeAccount3) + // - data: just 32 bytes (owner pubkey) - triggers non-compressible path + // - accounts: token_account (mutable), mint (non-mutable) + let owner_pubkey = context.owner_keypair.pubkey(); + let mut instruction_data = vec![18u8]; // discriminator + instruction_data.extend_from_slice(&owner_pubkey.to_bytes()); // 32-byte owner + + let create_non_compressible_ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(token_account_pubkey, false), // token_account (mutable, not signer) + AccountMeta::new_readonly(mint_with_restricted_ext, false), // mint + ], + data: instruction_data, + }; + + let result = context + .rpc + .create_and_send_transaction( + &[create_non_compressible_ix], + &payer_pubkey, + &[&context.payer], + ) + .await; + + // Should fail with MissingCompressibleConfig (6115) + // Rationale: Mints with restricted extensions must be marked as compression_only, + // and that marker is part of the Compressible extension + light_program_test::utils::assert::assert_rpc_error(result, 0, 6115).unwrap(); + } + + // Test 11: Token account passed as mint + // Passing a valid T22 token account instead of a mint should fail validation. + // The is_valid_mint function checks AccountType at offset 165 - token accounts have AccountType=2. + // Error: 3 (InstructionError::InvalidAccountData) + { + use light_test_utils::mint_2022::{ + create_mint_22_with_extension_types, create_token_22_account, + }; + use spl_token_2022::extension::ExtensionType; + + println!("Test 11: Token account passed as mint"); + + context.token_account_keypair = Keypair::new(); + + // Create a real T22 mint + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, + &[ExtensionType::TransferFeeConfig], + ) + .await; + let real_mint = mint_keypair.pubkey(); + + // Create a T22 token account for that mint + let t22_token_account = + create_token_22_account(&mut context.rpc, &context.payer, &real_mint, &payer_pubkey) + .await; + + // Try to create CToken with the token account as mint (should fail) + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compression_only: true, // Required for restricted extensions + }; + + let create_token_account_ix = CreateCTokenAccount::new( + payer_pubkey, + context.token_account_keypair.pubkey(), + t22_token_account, // Token account, not mint! + context.owner_keypair.pubkey(), + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + let result = context + .rpc + .create_and_send_transaction( + &[create_token_account_ix], + &payer_pubkey, + &[&context.payer, &context.token_account_keypair], + ) + .await; + + // Should fail with InstructionError::InvalidAccountData (3) because is_valid_mint returns false + // for token accounts (AccountType=2 at offset 165) + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); + } + + // Test 12: Invalid token_account_version + // Only version 3 (ShaFlat) is supported. V1 and V2 are rejected. + // Error: 2 (InstructionError::InvalidInstructionData) + { + println!("Test 12: Invalid token_account_version"); + + context.token_account_keypair = Keypair::new(); + + // Build instruction using SDK with V1 version (not supported for create) + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::V1, // Not supported! + compression_only: false, + }; + + let create_ix = CreateCTokenAccount::new( + payer_pubkey, + context.token_account_keypair.pubkey(), + context.mint_pubkey, + context.owner_keypair.pubkey(), + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + let result = context + .rpc + .create_and_send_transaction( + &[create_ix], + &payer_pubkey, + &[&context.payer, &context.token_account_keypair], + ) + .await; + + // Should fail with InstructionError::InvalidInstructionData (2) + // Only version 3 (ShaFlat) is supported for compressible accounts + light_program_test::utils::assert::assert_rpc_error(result, 0, 2).unwrap(); + } } diff --git a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs index be001e0c6d..256d4eafe8 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs @@ -123,6 +123,78 @@ async fn test_create_compressible_ata() { ) .await; } + + // Test 6: Maximum prepaid epochs (255) - boundary test + { + // Use different mint for sixth ATA + context.mint_pubkey = solana_sdk::pubkey::Pubkey::new_unique(); + + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: payer_pubkey, // Use payer as rent sponsor for large epoch payment + num_prepaid_epochs: 255, // Maximum u8 value + lamports_per_write: Some(100), + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + + create_and_assert_ata( + &mut context, + Some(compressible_data), + false, // Non-idempotent + "max_prepaid_epochs_255", + ) + .await; + } + + // Test 7: Owner equals mint pubkey - edge case + // This is an unusual but valid configuration where the owner of the ATA + // is the same pubkey as the mint. Should succeed. + { + // Use a new unique pubkey that will serve as both owner and mint + let owner_and_mint = solana_sdk::pubkey::Pubkey::new_unique(); + context.mint_pubkey = owner_and_mint; + + // Temporarily change the owner keypair to use the same pubkey as mint + // Note: This is a degenerate case but should still work + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compression_only: true, + }; + + let create_ata_ix = CreateAssociatedCTokenAccount::new( + payer_pubkey, + owner_and_mint, // Owner == Mint + owner_and_mint, // Mint == Owner + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[create_ata_ix], &payer_pubkey, &[&context.payer]) + .await + .unwrap(); + + // Verify ATA was created at the expected address + let (expected_ata, _) = derive_ctoken_ata(&owner_and_mint, &owner_and_mint); + let account = context.rpc.get_account(expected_ata).await.unwrap(); + assert!( + account.is_some(), + "ATA with owner==mint should be created successfully" + ); + println!( + "Successfully created ATA with owner==mint at {}", + expected_ata + ); + } } #[tokio::test] @@ -699,6 +771,131 @@ async fn test_create_ata_failing() { // Solana runtime rejects this as unauthorized signer privilege escalation. light_program_test::utils::assert::assert_rpc_error(result, 0, 19).unwrap(); } + + // Test 11: Non-compressible ATA for mint with restricted extensions + // Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) + // require the compression_only marker which is part of the Compressible extension. + // Error: 6115 (MissingCompressibleConfig) + { + use anchor_lang::prelude::borsh::BorshSerialize; + use light_ctoken_interface::instructions::create_associated_token_account::CreateAssociatedTokenAccountInstructionData; + use light_test_utils::mint_2022::create_mint_22_with_extension_types; + use solana_sdk::instruction::Instruction; + use spl_token_2022::extension::ExtensionType; + + println!("Test 11: Non-compressible ATA for mint with restricted extensions"); + + // Create a Token-2022 mint with TransferFeeConfig (a restricted extension) + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, // decimals + &[ExtensionType::TransferFeeConfig], + ) + .await; + let mint_with_restricted_ext = mint_keypair.pubkey(); + + // Use a new owner for this test + let owner = solana_sdk::pubkey::Pubkey::new_unique(); + + // Derive ATA address + let (ata_pubkey, bump) = derive_ctoken_ata(&owner, &mint_with_restricted_ext); + + // Build instruction data with compressible_config: None (non-compressible) + let instruction_data = CreateAssociatedTokenAccountInstructionData { + bump, + compressible_config: None, // Non-compressible! + }; + + let mut data = vec![100]; // CreateAssociatedCTokenAccount discriminator + instruction_data.serialize(&mut data).unwrap(); + + // Account order: owner, mint, payer, ata, system_program + let ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + solana_sdk::instruction::AccountMeta::new_readonly(owner, false), + solana_sdk::instruction::AccountMeta::new_readonly(mint_with_restricted_ext, false), + solana_sdk::instruction::AccountMeta::new(payer_pubkey, true), + solana_sdk::instruction::AccountMeta::new(ata_pubkey, false), + solana_sdk::instruction::AccountMeta::new_readonly( + solana_sdk::pubkey::Pubkey::default(), + false, + ), + ], + data, + }; + + let result = context + .rpc + .create_and_send_transaction(&[ix], &payer_pubkey, &[&context.payer]) + .await; + + // Should fail with MissingCompressibleConfig (6115) + // Rationale: Mints with restricted extensions must be marked as compression_only, + // and that marker is part of the Compressible extension + light_program_test::utils::assert::assert_rpc_error(result, 0, 6115).unwrap(); + } + + // Test 12: Token account passed as mint + // Passing a valid T22 token account instead of a mint should fail validation. + // The is_valid_mint function checks AccountType at offset 165 - token accounts have AccountType=2. + // Error: 3 (InstructionError::InvalidAccountData) + { + use light_test_utils::mint_2022::{ + create_mint_22_with_extension_types, create_token_22_account, + }; + use spl_token_2022::extension::ExtensionType; + + println!("Test 12: Token account passed as mint (ATA)"); + + // Create a real T22 mint + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, + &[ExtensionType::TransferFeeConfig], + ) + .await; + let real_mint = mint_keypair.pubkey(); + + // Create a T22 token account for that mint + let t22_token_account = + create_token_22_account(&mut context.rpc, &context.payer, &real_mint, &payer_pubkey) + .await; + + // Use a new owner for this test + let owner = solana_sdk::pubkey::Pubkey::new_unique(); + + // Try to create ATA with the token account as mint (should fail) + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compression_only: true, // Required for restricted extensions + }; + + let create_ata_ix = CreateAssociatedCTokenAccount::new( + payer_pubkey, + owner, + t22_token_account, // Token account, not mint! + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + let result = context + .rpc + .create_and_send_transaction(&[create_ata_ix], &payer_pubkey, &[&context.payer]) + .await; + + // Should fail with InstructionError::InvalidAccountData (3) because is_valid_mint returns false + // for token accounts (AccountType=2 at offset 165) + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); + } } #[tokio::test] diff --git a/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs b/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs index b94be727f2..c1be0813c9 100644 --- a/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs +++ b/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs @@ -416,7 +416,7 @@ async fn test_spl_instruction_compatibility() { /// Test SPL token instruction compatibility with ctoken program using decompressed cmint /// /// This test uses a real decompressed cmint to test instructions that require mint data: -/// - transfer_checked, approve_checked (require decimals validation) +/// - transfer_checked, /// - mint_to, mint_to_checked (require mint authority) /// - burn, burn_checked (require token burning) /// - freeze_account, thaw_account (require freeze authority) @@ -684,68 +684,6 @@ async fn test_spl_instruction_compatibility_with_cmint() { println!("transfer_checked completed successfully"); } - println!("Testing approve_checked using SPL instruction format..."); - - // ApproveChecked using SPL instruction format - { - let delegate = Keypair::new(); - - let mut approve_checked_ix = spl_token_2022::instruction::approve_checked( - &spl_token_2022::ID, - &account1_keypair.pubkey(), - &cmint_pda, - &delegate.pubkey(), - &owner_keypair.pubkey(), - &[], - 200, - decimals, - ) - .unwrap(); - approve_checked_ix.program_id = light_compressed_token::ID; - - rpc.create_and_send_transaction( - &[approve_checked_ix], - &payer_pubkey, - &[&payer, &owner_keypair], - ) - .await - .unwrap(); - - // Verify delegate was set - let account1 = rpc - .get_account(account1_keypair.pubkey()) - .await - .unwrap() - .unwrap(); - let account1_data = - spl_token_2022::state::Account::unpack_unchecked(&account1.data[..165]).unwrap(); - assert_eq!( - account1_data.delegate, - solana_sdk::program_option::COption::Some(delegate.pubkey()), - "Delegate should be set" - ); - assert_eq!( - account1_data.delegated_amount, 200, - "Delegated amount should be 200" - ); - - // Revoke for next tests - let mut revoke_ix = spl_token_2022::instruction::revoke( - &spl_token_2022::ID, - &account1_keypair.pubkey(), - &owner_keypair.pubkey(), - &[], - ) - .unwrap(); - revoke_ix.program_id = light_compressed_token::ID; - - rpc.create_and_send_transaction(&[revoke_ix], &payer_pubkey, &[&payer, &owner_keypair]) - .await - .unwrap(); - - println!("approve_checked completed successfully"); - } - println!("Testing freeze_account using SPL instruction format..."); // FreezeAccount using SPL instruction format @@ -896,7 +834,6 @@ async fn test_spl_instruction_compatibility_with_cmint() { println!(" - mint_to: Minted 1000 tokens"); println!(" - mint_to_checked: Minted 500 tokens with decimals validation"); println!(" - transfer_checked: Transferred 500 tokens with decimals validation"); - println!(" - approve_checked: Approved delegate with decimals validation"); println!(" - freeze_account: Froze account"); println!(" - thaw_account: Thawed account"); println!(" - burn: Burned 100 tokens"); diff --git a/program-tests/compressed-token-test/tests/ctoken/transfer.rs b/program-tests/compressed-token-test/tests/ctoken/transfer.rs index 998c825d93..bf58bb89ee 100644 --- a/program-tests/compressed-token-test/tests/ctoken/transfer.rs +++ b/program-tests/compressed-token-test/tests/ctoken/transfer.rs @@ -315,7 +315,7 @@ async fn test_ctoken_transfer_insufficient_balance() { let owner_keypair = context.owner_keypair.insecure_clone(); // Try to transfer more than the balance (1500 > 1000) - // Expected error: InsufficientFunds (error code 1) + // Expected error: SplInsufficientFunds (6154) transfer_and_assert_fails( &mut context, source, @@ -323,7 +323,7 @@ async fn test_ctoken_transfer_insufficient_balance() { 1500, &owner_keypair, "insufficient_balance_transfer", - 1, // InsufficientFunds + 6154, // SplInsufficientFunds ) .await; } @@ -393,7 +393,7 @@ async fn test_ctoken_transfer_wrong_authority() { let wrong_authority = Keypair::new(); // Try to transfer with wrong authority - // Expected error: OwnerMismatch (error code 4) + // Expected error: OwnerMismatch (6075) transfer_and_assert_fails( &mut context, source, @@ -401,7 +401,7 @@ async fn test_ctoken_transfer_wrong_authority() { 500, &wrong_authority, "wrong_authority_transfer", - 4, // OwnerMismatch + 6075, // OwnerMismatch ) .await; } @@ -425,7 +425,7 @@ async fn test_ctoken_transfer_mint_mismatch() { let owner_keypair = context.owner_keypair.insecure_clone(); // Try to transfer between accounts with different mints - // The SPL Token program returns error code 3 (MintMismatch) + // Expected error: SplMintMismatch (6155) transfer_and_assert_fails( &mut context, source, @@ -433,7 +433,7 @@ async fn test_ctoken_transfer_mint_mismatch() { 500, &owner_keypair, "mint_mismatch_transfer", - 3, // MintMismatch + 6155, // SplMintMismatch ) .await; } @@ -911,7 +911,7 @@ async fn test_ctoken_transfer_checked_insufficient_balance() { 9, &owner_keypair, "insufficient_balance_transfer_checked", - 1, // InsufficientFunds + 6154, // SplInsufficientFunds ) .await; } 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 b3a1249206..90c2e4eebb 100644 --- a/program-tests/compressed-token-test/tests/mint/cpi_context.rs +++ b/program-tests/compressed-token-test/tests/mint/cpi_context.rs @@ -4,8 +4,8 @@ use light_client::indexer::Indexer; use light_compressed_account::instruction_data::traits::LightInstructionData; use light_ctoken_interface::{ instructions::mint_action::{ - CompressedMintInstructionData, CompressedMintWithContext, CpiContext, - MintActionCompressedInstructionData, + CompressedMintInstructionData, CompressedMintWithContext, CpiContext, DecompressMintAction, + MintActionCompressedInstructionData, MintToCTokenAction, }, state::CompressedMintMetadata, CMINT_ADDRESS_TREE, CTOKEN_PROGRAM_ID, @@ -83,6 +83,7 @@ async fn test_setup() -> TestSetup { version: 3, cmint_decompressed: false, mint: spl_mint_pda.into(), + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: Some(freeze_authority.into()), @@ -124,7 +125,6 @@ async fn test_write_to_cpi_context_create_mint() { // Build instruction data using new builder API let instruction_data = MintActionCompressedInstructionData::new_mint( - compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), compressed_mint_inputs.mint.clone().unwrap(), @@ -245,7 +245,6 @@ async fn test_write_to_cpi_context_invalid_address_tree() { // Build instruction data with invalid address tree let instruction_data = MintActionCompressedInstructionData::new_mint( - compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), compressed_mint_inputs.mint.clone().unwrap(), @@ -335,12 +334,14 @@ async fn test_write_to_cpi_context_invalid_compressed_address() { // Keep the correct address_tree_pubkey but provide wrong address let invalid_compressed_address = [42u8; 32]; - // Build instruction data with invalid compressed address + // Build instruction data with invalid compressed address in metadata + let mut invalid_mint = compressed_mint_inputs.mint.clone().unwrap(); + invalid_mint.metadata.compressed_address = invalid_compressed_address; + let instruction_data = MintActionCompressedInstructionData::new_mint( - invalid_compressed_address, compressed_mint_inputs.root_index, CompressedProof::default(), - compressed_mint_inputs.mint.clone().unwrap(), + invalid_mint, ) .with_cpi_context(CpiContext { set_context: false, @@ -438,7 +439,6 @@ async fn test_execute_cpi_context_invalid_tree_index() { // Build instruction data for execute mode - must mark as create_mint let instruction_data = MintActionCompressedInstructionData::new_mint( - compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), compressed_mint_inputs.mint.clone().unwrap(), @@ -500,3 +500,280 @@ async fn test_execute_cpi_context_invalid_tree_index() { // Error code 6104 = MintActionInvalidCpiContextForCreateMint assert_rpc_error(result, 0, 6104).unwrap(); } + +#[tokio::test] +#[serial] +async fn test_write_to_cpi_context_decompressed_mint_fails() { + let TestSetup { + mut rpc, + compressed_mint_inputs: _, + payer, + mint_seed: _, + mint_authority, + compressed_mint_address, + cpi_context_pubkey, + address_tree, + address_tree_index, + output_queue: _, + output_queue_index, + } = test_setup().await; + + // Build instruction data with mint = None (simulates decompressed mint) + // This triggers cmint_decompressed = true in AccountsConfig + let mint_with_context = CompressedMintWithContext { + leaf_index: 0, + prove_by_index: false, + root_index: 0, + address: compressed_mint_address, + mint: None, + }; + + let instruction_data = MintActionCompressedInstructionData::new(mint_with_context, None) + .with_cpi_context(CpiContext { + set_context: false, + first_set_context: true, + in_tree_index: address_tree_index, + in_queue_index: 0, + out_queue_index: output_queue_index, + token_out_queue_index: 0, + assigned_account_index: 0, + read_only_address_trees: [0; 4], + address_tree_pubkey: address_tree.to_bytes(), + }); + + // Build account metas for CPI write mode + let config = MintActionMetaConfigCpiWrite { + fee_payer: payer.pubkey(), + mint_signer: None, + authority: mint_authority.pubkey(), + cpi_context: cpi_context_pubkey, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(config); + + // Serialize instruction data + let data = instruction_data + .data() + .expect("Failed to serialize instruction data"); + + // Build compressed token instruction + let ctoken_instruction = Instruction { + program_id: Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + accounts: account_metas, + data: data.clone(), + }; + + // Build wrapper instruction + let wrapper_ix_data = + compressed_token_test::instruction::WriteToCpiContextMintAction { inputs: data }; + + let wrapper_instruction = Instruction { + program_id: WRAPPER_PROGRAM_ID, + accounts: vec![AccountMeta::new_readonly( + Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + false, + )] + .into_iter() + .chain(ctoken_instruction.accounts.clone()) + .collect(), + data: wrapper_ix_data.data(), + }; + + // Execute wrapper instruction - should fail with CpiContextSetNotUsable + let result = rpc + .create_and_send_transaction( + &[wrapper_instruction], + &payer.pubkey(), + &[&payer, &mint_authority], + ) + .await; + + // Assert error code 6035 = CpiContextSetNotUsable + // "Decompress mint not allowed when writing to cpi context" + assert_rpc_error(result, 0, 6035).unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_write_to_cpi_context_mint_to_ctoken_fails() { + let TestSetup { + mut rpc, + compressed_mint_inputs, + payer, + mint_seed, + mint_authority, + compressed_mint_address: _, + cpi_context_pubkey, + address_tree, + address_tree_index, + output_queue: _, + output_queue_index, + } = test_setup().await; + + // Build instruction data for create mint with MintToCToken action + // MintToCToken is not allowed when writing to CPI context + let instruction_data = MintActionCompressedInstructionData::new_mint( + compressed_mint_inputs.root_index, + CompressedProof::default(), + compressed_mint_inputs.mint.clone().unwrap(), + ) + .with_mint_to_ctoken(MintToCTokenAction { + account_index: 0, + amount: 1000, + }) + .with_cpi_context(CpiContext { + set_context: false, + first_set_context: true, + in_tree_index: address_tree_index, + in_queue_index: 0, + out_queue_index: output_queue_index, + token_out_queue_index: 0, + assigned_account_index: 0, + read_only_address_trees: [0; 4], + address_tree_pubkey: address_tree.to_bytes(), + }); + + // Build account metas for CPI write mode + let config = MintActionMetaConfigCpiWrite { + fee_payer: payer.pubkey(), + mint_signer: Some(mint_seed.pubkey()), + authority: mint_authority.pubkey(), + cpi_context: cpi_context_pubkey, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(config); + + // Serialize instruction data + let data = instruction_data + .data() + .expect("Failed to serialize instruction data"); + + // Build compressed token instruction + let ctoken_instruction = Instruction { + program_id: Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + accounts: account_metas, + data: data.clone(), + }; + + // Build wrapper instruction + let wrapper_ix_data = + compressed_token_test::instruction::WriteToCpiContextMintAction { inputs: data }; + + let wrapper_instruction = Instruction { + program_id: WRAPPER_PROGRAM_ID, + accounts: vec![AccountMeta::new_readonly( + Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + false, + )] + .into_iter() + .chain(ctoken_instruction.accounts.clone()) + .collect(), + data: wrapper_ix_data.data(), + }; + + // Execute wrapper instruction - should fail with CpiContextSetNotUsable + let result = rpc + .create_and_send_transaction( + &[wrapper_instruction], + &payer.pubkey(), + &[&payer, &mint_seed, &mint_authority], + ) + .await; + + // Assert error code 6035 = CpiContextSetNotUsable + // "Mint to ctokens not allowed when writing to cpi context" + assert_rpc_error(result, 0, 6035).unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_write_to_cpi_context_decompress_mint_action_fails() { + let TestSetup { + mut rpc, + compressed_mint_inputs, + payer, + mint_seed, + mint_authority, + compressed_mint_address: _, + cpi_context_pubkey, + address_tree, + address_tree_index, + output_queue: _, + output_queue_index, + } = test_setup().await; + + // Build instruction data for create mint with DecompressMint action + // DecompressMint is not allowed when writing to CPI context + let instruction_data = MintActionCompressedInstructionData::new_mint( + compressed_mint_inputs.root_index, + CompressedProof::default(), + compressed_mint_inputs.mint.clone().unwrap(), + ) + .with_decompress_mint(DecompressMintAction { + cmint_bump: 255, + rent_payment: 2, + write_top_up: 1000, + }) + .with_cpi_context(CpiContext { + set_context: false, + first_set_context: true, + in_tree_index: address_tree_index, + in_queue_index: 0, + out_queue_index: output_queue_index, + token_out_queue_index: 0, + assigned_account_index: 0, + read_only_address_trees: [0; 4], + address_tree_pubkey: address_tree.to_bytes(), + }); + + // Build account metas for CPI write mode + let config = MintActionMetaConfigCpiWrite { + fee_payer: payer.pubkey(), + mint_signer: Some(mint_seed.pubkey()), + authority: mint_authority.pubkey(), + cpi_context: cpi_context_pubkey, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(config); + + // Serialize instruction data + let data = instruction_data + .data() + .expect("Failed to serialize instruction data"); + + // Build compressed token instruction + let ctoken_instruction = Instruction { + program_id: Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + accounts: account_metas, + data: data.clone(), + }; + + // Build wrapper instruction + let wrapper_ix_data = + compressed_token_test::instruction::WriteToCpiContextMintAction { inputs: data }; + + let wrapper_instruction = Instruction { + program_id: WRAPPER_PROGRAM_ID, + accounts: vec![AccountMeta::new_readonly( + Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + false, + )] + .into_iter() + .chain(ctoken_instruction.accounts.clone()) + .collect(), + data: wrapper_ix_data.data(), + }; + + // Execute wrapper instruction - should fail with CpiContextSetNotUsable + let result = rpc + .create_and_send_transaction( + &[wrapper_instruction], + &payer.pubkey(), + &[&payer, &mint_seed, &mint_authority], + ) + .await; + + // Assert error code 6035 = CpiContextSetNotUsable + // "Decompress mint not allowed when writing to cpi context" + assert_rpc_error(result, 0, 6035).unwrap(); +} 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 index e60c4bc481..e0b9882f2f 100644 --- a/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs +++ b/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs @@ -223,8 +223,8 @@ async fn test_ctoken_mint_to_checked_wrong_decimals() { ) .await; - // Should fail with MintDecimalsMismatch (error code 18 in pinocchio) + // Should fail with MintDecimalsMismatch (error code 18 in pinocchio mapped to 6166) assert!(result.is_err(), "Mint with wrong decimals should fail"); - light_program_test::utils::assert::assert_rpc_error(result, 0, 18).unwrap(); + light_program_test::utils::assert::assert_rpc_error(result, 0, 6166).unwrap(); println!("test_ctoken_mint_to_checked_wrong_decimals: passed"); } diff --git a/program-tests/compressed-token-test/tests/mint/failing.rs b/program-tests/compressed-token-test/tests/mint/failing.rs index 0ba559c05b..d4fdf2d5a7 100644 --- a/program-tests/compressed-token-test/tests/mint/failing.rs +++ b/program-tests/compressed-token-test/tests/mint/failing.rs @@ -1019,3 +1019,89 @@ async fn test_create_mint_non_signer_mint_signer() { ) .unwrap(); } + +/// Test that CompressAndCloseCMint must be the only action in the instruction. +/// Attempting to combine CompressAndCloseCMint with UpdateMintAuthority should fail. +#[tokio::test] +#[serial] +async fn test_compress_and_close_cmint_must_be_only_action() { + use light_compressible::rent::SLOTS_PER_EPOCH; + use light_ctoken_sdk::compressed_token::create_compressed_mint::derive_cmint_compressed_address; + use light_program_test::program_test::TestRpc; + use light_token_client::instructions::mint_action::DecompressMintParams; + + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + + let payer = Keypair::new(); + rpc.airdrop_lamports(&payer.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let mint_seed = Keypair::new(); + let mint_authority = Keypair::new(); + rpc.airdrop_lamports(&mint_authority.pubkey(), 1_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + + // 1. Create compressed mint with CMint (decompressed) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + Some(light_token_client::instructions::mint_action::NewMint { + decimals: 9, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: None, + metadata: None, + version: 3, + }), + ) + .await + .unwrap(); + + // Warp to epoch 2 so that rent expires + rpc.warp_to_slot(SLOTS_PER_EPOCH * 2).unwrap(); + + // 2. Try to combine CompressAndCloseCMint with UpdateMintAuthority + let new_authority = Keypair::new(); + let result = light_token_client::actions::mint_action( + &mut rpc, + light_token_client::instructions::mint_action::MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: mint_authority.pubkey(), + payer: payer.pubkey(), + actions: vec![ + MintActionType::CompressAndCloseCMint { idempotent: false }, + MintActionType::UpdateMintAuthority { + new_authority: Some(new_authority.pubkey()), + }, + ], + new_mint: None, + }, + &mint_authority, + &payer, + None, + ) + .await; + + // Should fail with CompressAndCloseCMintMustBeOnlyAction (error code 6169) + assert_rpc_error( + result, 0, 6169, // CompressAndCloseCMintMustBeOnlyAction + ) + .unwrap(); +} diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 5c45f9cb65..a7da7c01c8 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -156,37 +156,8 @@ async fn test_create_compressed_mint() { ) .await; } - // // 3. Create SPL mint from compressed mint - // // Get compressed mint data before creating SPL mint - // { - // let pre_compressed_mint_account = rpc - // .indexer() - // .unwrap() - // .get_compressed_account(compressed_mint_address, None) - // .await - // .unwrap() - // .value.unwrap(); - // let pre_compressed_mint: CompressedMint = BorshDeserialize::deserialize( - // &mut pre_compressed_mint_account.data.unwrap().data.as_slice(), - // ) - // .unwrap(); - - // // Use our create_spl_mint action helper (automatically handles proofs, PDAs, and transaction) - // create_spl_mint( - // &mut rpc, - // compressed_mint_address, - // &mint_seed, - // &mint_authority_keypair, - // &payer, - // ) - // .await - // .unwrap(); - - // // Verify SPL mint was created using our assertion helper - // assert_spl_mint(&mut rpc, mint_seed.pubkey(), &pre_compressed_mint).await; - // } - // 4. Transfer compressed tokens to new recipient + // 3. Transfer compressed tokens to new recipient // Get the compressed token account for decompression let compressed_token_accounts = rpc .indexer() @@ -1256,9 +1227,10 @@ async fn test_mint_actions() { metadata: CompressedMintMetadata { version: 3, // With metadata mint: spl_mint_pda.into(), - cmint_decompressed: false, // Should be true after CreateSplMint action + cmint_decompressed: false, // Becomes true after DecompressMint action + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ @@ -1490,8 +1462,9 @@ async fn test_create_compressed_mint_with_cmint() { version: 3, cmint_decompressed: false, // Before DecompressMint mint: cmint_pda.to_bytes().into(), + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -1557,6 +1530,123 @@ async fn test_create_compressed_mint_with_cmint() { println!("CompressAndCloseCMint test completed successfully!"); } +/// Test idempotent behavior of CompressAndCloseCMint. +/// When CMint is already compressed, calling with idempotent=true should succeed silently. +#[tokio::test] +#[serial] +async fn test_compress_and_close_cmint_idempotent() { + use light_program_test::program_test::TestRpc; + + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let decimals = 9u8; + let mint_seed = Keypair::new(); + let mint_authority = Keypair::new(); + let freeze_authority = Keypair::new(); + + rpc.airdrop_lamports(&mint_authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (cmint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint WITH CMint (decompress_mint = true) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + 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(); + + // Warp to epoch 2 so that rent expires + rpc.warp_to_slot(SLOTS_PER_EPOCH * 2).unwrap(); + + // 2. Compress and close CMint (first time - should succeed) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + None, + true, // compress_and_close_cmint = true + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // Verify CMint is closed + let cmint_after_close = rpc.get_account(cmint_pda).await.unwrap(); + assert!( + cmint_after_close.is_none(), + "CMint should be closed after CompressAndCloseCMint" + ); + + // 3. Try CompressAndCloseCMint again with idempotent=true (should succeed silently) + // Use a very low compute budget (10k) to verify the CPI is being skipped. + // If CPI was executed, this would fail due to insufficient compute units. + use light_client::rpc::Rpc; + use solana_sdk::compute_budget::ComputeBudgetInstruction; + + let mint_action_ix = + light_token_client::instructions::mint_action::create_mint_action_instruction( + &mut rpc, + light_token_client::instructions::mint_action::MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: mint_authority.pubkey(), + payer: payer.pubkey(), + actions: vec![MintActionType::CompressAndCloseCMint { idempotent: true }], + new_mint: None, + }, + ) + .await + .unwrap(); + + let compute_budget_ix = ComputeBudgetInstruction::set_compute_unit_limit(10_000); + + let result = rpc + .create_and_send_transaction( + &[compute_budget_ix, mint_action_ix], + &payer.pubkey(), + &[&payer, &mint_authority], + ) + .await; + + assert!( + result.is_ok(), + "CompressAndCloseCMint with idempotent=true should succeed with only 10k compute units when CPI is skipped: {:?}", + result.err() + ); + + println!("CompressAndCloseCMint idempotent test completed successfully!"); +} + /// Test decompressing an existing compressed mint to CMint /// 1. Create compressed mint without CMint /// 2. Mint tokens to recipients diff --git a/program-tests/utils/src/assert_claim.rs b/program-tests/utils/src/assert_claim.rs index 415189e118..016694fcbf 100644 --- a/program-tests/utils/src/assert_claim.rs +++ b/program-tests/utils/src/assert_claim.rs @@ -35,7 +35,6 @@ fn extract_pre_compression_mut( account_size: u64, current_slot: u64, account_lamports: u64, - base_lamports: u64, pubkey: &Pubkey, ) -> CompressionAssertData { let account_type = determine_account_type(data) @@ -52,8 +51,7 @@ fn extract_pre_compression_mut( let last_claimed_slot = u64::from(compression.last_claimed_slot); let compression_authority = Pubkey::from(compression.compression_authority); let rent_sponsor = Pubkey::from(compression.rent_sponsor); - let lamports_result = - compression.claim(account_size, current_slot, account_lamports, base_lamports); + let lamports_result = compression.claim(account_size, current_slot, account_lamports); let claim_failed = lamports_result.is_err(); let claimable_lamports = lamports_result.ok().flatten(); CompressionAssertData { @@ -71,8 +69,7 @@ fn extract_pre_compression_mut( let last_claimed_slot = u64::from(compression.last_claimed_slot); let compression_authority = Pubkey::from(compression.compression_authority); let rent_sponsor = Pubkey::from(compression.rent_sponsor); - let lamports_result = - compression.claim(account_size, current_slot, account_lamports, base_lamports); + let lamports_result = compression.claim(account_size, current_slot, account_lamports); let claim_failed = lamports_result.is_err(); let claimable_lamports = lamports_result.ok().flatten(); CompressionAssertData { @@ -137,10 +134,6 @@ pub async fn assert_claim( let account_size = pre_token_account.data.len() as u64; let account_lamports = pre_token_account.lamports; let current_slot = rpc.pre_context.as_ref().unwrap().get_sysvar::().slot; - let base_lamports = rpc - .get_minimum_balance_for_rent_exemption(account_size as usize) - .await - .unwrap(); // Extract compression info (handles both CToken and CMint) let pre_data = extract_pre_compression_mut( @@ -148,7 +141,6 @@ pub async fn assert_claim( account_size, current_slot, account_lamports, - base_lamports, token_account_pubkey, ); diff --git a/program-tests/utils/src/assert_close_token_account.rs b/program-tests/utils/src/assert_close_token_account.rs index 3926793af8..b51e494e6d 100644 --- a/program-tests/utils/src/assert_close_token_account.rs +++ b/program-tests/utils/src/assert_close_token_account.rs @@ -124,10 +124,7 @@ async fn assert_compressible_extension( // Calculate expected lamport distribution using the same function as the program let account_size = account_data_before_close.len() as u64; - let base_lamports = rpc - .get_minimum_balance_for_rent_exemption(account_size as usize) - .await - .unwrap(); + let base_lamports: u64 = compression.rent_exemption_paid.into(); // Create AccountRentState and use the method to calculate distribution let state = AccountRentState { diff --git a/program-tests/utils/src/assert_create_token_account.rs b/program-tests/utils/src/assert_create_token_account.rs index ad31b35957..b6b32cc96b 100644 --- a/program-tests/utils/src/assert_create_token_account.rs +++ b/program-tests/utils/src/assert_create_token_account.rs @@ -205,6 +205,8 @@ pub async fn assert_create_token_account_internal( info: CompressionInfo { config_account_version: 1, last_claimed_slot: current_slot, + rent_exemption_paid: rent_exemption as u32, + _reserved: 0, rent_config: RentConfig::default(), lamports_per_write: compressible_info.lamports_per_write.unwrap_or(0), compression_authority: compressible_info.compression_authority.to_bytes(), @@ -214,9 +216,9 @@ pub async fn assert_create_token_account_internal( }, }; - // Add Compressible extension to extensions list + // Add Compressible extension to extensions list (at beginning, matching program order) let mut all_extensions = final_extensions.unwrap_or_default(); - all_extensions.push(ExtensionStruct::Compressible(compressible_ext)); + all_extensions.insert(0, ExtensionStruct::Compressible(compressible_ext)); // Create expected compressible token account with embedded compression info let expected_token_account = CToken { diff --git a/program-tests/utils/src/assert_ctoken_burn.rs b/program-tests/utils/src/assert_ctoken_burn.rs index 3b8109c36a..34539ba9ec 100644 --- a/program-tests/utils/src/assert_ctoken_burn.rs +++ b/program-tests/utils/src/assert_ctoken_burn.rs @@ -133,22 +133,13 @@ pub async fn assert_ctoken_burn( } async fn calculate_expected_lamport_change( - rpc: &mut LightProgramTest, + _rpc: &mut LightProgramTest, compression: &light_compressible::compression_info::CompressionInfo, data_len: usize, current_slot: u64, current_lamports: u64, ) -> u64 { - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(data_len) - .await - .unwrap(); compression - .calculate_top_up_lamports( - data_len as u64, - current_slot, - current_lamports, - rent_exemption, - ) + .calculate_top_up_lamports(data_len as u64, current_slot, current_lamports) .unwrap() } diff --git a/program-tests/utils/src/assert_ctoken_mint_to.rs b/program-tests/utils/src/assert_ctoken_mint_to.rs index ea784c0a80..f2290f6cd6 100644 --- a/program-tests/utils/src/assert_ctoken_mint_to.rs +++ b/program-tests/utils/src/assert_ctoken_mint_to.rs @@ -133,22 +133,13 @@ pub async fn assert_ctoken_mint_to( } async fn calculate_expected_lamport_change( - rpc: &mut LightProgramTest, + _rpc: &mut LightProgramTest, compression: &light_compressible::compression_info::CompressionInfo, data_len: usize, current_slot: u64, current_lamports: u64, ) -> u64 { - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(data_len) - .await - .unwrap(); compression - .calculate_top_up_lamports( - data_len as u64, - current_slot, - current_lamports, - rent_exemption, - ) + .calculate_top_up_lamports(data_len as u64, current_slot, current_lamports) .unwrap() } diff --git a/program-tests/utils/src/assert_ctoken_transfer.rs b/program-tests/utils/src/assert_ctoken_transfer.rs index aa5bbb1f05..03e757f67b 100644 --- a/program-tests/utils/src/assert_ctoken_transfer.rs +++ b/program-tests/utils/src/assert_ctoken_transfer.rs @@ -74,17 +74,8 @@ pub async fn assert_compressible_for_account( name ); let current_slot = rpc.get_slot().await.unwrap(); - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(data_before.len()) - .await - .unwrap(); let top_up = compression_before - .calculate_top_up_lamports( - data_before.len() as u64, - current_slot, - lamports_before, - rent_exemption, - ) + .calculate_top_up_lamports(data_before.len() as u64, current_slot, lamports_before) .unwrap(); // Check if top-up was applied if top_up != 0 { diff --git a/program-tests/utils/src/assert_mint_action.rs b/program-tests/utils/src/assert_mint_action.rs index c7020749bb..92968374b6 100644 --- a/program-tests/utils/src/assert_mint_action.rs +++ b/program-tests/utils/src/assert_mint_action.rs @@ -268,13 +268,9 @@ pub async fn assert_mint_action( // Calculate expected top-up using embedded compression info let current_slot = rpc.get_slot().await.unwrap(); let account_size = pre_account.data.len() as u64; - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(pre_account.data.len()) - .await - .unwrap(); let expected_top_up = compression_info - .calculate_top_up_lamports(account_size, current_slot, pre_lamports, rent_exemption) + .calculate_top_up_lamports(account_size, current_slot, pre_lamports) .unwrap(); let actual_lamport_change = post_lamports diff --git a/program-tests/utils/src/mint_assert.rs b/program-tests/utils/src/mint_assert.rs index bd50457e7f..b73287f159 100644 --- a/program-tests/utils/src/mint_assert.rs +++ b/program-tests/utils/src/mint_assert.rs @@ -46,8 +46,9 @@ pub fn assert_compressed_mint_account( version: 3, mint: spl_mint_pda.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: light_compressible::compression_info::CompressionInfo::default(), extensions: expected_extensions, diff --git a/programs/compressed-token/anchor/src/freeze.rs b/programs/compressed-token/anchor/src/freeze.rs index b5a0e14689..be10899d25 100644 --- a/programs/compressed-token/anchor/src/freeze.rs +++ b/programs/compressed-token/anchor/src/freeze.rs @@ -13,7 +13,7 @@ use crate::{ process_transfer::{ add_data_hash_to_input_compressed_accounts_with_version, cpi_execute_compressed_transaction_transfer, - get_input_compressed_accounts_with_merkle_context_and_check_signer, + get_input_compressed_accounts_with_merkle_context_and_check_signer_for_freeze, InputTokenDataWithContext, }, FreezeInstruction, TokenData, @@ -75,7 +75,7 @@ pub fn process_freeze_or_thaw< ctx.remaining_accounts, version, )?; - // TODO: discuss + let proof = if inputs.proof == CompressedProof::default() { None } else { @@ -122,7 +122,9 @@ pub fn create_input_and_output_accounts_freeze_or_thaw< } let (mut compressed_input_accounts, input_token_data, _) = - get_input_compressed_accounts_with_merkle_context_and_check_signer::( + get_input_compressed_accounts_with_merkle_context_and_check_signer_for_freeze::< + FROZEN_INPUTS, + >( // The signer in this case is the freeze authority. The owner is not // required to sign for this instruction. Hence, we pass the owner // from a variable instead of an account to still reproduce value diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index 0081f30cdf..81f090543e 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -249,305 +249,345 @@ pub mod light_compressed_token { #[error_code] pub enum ErrorCode { #[msg("public keys and amounts must be of same length")] - PublicKeyAmountMissmatch, + PublicKeyAmountMissmatch, // 6000 #[msg("ComputeInputSumFailed")] - ComputeInputSumFailed, + ComputeInputSumFailed, // 6001 #[msg("ComputeOutputSumFailed")] - ComputeOutputSumFailed, + ComputeOutputSumFailed, // 6002 #[msg("ComputeCompressSumFailed")] - ComputeCompressSumFailed, + ComputeCompressSumFailed, // 6003 #[msg("ComputeDecompressSumFailed")] - ComputeDecompressSumFailed, + ComputeDecompressSumFailed, // 6004 #[msg("SumCheckFailed")] - SumCheckFailed, + SumCheckFailed, // 6005 #[msg("DecompressRecipientUndefinedForDecompress")] - DecompressRecipientUndefinedForDecompress, + DecompressRecipientUndefinedForDecompress, // 6006 #[msg("CompressedPdaUndefinedForDecompress")] - CompressedPdaUndefinedForDecompress, + CompressedPdaUndefinedForDecompress, // 6007 #[msg("DeCompressAmountUndefinedForDecompress")] - DeCompressAmountUndefinedForDecompress, + DeCompressAmountUndefinedForDecompress, // 6008 #[msg("CompressedPdaUndefinedForCompress")] - CompressedPdaUndefinedForCompress, + CompressedPdaUndefinedForCompress, // 6009 #[msg("DeCompressAmountUndefinedForCompress")] - DeCompressAmountUndefinedForCompress, + DeCompressAmountUndefinedForCompress, // 6010 #[msg("DelegateSignerCheckFailed")] - DelegateSignerCheckFailed, + DelegateSignerCheckFailed, // 6011 #[msg("Minted amount greater than u64::MAX")] - MintTooLarge, + MintTooLarge, // 6012 #[msg("SplTokenSupplyMismatch")] - SplTokenSupplyMismatch, + SplTokenSupplyMismatch, // 6013 #[msg("HeapMemoryCheckFailed")] - HeapMemoryCheckFailed, + HeapMemoryCheckFailed, // 6014 #[msg("The instruction is not callable")] - InstructionNotCallable, + InstructionNotCallable, // 6015 #[msg("ArithmeticUnderflow")] - ArithmeticUnderflow, + ArithmeticUnderflow, // 6016 #[msg("HashToFieldError")] - HashToFieldError, + HashToFieldError, // 6017 #[msg("Expected the authority to be also a mint authority")] - InvalidAuthorityMint, + InvalidAuthorityMint, // 6018 #[msg("Provided authority is not the freeze authority")] - InvalidFreezeAuthority, - InvalidDelegateIndex, - TokenPoolPdaUndefined, + InvalidFreezeAuthority, // 6019 + InvalidDelegateIndex, // 6020 + TokenPoolPdaUndefined, // 6021 #[msg("Compress or decompress recipient is the same account as the token pool pda.")] - IsTokenPoolPda, - InvalidTokenPoolPda, - NoInputTokenAccountsProvided, - NoInputsProvided, - MintHasNoFreezeAuthority, - MintWithInvalidExtension, + IsTokenPoolPda, // 6022 + InvalidTokenPoolPda, // 6023 + NoInputTokenAccountsProvided, // 6024 + NoInputsProvided, // 6025 + MintHasNoFreezeAuthority, // 6026 + MintWithInvalidExtension, // 6027 #[msg("The token account balance is less than the remaining amount.")] - InsufficientTokenAccountBalance, + InsufficientTokenAccountBalance, // 6028 #[msg("Max number of token pools reached.")] - InvalidTokenPoolBump, - FailedToDecompress, - FailedToBurnSplTokensFromTokenPool, - NoMatchingBumpFound, - NoAmount, - AmountsAndAmountProvided, + InvalidTokenPoolBump, // 6029 + FailedToDecompress, // 6030 + FailedToBurnSplTokensFromTokenPool, // 6031 + NoMatchingBumpFound, // 6032 + NoAmount, // 6033 + AmountsAndAmountProvided, // 6034 #[msg("Cpi context set and set first is not usable with burn, compression(transfer ix) or decompress(transfer).")] - CpiContextSetNotUsable, - MintIsNone, - InvalidMintPda, + CpiContextSetNotUsable, // 6035 + MintIsNone, // 6036 + InvalidMintPda, // 6037 #[msg("Sum inputs mint indices not in ascending order.")] - InputsOutOfOrder, + InputsOutOfOrder, // 6038 #[msg("Sum check, too many mints (max 5).")] - TooManyMints, - InvalidExtensionType, - InstructionDataExpectedDelegate, - ZeroCopyExpectedDelegate, + TooManyMints, // 6039 + InvalidExtensionType, // 6040 + InstructionDataExpectedDelegate, // 6041 + ZeroCopyExpectedDelegate, // 6042 #[msg("Unsupported TLV extension type - only CompressedOnly is currently implemented")] - UnsupportedTlvExtensionType, + UnsupportedTlvExtensionType, // 6043 // Mint Action specific errors #[msg("Mint action requires at least one action")] - MintActionNoActionsProvided, + MintActionNoActionsProvided, // 6044 #[msg("Missing mint signer account for SPL mint creation")] - MintActionMissingSplMintSigner, + MintActionMissingSplMintSigner, // 6045 #[msg("Missing system account configuration for mint action")] - MintActionMissingSystemAccount, + MintActionMissingSystemAccount, // 6046 #[msg("Invalid mint bump seed provided")] - MintActionInvalidMintBump, + MintActionInvalidMintBump, // 6047 #[msg("Missing mint account for decompressed mint operations")] - MintActionMissingMintAccount, + MintActionMissingMintAccount, // 6048 #[msg("Missing token pool account for decompressed mint operations")] - MintActionMissingTokenPoolAccount, + MintActionMissingTokenPoolAccount, // 6049 #[msg("Missing token program for SPL operations")] - MintActionMissingTokenProgram, + MintActionMissingTokenProgram, // 6050 #[msg("Mint account does not match expected mint")] - MintAccountMismatch, + MintAccountMismatch, // 6051 #[msg("Invalid or missing authority for compression operation")] - InvalidCompressAuthority, + InvalidCompressAuthority, // 6052 #[msg("Invalid queue index configuration")] - MintActionInvalidQueueIndex, + MintActionInvalidQueueIndex, // 6053 #[msg("Mint output serialization failed")] - MintActionSerializationFailed, + MintActionSerializationFailed, // 6054 #[msg("Proof required for mint action but not provided")] - MintActionProofMissing, + MintActionProofMissing, // 6055 #[msg("Unsupported mint action type")] - MintActionUnsupportedActionType, + MintActionUnsupportedActionType, // 6056 #[msg("Metadata operations require decompressed mints")] - MintActionMetadataNotDecompressed, + MintActionMetadataNotDecompressed, // 6057 #[msg("Missing metadata extension in mint")] - MintActionMissingMetadataExtension, + MintActionMissingMetadataExtension, // 6058 #[msg("Extension index out of bounds")] - MintActionInvalidExtensionIndex, + MintActionInvalidExtensionIndex, // 6059 #[msg("Invalid metadata value encoding")] - MintActionInvalidMetadataValue, + MintActionInvalidMetadataValue, // 6060 #[msg("Invalid metadata key encoding")] - MintActionInvalidMetadataKey, + MintActionInvalidMetadataKey, // 6061 #[msg("Extension at index is not a TokenMetadata extension")] - MintActionInvalidExtensionType, + MintActionInvalidExtensionType, // 6062 #[msg("Metadata key not found")] - MintActionMetadataKeyNotFound, + MintActionMetadataKeyNotFound, // 6063 #[msg("Missing executing system accounts for mint action")] - MintActionMissingExecutingAccounts, + MintActionMissingExecutingAccounts, // 6064 #[msg("Invalid mint authority for mint action")] - MintActionInvalidMintAuthority, + MintActionInvalidMintAuthority, // 6065 #[msg("Invalid mint PDA derivation in mint action")] - MintActionInvalidMintPda, + MintActionInvalidMintPda, // 6066 #[msg("Missing system accounts for queue index calculation")] - MintActionMissingSystemAccountsForQueue, + MintActionMissingSystemAccountsForQueue, // 6067 #[msg("Account data serialization failed in mint output")] - MintActionOutputSerializationFailed, + MintActionOutputSerializationFailed, // 6068 #[msg("Mint amount too large, would cause overflow")] - MintActionAmountTooLarge, + MintActionAmountTooLarge, // 6069 #[msg("Initial supply must be 0 for new mint creation")] - MintActionInvalidInitialSupply, + MintActionInvalidInitialSupply, // 6070 #[msg("Mint version not supported")] - MintActionUnsupportedVersion, + MintActionUnsupportedVersion, // 6071 #[msg("New mint must start as compressed")] - MintActionInvalidCompressionState, - MintActionUnsupportedOperation, + MintActionInvalidCompressionState, // 6072 + MintActionUnsupportedOperation, // 6073 // Close account specific errors #[msg("Cannot close account with non-zero token balance")] - NonNativeHasBalance, + NonNativeHasBalance, // 6074 #[msg("Authority signature does not match expected owner")] - OwnerMismatch, + OwnerMismatch, // 6075 #[msg("Account is frozen and cannot perform this operation")] - AccountFrozen, + AccountFrozen, // 6076 // Account creation specific errors #[msg("Account size insufficient for token account")] - InsufficientAccountSize, + InsufficientAccountSize, // 6077 #[msg("Account already initialized")] - AlreadyInitialized, + AlreadyInitialized, // 6078 #[msg("Extension instruction data invalid")] - InvalidExtensionInstructionData, + InvalidExtensionInstructionData, // 6079 #[msg("Lamports amount too large")] - MintActionLamportsAmountTooLarge, + MintActionLamportsAmountTooLarge, // 6080 #[msg("Invalid token program provided")] - InvalidTokenProgram, + InvalidTokenProgram, // 6081 // Transfer2 specific errors #[msg("Cannot access system accounts for CPI context write operations")] - Transfer2CpiContextWriteInvalidAccess, + Transfer2CpiContextWriteInvalidAccess, // 6082 #[msg("SOL pool operations not supported with CPI context write")] - Transfer2CpiContextWriteWithSolPool, + Transfer2CpiContextWriteWithSolPool, // 6083 #[msg("Change account must not contain token data")] - Transfer2InvalidChangeAccountData, + Transfer2InvalidChangeAccountData, // 6084 #[msg("Cpi context expected but not provided.")] - CpiContextExpected, + CpiContextExpected, // 6085 #[msg("CPI accounts slice exceeds provided account infos")] - CpiAccountsSliceOutOfBounds, + CpiAccountsSliceOutOfBounds, // 6086 // CompressAndClose specific errors #[msg("CompressAndClose requires a destination account for rent lamports")] - CompressAndCloseDestinationMissing, + CompressAndCloseDestinationMissing, // 6087 #[msg("CompressAndClose requires an authority account")] - CompressAndCloseAuthorityMissing, + CompressAndCloseAuthorityMissing, // 6088 #[msg("CompressAndClose: Compressed token owner does not match expected owner")] - CompressAndCloseInvalidOwner, + CompressAndCloseInvalidOwner, // 6089 #[msg("CompressAndClose: Compression amount must match the full token balance")] - CompressAndCloseAmountMismatch, + CompressAndCloseAmountMismatch, // 6090 #[msg("CompressAndClose: Token account balance must match compressed output amount")] - CompressAndCloseBalanceMismatch, + CompressAndCloseBalanceMismatch, // 6091 #[msg("CompressAndClose: Compressed token must not have a delegate")] - CompressAndCloseDelegateNotAllowed, + CompressAndCloseDelegateNotAllowed, // 6092 #[msg("CompressAndClose: Invalid compressed token version")] - CompressAndCloseInvalidVersion, + CompressAndCloseInvalidVersion, // 6093 #[msg("InvalidAddressTree")] - InvalidAddressTree, - #[msg("Too many compression transfers. Maximum 40 transfers allowed per instruction")] - TooManyCompressionTransfers, + InvalidAddressTree, // 6094 + #[msg("Too many compression transfers. Maximum 32 transfers allowed per instruction")] + TooManyCompressionTransfers, // 6095 #[msg("Missing fee payer for compressions-only operation")] - CompressionsOnlyMissingFeePayer, + CompressionsOnlyMissingFeePayer, // 6096 #[msg("Missing CPI authority PDA for compressions-only operation")] - CompressionsOnlyMissingCpiAuthority, + CompressionsOnlyMissingCpiAuthority, // 6097 #[msg("Cpi authority pda expected but not provided.")] - ExpectedCpiAuthority, + ExpectedCpiAuthority, // 6098 #[msg("InvalidRentSponsor")] - InvalidRentSponsor, - TooManyMintToRecipients, + InvalidRentSponsor, // 6099 + TooManyMintToRecipients, // 6100 #[msg("Prefunding for exactly 1 epoch is not allowed due to epoch boundary timing risk. Use 0 or 2+ epochs.")] - OneEpochPrefundingNotAllowed, + OneEpochPrefundingNotAllowed, // 6101 #[msg("Duplicate mint index detected in inputs, outputs, or compressions")] - DuplicateMint, + DuplicateMint, // 6102 #[msg("Invalid compressed mint address derivation")] - MintActionInvalidCompressedMintAddress, + MintActionInvalidCompressedMintAddress, // 6103 #[msg("Invalid CPI context for create mint operation")] - MintActionInvalidCpiContextForCreateMint, + MintActionInvalidCpiContextForCreateMint, // 6104 #[msg("Invalid address tree pubkey in CPI context")] - MintActionInvalidCpiContextAddressTreePubkey, + MintActionInvalidCpiContextAddressTreePubkey, // 6105 #[msg("CompressAndClose: Cannot use the same compressed output account for multiple closures")] - CompressAndCloseDuplicateOutput, + CompressAndCloseDuplicateOutput, // 6106 #[msg( "CompressAndClose by compression authority requires compressed token account in outputs" )] - CompressAndCloseOutputMissing, + CompressAndCloseOutputMissing, // 6107 // CMint (decompressed compressed mint) specific errors #[msg("Missing mint signer account for mint action")] - MintActionMissingMintSigner, + MintActionMissingMintSigner, // 6108 #[msg("Missing CMint account for decompress mint action")] - MintActionMissingCMintAccount, + MintActionMissingCMintAccount, // 6109 #[msg("CMint account already exists")] - CMintAlreadyExists, + CMintAlreadyExists, // 6110 #[msg("Invalid CMint account owner")] - InvalidCMintOwner, + InvalidCMintOwner, // 6111 #[msg("Failed to deserialize CMint account data")] - CMintDeserializationFailed, + CMintDeserializationFailed, // 6112 #[msg("Failed to resize CMint account")] - CMintResizeFailed, + CMintResizeFailed, // 6113 // CMint Compressibility errors #[msg("Invalid rent payment - must be >= 2 (CMint is always compressible)")] - InvalidRentPayment, + InvalidRentPayment, // 6114 #[msg("Missing compressible config account for CMint")] - MissingCompressibleConfig, + MissingCompressibleConfig, // 6115 #[msg("Missing rent sponsor account for CMint")] - MissingRentSponsor, + MissingRentSponsor, // 6116 #[msg("Rent payment exceeds max funded epochs")] - RentPaymentExceedsMax, + RentPaymentExceedsMax, // 6117 #[msg("Write top-up exceeds maximum allowed")] - WriteTopUpExceedsMaximum, + WriteTopUpExceedsMaximum, // 6118 #[msg("Failed to calculate CMint top-up amount")] - CMintTopUpCalculationFailed, + CMintTopUpCalculationFailed, // 6119 // CompressAndCloseCMint specific errors #[msg("CMint is not decompressed")] - CMintNotDecompressed, + CMintNotDecompressed, // 6120 #[msg("CMint is missing Compressible extension")] - CMintMissingCompressibleExtension, + CMintMissingCompressibleExtension, // 6121 #[msg("CMint is not compressible (rent not expired)")] - CMintNotCompressible, + CMintNotCompressible, // 6122 #[msg("Cannot combine DecompressMint and CompressAndCloseCMint in same instruction")] - CannotDecompressAndCloseInSameInstruction, + CannotDecompressAndCloseInSameInstruction, // 6123 #[msg("CMint account does not match compressed_mint.metadata.mint")] - InvalidCMintAccount, + InvalidCMintAccount, // 6124 #[msg("Mint data required in instruction when not decompressed")] - MintDataRequired, + MintDataRequired, // 6125 // Extension validation errors #[msg("Invalid mint account data")] - InvalidMint, + InvalidMint, // 6126 #[msg("Token operations blocked - mint is paused")] - MintPaused, + MintPaused, // 6127 #[msg("Mint account required for transfer when account has PausableAccount extension")] - MintRequiredForTransfer, + MintRequiredForTransfer, // 6128 #[msg("Non-zero transfer fees are not supported")] - NonZeroTransferFeeNotSupported, + NonZeroTransferFeeNotSupported, // 6129 #[msg("Transfer hooks with non-nil program_id are not supported")] - TransferHookNotSupported, + TransferHookNotSupported, // 6130 #[msg("Mint has extensions that require compression_only mode")] - CompressionOnlyRequired, + CompressionOnlyRequired, // 6131 #[msg("CompressAndClose: Compressed token mint does not match source token account mint")] - CompressAndCloseInvalidMint, + CompressAndCloseInvalidMint, // 6132 #[msg("CompressAndClose: Missing required CompressedOnly extension in output TLV")] - CompressAndCloseMissingCompressedOnlyExtension, + CompressAndCloseMissingCompressedOnlyExtension, // 6133 #[msg("CompressAndClose: CompressedOnly mint_account_index must be 0")] - CompressAndCloseInvalidMintAccountIndex, + CompressAndCloseInvalidMintAccountIndex, // 6134 #[msg( "CompressAndClose: Delegated amount mismatch between ctoken and CompressedOnly extension" )] - CompressAndCloseDelegatedAmountMismatch, + CompressAndCloseDelegatedAmountMismatch, // 6135 #[msg("CompressAndClose: Delegate mismatch between ctoken and compressed token output")] - CompressAndCloseInvalidDelegate, + CompressAndCloseInvalidDelegate, // 6136 #[msg("CompressAndClose: Withheld transfer fee mismatch")] - CompressAndCloseWithheldFeeMismatch, + CompressAndCloseWithheldFeeMismatch, // 6137 #[msg("CompressAndClose: Frozen state mismatch")] - CompressAndCloseFrozenMismatch, + CompressAndCloseFrozenMismatch, // 6138 #[msg("TLV extensions require version 3 (ShaFlat)")] - TlvRequiresVersion3, + TlvRequiresVersion3, // 6139 #[msg("CToken account has extensions that cannot be compressed. Only Compressible extension or no extensions allowed.")] - CTokenHasDisallowedExtensions, + CTokenHasDisallowedExtensions, // 6140 #[msg("CompressAndClose: rent_sponsor_is_signer flag does not match actual signer")] - RentSponsorIsSignerMismatch, + RentSponsorIsSignerMismatch, // 6141 #[msg("Mint has restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook, DefaultAccountState) must not create compressed token accounts.")] - MintHasRestrictedExtensions, + MintHasRestrictedExtensions, // 6142 #[msg("Decompress: CToken delegate does not match input compressed account delegate")] - DecompressDelegateMismatch, + DecompressDelegateMismatch, // 6143 #[msg("Mint cache capacity exceeded (max 5 unique mints)")] - MintCacheCapacityExceeded, + MintCacheCapacityExceeded, // 6144 #[msg("in_lamports field is not yet implemented")] - InLamportsUnimplemented, + InLamportsUnimplemented, // 6145 #[msg("out_lamports field is not yet implemented")] - OutLamportsUnimplemented, + OutLamportsUnimplemented, // 6146 #[msg("Mints with restricted extensions require compressible accounts")] - CompressibleRequired, + CompressibleRequired, // 6147 #[msg("CMint account not found")] - CMintNotFound, + CMintNotFound, // 6148 #[msg("CompressedOnly inputs must decompress to CToken account, not SPL token account")] - CompressedOnlyRequiresCTokenDecompress, + CompressedOnlyRequiresCTokenDecompress, // 6149 #[msg("Invalid token data version")] - InvalidTokenDataVersion, + InvalidTokenDataVersion, // 6150 #[msg("compression_only can only be set for mints with restricted extensions")] - CompressionOnlyNotAllowed, + CompressionOnlyNotAllowed, // 6151 #[msg("Associated token accounts must have compression_only set")] - AtaRequiresCompressionOnly, + AtaRequiresCompressionOnly, // 6152 + // ========================================================================= + // SPL Token compatible errors (mapped from pinocchio token processor) + // These mirror SPL Token error codes for consistent error reporting + // ========================================================================= + #[msg("Lamport balance below rent-exempt threshold")] + NotRentExempt, // 6153 (SPL Token code 0) + #[msg("Insufficient funds for the operation")] + InsufficientFunds, // 6154 (SPL Token code 1) + #[msg("Account not associated with this Mint")] + MintMismatch, // 6155 (SPL Token code 3) + #[msg("This token's supply is fixed and new tokens cannot be minted")] + FixedSupply, // 6156 (SPL Token code 5) + #[msg("Account already in use")] + AlreadyInUse, // 6157 (SPL Token code 6) + #[msg("Invalid number of provided signers")] + InvalidNumberOfProvidedSigners, // 6158 (SPL Token code 7) + #[msg("Invalid number of required signers")] + InvalidNumberOfRequiredSigners, // 6159 (SPL Token code 8) + #[msg("State is uninitialized")] + UninitializedState, // 6160 (SPL Token code 9) + #[msg("Instruction does not support native tokens")] + NativeNotSupported, // 6161 (SPL Token code 10) + #[msg("Invalid instruction")] + InvalidInstruction, // 6162 (SPL Token code 12) + #[msg("State is invalid for requested operation")] + InvalidState, // 6163 (SPL Token code 13) + #[msg("Operation overflowed")] + Overflow, // 6164 (SPL Token code 14) + #[msg("Account does not support specified authority type")] + AuthorityTypeNotSupported, // 6165 (SPL Token code 15) + #[msg("Mint decimals mismatch between the client and mint")] + MintDecimalsMismatch, // 6166 (SPL Token code 18) + #[msg("Failed to calculate rent exemption for CMint")] + CMintRentExemptionFailed, // 6167 + #[msg("CompressAndClose: is_ata mismatch between CompressibleExtension and CompressedOnly extension")] + CompressAndCloseIsAtaMismatch, // 6168 + #[msg("CompressAndCloseCMint must be the only action in the instruction")] + CompressAndCloseCMintMustBeOnlyAction, // 6169 + #[msg("Idempotent early exit - not a real error, used to skip CPI")] + IdempotentEarlyExit, // 6170 } /// Anchor error code offset - error codes start at 6000 @@ -559,6 +599,11 @@ impl From for ProgramError { } } +/// Checks if an error is the IdempotentEarlyExit error (used to skip CPI) +pub fn is_idempotent_early_exit(err: &ProgramError) -> bool { + matches!(err, ProgramError::Custom(code) if *code == ERROR_CODE_OFFSET + ErrorCode::IdempotentEarlyExit as u32) +} + /// Checks if CPI context usage is valid for the current instruction /// Throws an error if cpi_context is Some and (set_context OR first_set_context is true) pub fn check_cpi_context(cpi_context: &Option) -> Result<()> { diff --git a/programs/compressed-token/anchor/src/process_transfer.rs b/programs/compressed-token/anchor/src/process_transfer.rs index bb61286ddc..5980bda72a 100644 --- a/programs/compressed-token/anchor/src/process_transfer.rs +++ b/programs/compressed-token/anchor/src/process_transfer.rs @@ -599,7 +599,10 @@ pub struct CompressedTokenInstructionDataTransfer { pub with_transaction_hash: bool, } -pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer( +fn get_input_compressed_accounts_with_merkle_context_and_check_signer_inner< + const IS_FROZEN: bool, + const CHECK_TLV: bool, +>( signer: &Pubkey, signer_is_delegate: &Option, remaining_accounts: &[AccountInfo<'_>], @@ -644,7 +647,7 @@ pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer( + signer: &Pubkey, + signer_is_delegate: &Option, + remaining_accounts: &[AccountInfo<'_>], + input_token_data_with_context: &[InputTokenDataWithContext], + mint: &Pubkey, +) -> Result<(Vec, Vec, u64)> { + get_input_compressed_accounts_with_merkle_context_and_check_signer_inner::( + signer, + signer_is_delegate, + remaining_accounts, + input_token_data_with_context, + mint, + ) +} + +/// Get input compressed accounts - for all other instructions (checks TLV) +pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer( + signer: &Pubkey, + signer_is_delegate: &Option, + remaining_accounts: &[AccountInfo<'_>], + input_token_data_with_context: &[InputTokenDataWithContext], + mint: &Pubkey, +) -> Result<(Vec, Vec, u64)> { + get_input_compressed_accounts_with_merkle_context_and_check_signer_inner::( + signer, + signer_is_delegate, + remaining_accounts, + input_token_data_with_context, + mint, + ) +} + #[derive(Clone, Debug, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)] pub struct PackedTokenTransferOutputData { pub owner: Pubkey, diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 2c40ce208a..49142f7717 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -45,68 +45,65 @@ Every instruction description must include the sections: ## Instruction Index ### Account Management -1. **Create CToken Account** - [`docs/instructions/CREATE_TOKEN_ACCOUNT.md`](docs/instructions/CREATE_TOKEN_ACCOUNT.md) +1. **Create CToken Account** - [`docs/ctoken/CREATE.md`](docs/ctoken/CREATE.md) - Create regular token account (discriminator: 18, enum: `InstructionType::CreateTokenAccount`) - Create associated token account (discriminator: 100, enum: `InstructionType::CreateAssociatedCTokenAccount`) - Create associated token account idempotent (discriminator: 102, enum: `InstructionType::CreateAssociatedTokenAccountIdempotent`) - **Config validation:** Requires ACTIVE config only -2. **Close Token Account** - [`docs/instructions/CLOSE_TOKEN_ACCOUNT.md`](docs/instructions/CLOSE_TOKEN_ACCOUNT.md) (discriminator: 9, enum: `InstructionType::CloseTokenAccount`) +2. **Close Token Account** - [`docs/ctoken/CLOSE.md`](docs/ctoken/CLOSE.md) (discriminator: 9, enum: `InstructionType::CloseTokenAccount`) - Close decompressed token accounts - Returns rent exemption to rent recipient if compressible - Returns remaining lamports to destination account ### Rent Management -3. **Claim** - [`docs/instructions/CLAIM.md`](docs/instructions/CLAIM.md) +3. **Claim** - [`docs/compressible/CLAIM.md`](docs/compressible/CLAIM.md) - Claims rent from expired compressible accounts (discriminator: 104, enum: `InstructionType::Claim`) - **Config validation:** Not inactive (active or deprecated OK) -4. **Withdraw Funding Pool** - [`docs/instructions/WITHDRAW_FUNDING_POOL.md`](docs/instructions/WITHDRAW_FUNDING_POOL.md) +4. **Withdraw Funding Pool** - [`docs/compressible/WITHDRAW_FUNDING_POOL.md`](docs/compressible/WITHDRAW_FUNDING_POOL.md) - Withdraws funds from rent recipient pool (discriminator: 105, enum: `InstructionType::WithdrawFundingPool`) - **Config validation:** Not inactive (active or deprecated OK) ### Token Operations -5. **Transfer2** - [`docs/instructions/TRANSFER2.md`](docs/instructions/TRANSFER2.md) +5. **Transfer2** - [`docs/compressed_token/TRANSFER2.md`](docs/compressed_token/TRANSFER2.md) - Batch transfer instruction for compressed/decompressed operations (discriminator: 101, enum: `InstructionType::Transfer2`) - Supports Compress, Decompress, CompressAndClose operations - Multi-mint support with sum checks -6. **MintAction** - [`docs/instructions/MINT_ACTION.md`](docs/instructions/MINT_ACTION.md) +6. **MintAction** - [`docs/compressed_token/MINT_ACTION.md`](docs/compressed_token/MINT_ACTION.md) - Batch instruction for compressed mint management and mint operations (discriminator: 103, enum: `InstructionType::MintAction`) - - Supports 9 action types: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, CreateSplMint, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey + - Supports 10 action types: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey, DecompressMint, CompressAndCloseCMint - Handles both compressed and decompressed token minting -7. **CTokenTransfer** - [`docs/instructions/CTOKEN_TRANSFER.md`](docs/instructions/CTOKEN_TRANSFER.md) +7. **CTokenTransfer** - [`docs/ctoken/TRANSFER.md`](docs/ctoken/TRANSFER.md) - Transfer between decompressed accounts (discriminator: 3, enum: `InstructionType::CTokenTransfer`) -8. **CTokenTransferChecked** - [`docs/instructions/CTOKEN_TRANSFER_CHECKED.md`](docs/instructions/CTOKEN_TRANSFER_CHECKED.md) +8. **CTokenTransferChecked** - [`docs/ctoken/TRANSFER_CHECKED.md`](docs/ctoken/TRANSFER_CHECKED.md) - Transfer with decimals validation (discriminator: 12, enum: `InstructionType::CTokenTransferChecked`) -9. **CTokenApprove** - [`docs/instructions/CTOKEN_APPROVE.md`](docs/instructions/CTOKEN_APPROVE.md) +9. **CTokenApprove** - [`docs/ctoken/APPROVE.md`](docs/ctoken/APPROVE.md) - Approve delegate on decompressed CToken account (discriminator: 4, enum: `InstructionType::CTokenApprove`) -10. **CTokenRevoke** - [`docs/instructions/CTOKEN_REVOKE.md`](docs/instructions/CTOKEN_REVOKE.md) +10. **CTokenRevoke** - [`docs/ctoken/REVOKE.md`](docs/ctoken/REVOKE.md) - Revoke delegate on decompressed CToken account (discriminator: 5, enum: `InstructionType::CTokenRevoke`) -11. **CTokenMintTo** - [`docs/instructions/CTOKEN_MINT_TO.md`](docs/instructions/CTOKEN_MINT_TO.md) +11. **CTokenMintTo** - [`docs/ctoken/MINT_TO.md`](docs/ctoken/MINT_TO.md) - Mint tokens to decompressed CToken account (discriminator: 7, enum: `InstructionType::CTokenMintTo`) -12. **CTokenBurn** - [`docs/instructions/CTOKEN_BURN.md`](docs/instructions/CTOKEN_BURN.md) +12. **CTokenBurn** - [`docs/ctoken/BURN.md`](docs/ctoken/BURN.md) - Burn tokens from decompressed CToken account (discriminator: 8, enum: `InstructionType::CTokenBurn`) -13. **CTokenFreezeAccount** - [`docs/instructions/CTOKEN_FREEZE_ACCOUNT.md`](docs/instructions/CTOKEN_FREEZE_ACCOUNT.md) +13. **CTokenFreezeAccount** - [`docs/ctoken/FREEZE_ACCOUNT.md`](docs/ctoken/FREEZE_ACCOUNT.md) - Freeze decompressed CToken account (discriminator: 10, enum: `InstructionType::CTokenFreezeAccount`) -14. **CTokenThawAccount** - [`docs/instructions/CTOKEN_THAW_ACCOUNT.md`](docs/instructions/CTOKEN_THAW_ACCOUNT.md) +14. **CTokenThawAccount** - [`docs/ctoken/THAW_ACCOUNT.md`](docs/ctoken/THAW_ACCOUNT.md) - Thaw frozen decompressed CToken account (discriminator: 11, enum: `InstructionType::CTokenThawAccount`) -15. **CTokenApproveChecked** - [`docs/instructions/CTOKEN_APPROVE_CHECKED.md`](docs/instructions/CTOKEN_APPROVE_CHECKED.md) - - Approve delegate with decimals validation (discriminator: 13, enum: `InstructionType::CTokenApproveChecked`) - -16. **CTokenMintToChecked** - [`docs/instructions/CTOKEN_MINT_TO_CHECKED.md`](docs/instructions/CTOKEN_MINT_TO_CHECKED.md) +15. **CTokenMintToChecked** - [`docs/ctoken/MINT_TO_CHECKED.md`](docs/ctoken/MINT_TO_CHECKED.md) - Mint tokens with decimals validation (discriminator: 14, enum: `InstructionType::CTokenMintToChecked`) -17. **CTokenBurnChecked** - [`docs/instructions/CTOKEN_BURN_CHECKED.md`](docs/instructions/CTOKEN_BURN_CHECKED.md) +16. **CTokenBurnChecked** - [`docs/ctoken/BURN_CHECKED.md`](docs/ctoken/BURN_CHECKED.md) - Burn tokens with decimals validation (discriminator: 15, enum: `InstructionType::CTokenBurnChecked`) ## Config State Requirements Summary @@ -115,29 +112,59 @@ Every instruction description must include the sections: # Source Code Structure (`src/`) -## Core Instructions -- **`create_token_account.rs`** - Create regular ctoken accounts with optional compressible extension -- **`create_associated_token_account.rs`** - Create deterministic ATA accounts -- **`close_token_account/`** - Close ctoken accounts, handle rent distribution -- **`transfer/`** - SPL-compatible transfers between decompressed accounts - - `default.rs` - CTokenTransfer (discriminator: 3) - - `checked.rs` - CTokenTransferChecked (discriminator: 12) - - `shared.rs` - Common transfer utilities - -## Token Operations +``` +src/ +├── compressed_token/ # Operations on compressed accounts (in Merkle trees) +│ ├── mint_action/ # MintAction instruction (103) +│ └── transfer2/ # Transfer2 instruction (101) +├── compressible/ # Rent management +│ ├── claim.rs # Claim instruction (104) +│ └── withdraw_funding_pool.rs # WithdrawFundingPool instruction (105) +├── ctoken/ # Operations on CToken Solana accounts (decompressed) +│ ├── approve_revoke.rs # CTokenApprove (4), CTokenRevoke (5) +│ ├── burn.rs # CTokenBurn (8), CTokenBurnChecked (15) +│ ├── close/ # CloseTokenAccount instruction (9) +│ ├── create.rs # CreateTokenAccount instruction (18) +│ ├── create_ata.rs # CreateAssociatedCTokenAccount (100, 102) +│ ├── freeze_thaw.rs # CTokenFreezeAccount (10), CTokenThawAccount (11) +│ ├── mint_to.rs # CTokenMintTo (7), CTokenMintToChecked (14) +│ └── transfer/ # CTokenTransfer (3), CTokenTransferChecked (12) +├── extensions/ # Extension handling +├── shared/ # Common utilities +├── convert_account_infos.rs +└── lib.rs # Entry point and instruction dispatch +``` + +## Compressed Token Operations (`compressed_token/`) +Operations on compressed accounts stored in Merkle trees. + +- **`mint_action/`** - MintAction instruction for compressed mint management + - `processor.rs` - Main instruction processor + - `accounts.rs` - Account validation and parsing + - `actions/` - Individual action handlers (create_mint, mint_to, decompress_mint, etc.) - **`transfer2/`** - Unified transfer instruction supporting multiple modes - `compression/` - Compress & decompress functionality - `ctoken/` - CToken-specific compression (compress_and_close.rs, decompress.rs, etc.) - `spl.rs` - SPL token compression - `processor.rs` - Main instruction processor - `accounts.rs` - Account validation and parsing -- **`mint_action/`** - Mint tokens to compressed/decompressed accounts -- **`ctoken_approve_revoke.rs`** - CTokenApprove (4), CTokenRevoke (5), CTokenApproveChecked (13) -- **`ctoken_mint_to.rs`** - CTokenMintTo (7), CTokenMintToChecked (14) -- **`ctoken_burn.rs`** - CTokenBurn (8), CTokenBurnChecked (15) -- **`ctoken_freeze_thaw.rs`** - CTokenFreezeAccount (10), CTokenThawAccount (11) -## Rent Management +## CToken Operations (`ctoken/`) +Operations on CToken Solana accounts (decompressed compressed tokens). + +- **`create.rs`** - Create regular ctoken accounts with optional compressible extension +- **`create_ata.rs`** - Create deterministic ATA accounts +- **`close/`** - Close ctoken accounts, handle rent distribution +- **`transfer/`** - SPL-compatible transfers between decompressed accounts + - `default.rs` - CTokenTransfer (discriminator: 3) + - `checked.rs` - CTokenTransferChecked (discriminator: 12) + - `shared.rs` - Common transfer utilities +- **`approve_revoke.rs`** - CTokenApprove (4), CTokenRevoke (5) +- **`mint_to.rs`** - CTokenMintTo (7), CTokenMintToChecked (14) +- **`burn.rs`** - CTokenBurn (8), CTokenBurnChecked (15) +- **`freeze_thaw.rs`** - CTokenFreezeAccount (10), CTokenThawAccount (11) + +## Rent Management (`compressible/`) - **`claim.rs`** - Claim rent from expired compressible accounts - **`withdraw_funding_pool.rs`** - Withdraw funds from rent recipient pool @@ -176,6 +203,55 @@ Custom error codes are defined in **`programs/compressed-token/anchor/src/lib.rs - Errors are returned as `ProgramError::Custom(error_code as u32)` on-chain - CToken-specific errors are also defined in **`program-libs/ctoken-interface/src/error.rs`** (`CTokenError` enum) +### Error Conversion Functions (`shared/convert_program_error.rs`) + +Two functions exist for converting pinocchio errors to anchor ProgramError: + +| Function | Use Case | Error Mapping | +|----------|----------|---------------| +| `convert_pinocchio_token_error` | SPL Token operations via pinocchio_token_program processors | Maps SPL Token error codes (0-18) to named ErrorCode variants | +| `convert_token_error` | Functions returning TokenError directly (e.g., unpack_amount_and_decimals) | Maps SPL Token error codes (0-18) to named ErrorCode variants | +| `convert_program_error` | System program, data access, lamport transfers | Adds +6000 offset to raw error code | + +**When to use each:** + +```rust +// SPL Token operations - use convert_pinocchio_token_error +process_transfer(accounts, data).map_err(convert_pinocchio_token_error)?; +process_burn(accounts, data).map_err(convert_pinocchio_token_error)?; +process_mint_to(accounts, data).map_err(convert_pinocchio_token_error)?; + +// System/internal operations - use convert_program_error +transfer_lamports_via_cpi(...).map_err(convert_program_error)?; +account.try_borrow_mut_data().map_err(convert_program_error)?; + +// ErrorCode variants - use ProgramError::from directly +sum_check_multi_mint(...).map_err(ProgramError::from)?; +validate_mint_uniqueness(...).map_err(ProgramError::from)?; +``` + +**SPL Token Error Code Mapping:** +| SPL Code | ErrorCode Variant | Description | +|----------|-------------------|-------------| +| 0 | NotRentExempt | Lamport balance below rent-exempt threshold | +| 1 | InsufficientFunds | Insufficient funds for the operation | +| 2 | InvalidMint | Invalid mint account | +| 3 | MintMismatch | Account not associated with this Mint | +| 4 | OwnerMismatch | Owner does not match | +| 5 | FixedSupply | Token supply is fixed | +| 6 | AlreadyInUse | Account already in use | +| 7-8 | InvalidNumberOf*Signers | Signer count mismatch | +| 9 | UninitializedState | State is uninitialized | +| 10 | NativeNotSupported | Native tokens not supported | +| 11 | NonNativeHasBalance | Non-native account has balance | +| 12 | InvalidInstruction | Invalid instruction | +| 13 | InvalidState | State is invalid | +| 14 | Overflow | Operation overflowed | +| 15 | AuthorityTypeNotSupported | Authority type not supported | +| 16 | MintHasNoFreezeAuthority | Mint cannot freeze | +| 17 | AccountFrozen | Account is frozen | +| 18 | MintDecimalsMismatch | Decimals mismatch | + ## SDKs (`sdk-libs/`) - **`ctoken-sdk/`** - SDK for programs to interact with compressed tokens (CPIs, instruction builders) - **`token-client/`** - Client SDK for Rust applications (test helpers, transaction builders) diff --git a/programs/compressed-token/program/docs/ACCOUNTS.md b/programs/compressed-token/program/docs/ACCOUNTS.md index 5f0a38b637..0bf03ba659 100644 --- a/programs/compressed-token/program/docs/ACCOUNTS.md +++ b/programs/compressed-token/program/docs/ACCOUNTS.md @@ -19,12 +19,24 @@ path: `program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs` crate: `light-ctoken-interface` - **associated instructions** - 1. `CreateTokenAccount` `18` - 2. `CloseTokenAccount` `9` - 3. `CTokenTransfer` `3` - 4. `Transfer2` `101` - `Decompress`, `DecompressAndClose` - 5. `MintAction` `103` - `MintToCToken` - 6. `Claim` `104` + 1. `CTokenTransfer` `3` + 2. `CTokenApprove` `4` + 3. `CTokenRevoke` `5` + 4. `CTokenMintTo` `7` + 5. `CTokenBurn` `8` + 6. `CloseTokenAccount` `9` + 7. `CTokenFreezeAccount` `10` + 8. `CTokenThawAccount` `11` + 9. `CTokenTransferChecked` `12` + 10. `CTokenMintToChecked` `14` + 11. `CTokenBurnChecked` `15` + 12. `CreateTokenAccount` `18` + 13. `CreateAssociatedCTokenAccount` `100` + 14. `Transfer2` `101` - `Decompress`, `DecompressAndClose` + 15. `CreateAssociatedTokenAccountIdempotent` `102` + 16. `MintAction` `103` - `MintToCToken` + 17. `Claim` `104` + 18. `WithdrawFundingPool` `105` - **serialization example** borsh and zero copy deserialization deserialize the compressible extension, spl serialization only deserialize the base token data. zero copy: (always use in programs) @@ -82,13 +94,39 @@ ### Compressed Mint ## Extensions -The compressed token program supports 2 extensions. +The compressed token program supports multiple extensions defined in `program-libs/ctoken-interface/src/state/extensions/`. -### TokenMetadata -- Mint extension, compatible with TokenMetada extension of Token2022. +### Mint Extensions + +#### TokenMetadata +- Mint extension, compatible with TokenMetadata extension of Token2022. - Only available in compressed mints. +- Path: `program-libs/ctoken-interface/src/state/extensions/token_metadata.rs` + +### Token Account Extensions -### Compressible +#### Compressible - Token account extension, Token2022 does not have an equivalent extension. - Only available in ctoken solana accounts (decompressed ctokens), not in compressed token accounts. -- +- Stores compression info (rent sponsor, config, creation slot, etc.) for rent management. +- Path: `program-libs/ctoken-interface/src/state/extensions/compressible.rs` + +#### CompressedOnly +- Marker extension indicating the account can only exist in compressed form. +- Path: `program-libs/ctoken-interface/src/state/extensions/compressed_only.rs` + +#### Pausable +- Token account extension compatible with Token2022 PausableAccount extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/pausable.rs` + +#### PermanentDelegate +- Token account extension compatible with Token2022 PermanentDelegate extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/permanent_delegate.rs` + +#### TransferFee +- Token account extension compatible with Token2022 TransferFee extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/transfer_fee.rs` + +#### TransferHook +- Token account extension compatible with Token2022 TransferHook extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/transfer_hook.rs` diff --git a/programs/compressed-token/program/docs/CLAUDE.md b/programs/compressed-token/program/docs/CLAUDE.md index 461b7bf356..f08fa26158 100644 --- a/programs/compressed-token/program/docs/CLAUDE.md +++ b/programs/compressed-token/program/docs/CLAUDE.md @@ -8,30 +8,35 @@ This documentation is organized to provide clear navigation through the compress - **`../CLAUDE.md`** (parent) - Main entry point with summary and instruction index - **`ACCOUNTS.md`** - Complete account layouts and data structures - **`EXTENSIONS.md`** - Token-2022 extension validation across ctoken instructions +- **`INSTRUCTIONS.md`** - Full instruction reference and discriminator table - **`RESTRICTED_T22_EXTENSIONS.md`** - SPL Token-2022 behavior for 5 restricted extensions - **`T22_VS_CTOKEN_COMPARISON.md`** - Comparison of T22 vs ctoken extension behavior -- **`instructions/`** - Detailed instruction documentation - - `CREATE_TOKEN_ACCOUNT.md` - Create token account & associated token account instructions +- **`compressed_token/`** - Compressed token operations (Merkle tree accounts) + - `TRANSFER2.md` - Batch transfer with compress/decompress operations - `MINT_ACTION.md` - Mint operations and compressed mint management - - `TRANSFER2.md` - Batch transfer instruction for compressed/decompressed operations - - `CLAIM.md` - Claim rent from expired compressible accounts - - `CLOSE_TOKEN_ACCOUNT.md` - Close decompressed token accounts - - `CTOKEN_TRANSFER.md` - Transfer between decompressed accounts - - `CTOKEN_TRANSFER_CHECKED.md` - Transfer with decimals validation - - `CTOKEN_APPROVE.md` - Approve delegate on decompressed CToken account - - `CTOKEN_REVOKE.md` - Revoke delegate on decompressed CToken account - - `CTOKEN_MINT_TO.md` - Mint tokens to decompressed CToken account - - `CTOKEN_BURN.md` - Burn tokens from decompressed CToken account - - `CTOKEN_FREEZE_ACCOUNT.md` - Freeze decompressed CToken account - - `CTOKEN_THAW_ACCOUNT.md` - Thaw frozen decompressed CToken account - - `CTOKEN_APPROVE_CHECKED.md` - Approve delegate with decimals validation - - `CTOKEN_MINT_TO_CHECKED.md` - Mint tokens with decimals validation - - `CTOKEN_BURN_CHECKED.md` - Burn tokens with decimals validation - - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool + - `FREEZE.md` - Freeze compressed token accounts (Anchor) + - `THAW.md` - Thaw frozen compressed token accounts (Anchor) - `CREATE_TOKEN_POOL.md` - Create initial token pool for SPL/T22 mint compression - `ADD_TOKEN_POOL.md` - Add additional token pools (up to 5 per mint) +- **`compressible/`** - Rent management for compressible accounts + - `CLAIM.md` - Claim rent from expired compressible accounts + - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool +- **`ctoken/`** - CToken (decompressed) account operations + - `CREATE.md` - Create token account & associated token account + - `CLOSE.md` - Close decompressed token accounts + - `TRANSFER.md` - Transfer between decompressed accounts + - `TRANSFER_CHECKED.md` - Transfer with decimals validation + - `APPROVE.md` - Approve delegate + - `REVOKE.md` - Revoke delegate + - `MINT_TO.md` - Mint tokens to CToken account + - `MINT_TO_CHECKED.md` - Mint with decimals validation + - `BURN.md` - Burn tokens from CToken account + - `BURN_CHECKED.md` - Burn with decimals validation + - `FREEZE_ACCOUNT.md` - Freeze CToken account + - `THAW_ACCOUNT.md` - Thaw frozen CToken account ## Navigation Tips - Start with `../CLAUDE.md` for the instruction index and overview - Use `ACCOUNTS.md` for account structure reference +- Use `INSTRUCTIONS.md` for discriminator reference and instruction index - Refer to specific instruction docs for implementation details diff --git a/programs/compressed-token/program/docs/EXTENSIONS.md b/programs/compressed-token/program/docs/EXTENSIONS.md index cf70935947..7eaff32385 100644 --- a/programs/compressed-token/program/docs/EXTENSIONS.md +++ b/programs/compressed-token/program/docs/EXTENSIONS.md @@ -6,7 +6,7 @@ This document describes how Token-2022 extensions are validated across compresse The compressed token program supports 16 Token-2022 extension types. **5 restricted extensions** require instruction-level validation checks. Pure mint extensions (metadata, group, etc.) are allowed without explicit instruction support. -**Allowed extensions** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:24-44`): +**Allowed extensions** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:17-44`): 1. MetadataPointer 2. TokenMetadata @@ -84,7 +84,7 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric **Validation paths:** - `programs/compressed-token/anchor/src/instructions/create_token_pool.rs:142-153` - `assert_mint_extensions()` checks TransferFeeConfig -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:86-99` - `parse_mint_extensions()` checks TransferFeeConfig +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:86-99` - `parse_mint_extensions()` checks TransferFeeConfig (lines 86-99 in file) **Unchecked instructions:** 1. CTokenApprove @@ -110,7 +110,7 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric **Validation paths:** - `programs/compressed-token/anchor/src/instructions/create_token_pool.rs:155-162` - `assert_mint_extensions()` checks TransferHook -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:101-107` - `parse_mint_extensions()` checks TransferHook +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:101-107` - `parse_mint_extensions()` checks TransferHook (lines 101-107 in file) **Unchecked instructions:** 1. CTokenApprove @@ -134,9 +134,9 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric | CTokenTransfer | `parse_mint_extensions()` → `verify_owner_or_delegate_signer()` | Extract delegate pubkey, then validate authority is owner OR delegate. If authority matches permanent delegate, that account must be a signer. | `OwnerMismatch` (6075) or `MissingRequiredSignature` | **Validation paths:** -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:77-84` - Extracts delegate pubkey in `parse_mint_extensions()` +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:76-84` - Extracts delegate pubkey in `parse_mint_extensions()` - `programs/compressed-token/program/src/shared/owner_validation.rs:30-78` - `verify_owner_or_delegate_signer()` validates delegate/permanent delegate signer -- `programs/compressed-token/program/src/transfer/shared.rs:164-179` - `validate_permanent_delegate()` +- `programs/compressed-token/program/src/ctoken/transfer/shared.rs:196-214` - `validate_permanent_delegate()` **Unchecked instructions:** 1. CTokenApprove @@ -160,8 +160,8 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric | CTokenTransfer | `check_mint_extensions()` | `pausable_config.paused == false` | `MintPaused` (6127) | **Validation path:** -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:71-74` - `parse_mint_extensions()` checks PausableConfig.paused -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:147-149` - `check_mint_extensions()` throws MintPaused error +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:70-74` - `parse_mint_extensions()` checks PausableConfig.paused +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:147-150` - `check_mint_extensions()` throws MintPaused error **Unchecked instructions:** 1. CTokenApprove - allowed when paused (only affects delegation, not token movement) @@ -186,8 +186,8 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric | Transfer2 (Decompress) | - | Restores frozen state from CompressedOnly extension | - | **Validation paths:** -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:211-220` - Detects `default_state_frozen` -- `programs/compressed-token/program/src/shared/initialize_ctoken_account.rs:96-100` - Applies frozen state +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:213-220` - Detects `default_state_frozen` in `has_mint_extensions()` +- `programs/compressed-token/program/src/shared/initialize_ctoken_account.rs:190-198` - Applies frozen state in `initialize_ctoken_account()` **Account Initialization:** ```rust @@ -228,6 +228,8 @@ pub struct CompressedOnlyExtension { pub delegated_amount: u64, /// Withheld transfer fee amount from the source CToken account. pub withheld_transfer_fee: u64, + /// Whether the source was an ATA (1) or regular token account (0). + pub is_ata: u8, } ``` @@ -242,12 +244,18 @@ pub struct CompressedOnlyExtensionInstructionData { pub is_frozen: bool, /// Index of the compression operation that consumes this input. pub compression_index: u8, + /// Whether the source CToken account was an ATA. + pub is_ata: bool, + /// ATA derivation bump (only used when is_ata=true). + pub bump: u8, + /// Index into packed_accounts for the actual owner (only used when is_ata=true). + pub owner_index: u8, } ``` ### When Created (CompressAndClose) -**Path:** `programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs` **Trigger:** `ZCompressionMode::CompressAndClose` with `compression_only=true` on source CToken account. @@ -256,15 +264,21 @@ pub struct CompressedOnlyExtensionInstructionData { - Output compressed token must include CompressedOnly extension in TLV data - Extension values must match source CToken state -**Validation (lines 168-277 in `validate_compressed_token_account`):** -1. If source has `compression_only=true`, CompressedOnly extension is required (line 173-175) -2. `delegated_amount` must match source CToken's `delegated_amount` (lines 181-188) -3. Delegate pubkey must match if delegated_amount > 0 (lines 189-210) -4. `withheld_transfer_fee` must match source's TransferFeeAccount withheld amount (lines 211-237) -5. `is_frozen` must match source CToken's frozen state (`state == 2`) (lines 239-251) -6. If source is frozen but extension missing → `CompressAndCloseMissingCompressedOnlyExtension` (lines 253-259) - -**Source CToken Reset (lines 71-74 in `process_compress_and_close`):** +**Validation (in `validate_compressed_token_account` and `validate_compressed_only_ext`):** +1. Owner must match (lines 103-115): output owner must match ctoken owner (or token account pubkey for ATA/compress_to_pubkey) +2. Amount must match (lines 117-121): compression_amount == output_amount == ctoken.amount +3. Mint must match (lines 123-129): output mint matches ctoken mint +4. Version must be ShaFlat (lines 131-134) +5. Extension required for compression_only or ATA accounts (lines 136-145) +6. Without extension: must not be frozen, must not have delegate (lines 147-156) +7. With extension (`validate_compressed_only_ext` function, lines 170-222): + - 7a. `delegated_amount` must match (lines 177-181) + - 7b. Delegate pubkey must match if present (lines 183-194) + - 7c. `withheld_transfer_fee` must match (lines 196-209) + - 7d. `is_frozen` must match (lines 211-214) + - 7e. `is_ata` must match (lines 216-219) + +**Source CToken Reset (lines 68-71 in `process_compress_and_close`):** ```rust ctoken.base.amount.set(0); // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) @@ -274,24 +288,21 @@ ctoken.base.set_initialized(); ### When Consumed (Decompress) -**Path:** `programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs` **Trigger:** Decompressing a compressed token that has CompressedOnly extension. -**State Restoration (`apply_decompress_extension_state` function, lines 56-128):** -1. Extract CompressedOnly data from input TLV (lines 65-77) -2. Validate destination is fresh with matching owner via `validate_decompression_destination` (lines 15-50) -3. Restore delegate pubkey from instruction input account (lines 85-96) -4. Restore `delegated_amount` to destination CToken (lines 99-101) -5. Restore `withheld_transfer_fee` to TransferFeeAccount extension (lines 104-120) -6. Restore frozen state via `ctoken.base.set_frozen()` (lines 122-125) - -**Validation (`validate_decompression_destination`, lines 15-50):** -- Destination owner must match input owner -- Destination amount must be 0 -- Destination must not have delegate -- Destination delegated_amount must be 0 -- Destination must not have close_authority +**State Restoration (`validate_and_apply_compressed_only` function, lines 15-70):** +1. Return early if no decompress inputs or no CompressedOnly extension (lines 23-29) +2. Validate amount matches for ATA or compress_to_pubkey decompress (lines 31-46) +3. Validate destination ownership via `validate_destination` (lines 48-56) +4. Restore delegate pubkey and delegated_amount via `apply_delegate` (lines 58-59) +5. Restore `withheld_transfer_fee` via `apply_withheld_fee` (lines 61-62) +6. Restore frozen state via `ctoken.base.set_frozen()` (lines 64-67) + +**Validation (`validate_destination`, lines 77-106):** +- For non-ATA: CToken owner must match input owner +- For ATA: destination address must match input owner (ATA pubkey), and CToken owner must match wallet owner ### State Preservation Matrix @@ -300,6 +311,7 @@ ctoken.base.set_initialized(); | delegated_amount | ✅ | ✅ | Stored in extension | | withheld_transfer_fee | ✅ | ✅ | Restored to TransferFeeAccount | | is_frozen | ✅ | ✅ | Restored via `set_frozen()` | +| is_ata | ✅ | ✅ | Used to validate ATA derivation on decompress | | delegate pubkey | Validated | From input | Passed as instruction account | | amount | ❌ (set to 0) | From compression | New amount from compressed token | | close_authority | ❌ | ❌ | Not preserved | @@ -312,6 +324,9 @@ ctoken.base.set_initialized(); | `CompressAndCloseDelegatedAmountMismatch` | 6135 | delegated_amount doesn't match source | | `CompressAndCloseWithheldFeeMismatch` | 6137 | withheld_transfer_fee doesn't match source | | `CompressAndCloseFrozenMismatch` | 6138 | is_frozen doesn't match source frozen state | +| `CompressAndCloseIsAtaMismatch` | N/A | is_ata doesn't match source ATA flag | +| `CompressAndCloseInvalidDelegate` | N/A | delegate pubkey doesn't match source | +| `CompressAndCloseDelegateNotAllowed` | N/A | delegate present but CompressedOnly extension missing | --- @@ -333,12 +348,12 @@ ctoken.base.set_initialized(); --- ### `has_mint_extensions()` -**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:175-230` +**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:174-230` **Used by:** CreateTokenAccount (detection only) **Behavior:** -1. Return default flags if not Token-2022 mint (lines 177-179) +1. Return default flags if not Token-2022 mint (lines 176-179) 2. Deserialize mint with `PodStateWithExtensions::unpack()` (lines 181-184) 3. Get all extension types in a single call (line 187) 4. Validate all extensions are in `ALLOWED_EXTENSION_TYPES` → `MintWithInvalidExtension` (lines 196-200) @@ -346,7 +361,7 @@ ctoken.base.set_initialized(); 6. Check if DefaultAccountState is set to Frozen (lines 213-220) 7. Return `MintExtensionFlags` with boolean flags -**Returns** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:59-74`): +**Returns** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:59-75`): ```rust MintExtensionFlags { has_pausable: bool, @@ -368,15 +383,15 @@ MintExtensionFlags { **Used by:** Internal helper for `check_mint_extensions()` and `build_mint_extension_cache()` **Behavior:** -1. Return default if not Token-2022 mint (lines 57-59) +1. Return default if not Token-2022 mint (lines 56-59) 2. Deserialize mint with `PodStateWithExtensions::unpack()` (lines 61-64) 3. Compute `has_restricted_extensions` from extension types (lines 66-68) -4. Check if Pausable extension exists and paused state (lines 71-74) -5. Extract PermanentDelegate pubkey if exists (lines 77-84) -6. Check TransferFeeConfig for non-zero fees (lines 87-99) -7. Check TransferHook for non-nil program_id (lines 102-107) +4. Check if Pausable extension exists and paused state (lines 70-74) +5. Extract PermanentDelegate pubkey if exists (lines 76-84) +6. Check TransferFeeConfig for non-zero fees (lines 86-99) +7. Check TransferHook for non-nil program_id (lines 101-107) -**Returns** (defined in `check_mint_extensions.rs:21-40`): +**Returns** (defined in `check_mint_extensions.rs:22-40`): ```rust MintExtensionChecks { permanent_delegate: Option, // For signer validation @@ -391,7 +406,7 @@ MintExtensionChecks { --- ### `check_mint_extensions()` -**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:134-159` +**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:133-159` **Used by:** Transfer2, CTokenTransfer (runtime validation) @@ -401,31 +416,31 @@ MintExtensionChecks { **Behavior:** Wrapper around `parse_mint_extensions()` that throws errors for invalid states: 1. Call `parse_mint_extensions()` (line 138) -2. If `deny_restricted_extensions && has_restricted_extensions` → `MintHasRestrictedExtensions` (6142) (lines 141-145) -3. If `is_paused == true` → `MintPaused` (6127) (lines 148-150) +2. If `deny_restricted_extensions && has_restricted_extensions` → `MintHasRestrictedExtensions` (6142) (lines 140-145) +3. If `is_paused == true` → `MintPaused` (6127) (lines 147-150) 4. If `has_non_zero_transfer_fee` → `NonZeroTransferFeeNotSupported` (6129) (lines 151-153) 5. If `has_non_nil_transfer_hook` → `TransferHookNotSupported` (6130) (lines 154-156) --- ### `build_mint_extension_cache()` -**Path:** `programs/compressed-token/program/src/transfer2/check_extensions.rs:77-145` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs:78-158` **Used by:** Transfer2 (batch validation) **Behavior:** -1. For each unique mint in inputs (lines 85-97): +1. For each unique mint in inputs (lines 89-101): - If no outputs: call `parse_mint_extensions()` (bypass state checks for pure decompress) - Otherwise: call `check_mint_extensions()` with `deny_restricted_extensions` - Cache result in `ArrayMap` -2. For each unique mint in compressions (lines 100-142): +2. For each unique mint in compressions (lines 103-142): - CompressAndClose and full Decompress: use `parse_mint_extensions()` (bypass state checks) - Otherwise: use `check_mint_extensions()` with `deny_restricted_extensions` -3. Special handling for CompressAndClose mode (lines 116-137): +3. Special handling for CompressAndClose mode (lines 121-140): - Mints with restricted extensions require CompressedOnly output extension - If missing → `CompressAndCloseMissingCompressedOnlyExtension` (6133) -**Returns:** `MintExtensionCache` (type alias defined at line 46) - Cached checks keyed by mint account index +**Returns:** `MintExtensionCache` (type alias defined at line 49) - Cached checks keyed by mint account index --- @@ -448,17 +463,17 @@ MintExtensionChecks { **Enforcement:** `build_mint_extension_cache()` is called with `deny_restricted_extensions = !out_token_data.is_empty()` **Flow:** -1. `build_mint_extension_cache()` computes `deny_restricted_extensions = !inputs.out_token_data.is_empty()` (line 82) -2. For input mints: calls `check_mint_extensions(mint, deny_restricted_extensions)` (line 93) +1. `build_mint_extension_cache()` computes `deny_restricted_extensions = !inputs.out_token_data.is_empty()` (line 86) +2. For input mints: calls `check_mint_extensions(mint, deny_restricted_extensions)` (line 97) 3. If `deny_restricted_extensions=true` and mint has restricted extensions → `MintHasRestrictedExtensions` (6142) **Exception - CompressAndClose and Decompress modes:** -- CompressAndClose: calls `parse_mint_extensions()` to bypass state checks (line 111) -- Full Decompress (no outputs): calls `parse_mint_extensions()` to bypass state checks (lines 89-91) -- CompressAndClose still requires CompressedOnly output extension for restricted mints (lines 116-137) +- CompressAndClose: calls `parse_mint_extensions()` to bypass state checks (line 112) +- Full Decompress (no outputs): calls `parse_mint_extensions()` to bypass state checks (lines 93-95) +- CompressAndClose still requires CompressedOnly output extension for restricted mints (lines 125-140) - If missing → `CompressAndCloseMissingCompressedOnlyExtension` (6133) -**Path:** `programs/compressed-token/program/src/transfer2/processor.rs:61` calls `build_mint_extension_cache()` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/processor.rs:61` calls `build_mint_extension_cache()` ### Anchor Instructions diff --git a/programs/compressed-token/program/docs/INSTRUCTIONS.md b/programs/compressed-token/program/docs/INSTRUCTIONS.md new file mode 100644 index 0000000000..e6935cb0a4 --- /dev/null +++ b/programs/compressed-token/program/docs/INSTRUCTIONS.md @@ -0,0 +1,103 @@ +# Instructions Reference + +## Overview +This file contains the discriminator reference table and instruction index for the compressed token program. + +## Related Documentation +- **`CLAUDE.md`** - Documentation structure guide +- **`../CLAUDE.md`** (parent) - Main entry point with summary and instruction index +- **`ACCOUNTS.md`** - Complete account layouts and data structures +- **`compressed_token/`** - Compressed token operations (Merkle tree accounts) + - `TRANSFER2.md` - Batch transfer with compress/decompress operations + - `MINT_ACTION.md` - Mint operations and compressed mint management + - `FREEZE.md` - Freeze compressed token accounts (Anchor) + - `THAW.md` - Thaw frozen compressed token accounts (Anchor) + - `CREATE_TOKEN_POOL.md` - Create initial token pool for SPL/T22 mint compression + - `ADD_TOKEN_POOL.md` - Add additional token pools (up to 5 per mint) +- **`compressible/`** - Rent management for compressible accounts + - `CLAIM.md` - Claim rent from expired compressible accounts + - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool +- **`ctoken/`** - CToken (decompressed) account operations + - `CREATE.md` - Create token account & associated token account + - `CLOSE.md` - Close decompressed token accounts + - `TRANSFER.md` - Transfer between decompressed accounts + - `TRANSFER_CHECKED.md` - Transfer with decimals validation + - `APPROVE.md` - Approve delegate + - `REVOKE.md` - Revoke delegate + - `MINT_TO.md` - Mint tokens to CToken account + - `MINT_TO_CHECKED.md` - Mint with decimals validation + - `BURN.md` - Burn tokens from CToken account + - `BURN_CHECKED.md` - Burn with decimals validation + - `FREEZE_ACCOUNT.md` - Freeze CToken account + - `THAW_ACCOUNT.md` - Thaw frozen CToken account + +## Discriminator Reference + +| Instruction | Discriminator | Enum Variant | SPL Token Compatible | +|-------------|---------------|--------------|----------------------| +| CTokenTransfer | 3 | `InstructionType::CTokenTransfer` | Transfer | +| CTokenApprove | 4 | `InstructionType::CTokenApprove` | Approve | +| CTokenRevoke | 5 | `InstructionType::CTokenRevoke` | Revoke | +| CTokenMintTo | 7 | `InstructionType::CTokenMintTo` | MintTo | +| CTokenBurn | 8 | `InstructionType::CTokenBurn` | Burn | +| CloseTokenAccount | 9 | `InstructionType::CloseTokenAccount` | CloseAccount | +| CTokenFreezeAccount | 10 | `InstructionType::CTokenFreezeAccount` | FreezeAccount | +| CTokenThawAccount | 11 | `InstructionType::CTokenThawAccount` | ThawAccount | +| CTokenTransferChecked | 12 | `InstructionType::CTokenTransferChecked` | TransferChecked | +| CTokenMintToChecked | 14 | `InstructionType::CTokenMintToChecked` | MintToChecked | +| CTokenBurnChecked | 15 | `InstructionType::CTokenBurnChecked` | BurnChecked | +| CreateTokenAccount | 18 | `InstructionType::CreateTokenAccount` | InitializeAccount3 | +| CreateAssociatedCTokenAccount | 100 | `InstructionType::CreateAssociatedCTokenAccount` | - | +| Transfer2 | 101 | `InstructionType::Transfer2` | - | +| CreateAssociatedTokenAccountIdempotent | 102 | `InstructionType::CreateAssociatedTokenAccountIdempotent` | - | +| MintAction | 103 | `InstructionType::MintAction` | - | +| Claim | 104 | `InstructionType::Claim` | - | +| WithdrawFundingPool | 105 | `InstructionType::WithdrawFundingPool` | - | +| Freeze | Anchor | `anchor_compressed_token::freeze` | - | +| Thaw | Anchor | `anchor_compressed_token::thaw` | - | + +**SPL Token Compatibility Notes:** +- Instructions with SPL Token equivalents share the same discriminator and accept the same instruction data format +- CreateTokenAccount (18) accepts 32-byte owner pubkey for InitializeAccount3 compatibility +- CToken-specific instructions (100+) have no SPL Token equivalent + +## Navigation Tips +- Start with `../CLAUDE.md` for the instruction index and overview +- Use `ACCOUNTS.md` for account structure reference +- Refer to specific instruction docs for implementation details + + +# Instructions + +**Instruction Schema:** +every instruction description must include the sections: + - **path** path to instruction code in the program + - **description** highlevel description what the instruction does including accounts used and their state layout (paths to the code), usage flows what the instruction does + - **instruction_data** paths to code where instruction data structs are defined + - **Accounts** accounts in order including checks + - **instruciton logic and checks** + - **Errors** possible errors and description what causes these errors + +## Compressed Token Operations (`compressed_token/`) +1. **Transfer2** - Batch transfer instruction supporting compress/decompress/transfer operations +2. **MintAction** - Batch instruction for compressed mint management (9 actions) +3. **Freeze** - Freeze compressed token accounts (Anchor) +4. **Thaw** - Thaw frozen compressed token accounts (Anchor) + +## CToken Operations (`ctoken/`) +5. **Create** - Create regular and associated ctoken accounts +6. **Close** - Close decompressed token accounts with rent distribution +7. **Transfer** - SPL-compatible transfers between decompressed accounts +8. **Approve/Revoke** - Approve and revoke delegate on decompressed CToken accounts +9. **MintTo** - Mint tokens to decompressed CToken account +10. **Burn** - Burn tokens from decompressed CToken account +11. **Freeze/Thaw** - Freeze and thaw decompressed CToken accounts +12. **Checked Operations** - TransferChecked, MintToChecked, BurnChecked + +## Compressible Operations (`compressible/`) +13. **Claim** - Rent reclamation from expired compressible accounts +14. **Withdraw Funding Pool** - Withdraw funds from rent recipient pool + +## Token Pool Operations (root) +15. **Create Token Pool** - Create initial token pool PDA for SPL/T22 mint compression +16. **Add Token Pool** - Add additional token pools for a mint (up to 5 per mint) diff --git a/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md b/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md index c19de8a46a..206541a380 100644 --- a/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md +++ b/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md @@ -34,7 +34,7 @@ This document compares the behavior of 5 restricted Token-2022 extensions betwee |-------------------|--------------------------------------------------|--------------------------------------------------| | Fee handling | Deducted from transfer, withheld in destination | Must be 0, otherwise `NonZeroTransferFeeNotSupported` | | CloseAccount | Blocked if `withheld_amount > 0` | No withheld check (fees always 0) | -| Account extension | TransferFeeAmount with `withheld_amount` field | TransferFeeAccount marker (no withheld tracking) | +| Account extension | TransferFeeAmount with `withheld_amount` field | TransferFeeAccountExtension with `withheld_amount` field | ### T22 Features Not Implemented @@ -115,7 +115,7 @@ CToken adds an account marker to identify accounts belonging to mints with perma |-------------------|-----------------------------------------------|----------------------------------------------| | Hook execution | CPI to program_id after balance update | No CPI (program_id must be nil) | | Reentrancy guard | `transferring` flag in TransferHookAccount | No guard needed (no CPI) | -| Account extension | TransferHookAccount with `transferring` field | TransferHookAccount marker (no transferring) | +| Account extension | TransferHookAccount with `transferring` field | TransferHookAccountExtension with `transferring` field (always false) | ### T22 Features Not Implemented @@ -193,11 +193,14 @@ Enables: ### CompressAndClose/Decompress Bypass (CToken-specific) ```rust -// Path: src/transfer2/check_extensions.rs:106-114 -let is_full_decompress = - compression.mode.is_decompress() && inputs.out_token_data.is_empty(); -let checks = if compression.mode.is_compress_and_close() || is_full_decompress { - // CompressAndClose and Decompress bypass extension state checks +// Path: src/compressed_token/transfer2/check_extensions.rs (build_mint_extension_cache function) +let no_compressed_outputs = inputs.out_token_data.is_empty(); + +// For compressions, bypass state checks when: +// - CompressAndClose mode, or +// - No compressed outputs (full decompress / CToken-to-SPL) +let checks = if compression.mode.is_compress_and_close() || no_compressed_outputs { + // Bypass extension state checks (paused, non-zero fees, non-nil transfer hook) parse_mint_extensions(mint_account)? // Extract data only } else { check_mint_extensions(mint_account, deny_restricted_extensions)? // Validate state @@ -215,15 +218,18 @@ This allows: ### Account Extension Markers -| Extension | T22 Adds Marker | CToken Adds Marker | -|---------------------|---------------------|----------------------------------| -| TransferFeeConfig | TransferFeeAmount | TransferFeeAccount | -| DefaultAccountState | None | None | -| PermanentDelegate | None | PermanentDelegateAccountExtension | -| TransferHook | TransferHookAccount | TransferHookAccount | -| Pausable | PausableAccount | PausableAccount | - -**Key difference:** T22's TransferFeeAmount and TransferHookAccount have data fields. CToken uses zero-sized markers. +| Extension | T22 Adds Marker | CToken Adds Marker | +|---------------------|---------------------|------------------------------------| +| TransferFeeConfig | TransferFeeAmount | TransferFeeAccountExtension | +| DefaultAccountState | None | None | +| PermanentDelegate | None | PermanentDelegateAccountExtension | +| TransferHook | TransferHookAccount | TransferHookAccountExtension | +| Pausable | PausableAccount | PausableAccountExtension | + +**Key differences:** +- T22's TransferFeeAmount has `withheld_amount` field. CToken's TransferFeeAccountExtension also has `withheld_amount` for state preservation during compress/decompress cycles. +- T22's TransferHookAccount has `transferring` flag for reentrancy guard. CToken's TransferHookAccountExtension has the same field but it's always false (no CPI invocation). +- PermanentDelegateAccountExtension and PausableAccountExtension are zero-sized markers in CToken. ### Validation Function Comparison diff --git a/programs/compressed-token/program/docs/instructions/ADD_TOKEN_POOL.md b/programs/compressed-token/program/docs/compressed_token/ADD_TOKEN_POOL.md similarity index 100% rename from programs/compressed-token/program/docs/instructions/ADD_TOKEN_POOL.md rename to programs/compressed-token/program/docs/compressed_token/ADD_TOKEN_POOL.md diff --git a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_POOL.md b/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CREATE_TOKEN_POOL.md rename to programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md index 8ae26a1ad1..94b4f3cfa4 100644 --- a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_POOL.md +++ b/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md @@ -39,7 +39,7 @@ Token pool pda is renamed to spl interface pda in the light-token-sdk. - Token program interface (SPL Token or Token-2022) 6. cpi_authority_pda - CPI authority PDA - - PDA derivation: seeds=[b"light_cpi_authority"], program=light_compressed_token + - PDA derivation: seeds=[b"cpi_authority"], program=light_compressed_token - Becomes the owner/authority of the token pool account **Instruction Logic and Checks:** diff --git a/programs/compressed-token/program/docs/instructions/compressed_token/FREEZE.md b/programs/compressed-token/program/docs/compressed_token/FREEZE.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/compressed_token/FREEZE.md rename to programs/compressed-token/program/docs/compressed_token/FREEZE.md index 08fc60b1ed..a3637c6f4a 100644 --- a/programs/compressed-token/program/docs/instructions/compressed_token/FREEZE.md +++ b/programs/compressed-token/program/docs/compressed_token/FREEZE.md @@ -69,7 +69,7 @@ Supports multiple hashing versions via an optional trailing version byte: - Return `InvalidFreezeAuthority` if authority doesn't match 4. **Build input compressed accounts:** - - Call `get_input_compressed_accounts_with_merkle_context_and_check_signer::` (FROZEN_INPUTS=false) + - Call `get_input_compressed_accounts_with_merkle_context_and_check_signer::` (IS_FROZEN=false) - Reconstruct token data from inputs using owner from instruction data - Set input state to Initialized (expected input state) diff --git a/programs/compressed-token/program/docs/instructions/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md similarity index 57% rename from programs/compressed-token/program/docs/instructions/MINT_ACTION.md rename to programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index 8157914f2a..07618b677b 100644 --- a/programs/compressed-token/program/docs/instructions/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -2,12 +2,12 @@ **discriminator:** 103 **enum:** `InstructionType::MintAction` -**path:** programs/compressed-token/program/src/mint_action/ +**path:** programs/compressed-token/program/src/compressed_token/mint_action/ **description:** Batch instruction for managing compressed mint accounts (cmints) and performing mint operations. A compressed mint account stores the mint's supply, decimals, authorities (mint/freeze), and optional TokenMetadata extension in compressed state. TokenMetadata is the only extension supported for compressed mints and provides fields for name, symbol, uri, update_authority, and additional key-value metadata. -This instruction supports 11 total actions - one creation action (controlled by `create_mint` flag) and 10 enum-based actions: +This instruction supports 10 total actions - one creation action (controlled by `create_mint` flag) and 9 enum-based actions: **Compressed mint creation (executed first when `create_mint` is Some):** 1. **Create Compressed Mint** - Create a new compressed mint account with initial authorities and optional TokenMetadata extension @@ -15,24 +15,23 @@ This instruction supports 11 total actions - one creation action (controlled by **Core mint operations (Action enum variants):** 2. `MintToCompressed` - Mint new compressed tokens to one or more compressed token accounts 3. `MintToCToken` - Mint new tokens to decompressed ctoken accounts (not SPL tokens) -4. `CreateSplMint` - Create an SPL Token 2022 mint for an existing compressed mint, enabling SPL interoperability **Authority updates (Action enum variants):** -5. `UpdateMintAuthority` - Update or remove the mint authority -6. `UpdateFreezeAuthority` - Update or remove the freeze authority +4. `UpdateMintAuthority` - Update or remove the mint authority +5. `UpdateFreezeAuthority` - Update or remove the freeze authority **TokenMetadata extension operations (Action enum variants):** -7. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension -8. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension -9. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension +6. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension +7. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension +8. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension **Decompress/Compress operations (Action enum variants):** -10. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. -11. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). +9. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. +10. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). Key concepts integrated: -- **Compressed mint (cmint)**: Mint state stored in compressed account with deterministic address derived from associated SPL mint pubkey -- **SPL mint synchronization**: When SPL mint exists, supply is tracked in both compressed mint and SPL mint through token pool PDAs +- **Compressed mint (cmint)**: Mint state stored in compressed account with deterministic address derived from a mint signer PDA +- **Decompressed mint (CMint)**: When a compressed mint is decompressed, a CMint Solana account becomes the source of truth - **Authority validation**: All actions require appropriate authority (mint/freeze/metadata) to be transaction signer - **Batch processing**: Multiple actions execute sequentially with state updates persisted between actions @@ -42,10 +41,7 @@ Key concepts integrated: **Core fields:** - `leaf_index`: u32 - Merkle tree leaf index of existing compressed mint (only used if create_mint is None) - `prove_by_index`: bool - Use proof-by-index for existing mint validation (only used if create_mint is None) - - `root_index`: u16 - Root index for address proof (create) or validity proof (update) - - `compressed_address`: [u8; 32] - Deterministic address derived from SPL mint pubkey - - `token_pool_bump`: u8 - Token pool PDA bump (required for SPL mint operations) - - `token_pool_index`: u8 - Token pool PDA index (required for SPL mint operations) + - `root_index`: u16 - Root index for address proof (create) or validity proof (update). Not used if proof by index. - `max_top_up`: u16 - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) - `create_mint`: Option - Configuration for creating new compressed mint (None for existing mint operations) - `actions`: Vec - Ordered list of actions to execute @@ -54,44 +50,47 @@ Key concepts integrated: - `mint`: Option - Full mint state including supply, decimals, metadata, authorities, and extensions (None when reading from decompressed CMint) 2. Action types (path: program-libs/ctoken-interface/src/instructions/mint_action/): - - `MintToCompressed(MintToCompressedAction)` - Mint tokens to compressed accounts (mint_to.rs) + - `MintToCompressed(MintToCompressedAction)` - Mint tokens to compressed accounts (mint_to_compressed.rs) - `UpdateMintAuthority(UpdateAuthority)` - Update mint authority (update_mint.rs) - `UpdateFreezeAuthority(UpdateAuthority)` - Update freeze authority (update_mint.rs) - - `CreateSplMint(CreateSplMintAction)` - Create SPL mint for cmint (create_spl_mint.rs) - `MintToCToken(MintToCTokenAction)` - Mint to ctoken accounts (mint_to_ctoken.rs) - `UpdateMetadataField(UpdateMetadataFieldAction)` - Update metadata field (update_metadata.rs) - `UpdateMetadataAuthority(UpdateMetadataAuthorityAction)` - Update metadata authority (update_metadata.rs) - `RemoveMetadataKey(RemoveMetadataKeyAction)` - Remove metadata key (update_metadata.rs) - - `DecompressMint(DecompressMintAction)` - Decompress compressed mint to CMint Solana account - - `CompressAndCloseCMint(CompressAndCloseCMintAction)` - Compress and close CMint Solana account + - `DecompressMint(DecompressMintAction)` - Decompress compressed mint to CMint Solana account (decompress_mint.rs) + - `CompressAndCloseCMint(CompressAndCloseCMintAction)` - Compress and close CMint Solana account (compress_and_close_cmint.rs) **Accounts:** + +The account ordering differs based on whether writing to CPI context or executing. + +**Always present:** 1. light_system_program - non-mutable - - Light Protocol system program for cpi to create or update the compressed mint account. + - Light Protocol system program for CPI to create or update the compressed mint account. -Optional accounts (based on configuration): -2. mint_signer - - (signer) - required if create_mint is Some or CreateSplMint action present - - PDA seed for SPL mint creation (seeds from compressed mint randomness) +2. mint_signer (optional) + - (signer if create_mint is Some, non-signer for DecompressMint) + - Required if create_mint is Some or DecompressMint action is present + - PDA seed derivation from compressed mint randomness 3. authority - (signer) - Must match current mint/freeze/metadata authority for respective actions -For execution (when not writing to CPI context): -4. mint - - (mutable) - optional, required for SPL mint supply synchronization - - SPL Token 2022 mint account for supply synchronization +**For execution (when not writing to CPI context):** -5. token_pool_pda - - (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 +4. compressible_config (optional) + - Required when DecompressMint or CompressAndCloseCMint action is present + - CompressibleConfig account - parsed and validated for active state -6. token_program - - non-mutable - optional, required for SPL mint supply synchronization - - Must be SPL Token 2022 program (validated in accounts.rs:126) +5. cmint (optional) + - (mutable) - CMint Solana account (decompressed compressed mint) + - Required when cmint_decompressed=true OR DecompressMint OR CompressAndCloseCMint action present + +6. rent_sponsor (optional) + - (mutable) - Required when DecompressMint or CompressAndCloseCMint action is present + - Rent sponsor PDA that pays for CMint account creation 7-12. Light system accounts (standard set): - fee_payer (signer, mutable) @@ -100,6 +99,9 @@ For execution (when not writing to CPI context): - account_compression_authority - account_compression_program - system_program + - sol_pool_pda (optional) + - sol_decompression_recipient (optional) + - cpi_context (optional) 13. out_output_queue - (mutable) @@ -118,10 +120,13 @@ For execution (when not writing to CPI context): - (mutable) - optional, required for MintToCompressed actions - Output queue for newly minted compressed token accounts -For CPI context write (when write_to_cpi_context=true): -4-6. CPI context accounts only +**For CPI context write (when write_to_cpi_context=true):** +4-6. CPI context accounts: + - fee_payer (signer, mutable) + - cpi_authority_pda + - cpi_context -Packed accounts (remaining accounts): +**Packed accounts (remaining accounts):** - Merkle tree and queue accounts for compressed storage - Recipient ctoken accounts for MintToCToken action @@ -134,15 +139,17 @@ Packed accounts (remaining accounts): 2. **Validate and parse accounts:** - Check authority is signer - - If SPL mint initialized: validate token pool PDA derivation - - Validate mint account matches expected cmint pubkey + - Validate CMint account matches expected mint pubkey (when cmint_pubkey provided) - For create_mint: validate address_merkle_tree is CMINT_ADDRESS_TREE + - Parse compressible config when DecompressMint or CompressAndCloseCMint action present - Extract packed accounts for dynamic operations 3. **Process mint creation or input:** - If create_mint is Some: - - Derive SPL mint PDA from compressed address - - Set create address in CPI instruction + - Derive mint PDA from mint_signer key: `find_program_address([COMPRESSED_MINT_SEED, mint_signer], program_id)` + - Validate mint.metadata.mint matches derived PDA + - Validate compressed address derivation (especially with CPI context) + - Set new address params in CPI instruction - If create_mint is None: - Hash existing compressed mint account - Set input with merkle context (tree, queue, leaf_index, proof) @@ -154,26 +161,17 @@ Packed accounts (remaining accounts): - Validate: mint authority matches signer - Calculate: sum recipient amounts with overflow protection - Update: mint supply += sum_amounts - - If SPL mint exists: mint equivalent tokens to pool via CPI - Create: compressed token accounts for each recipient **UpdateMintAuthority / UpdateFreezeAuthority:** - Validate: current authority matches signer - Update: set new authority (can be None to disable) - **CreateSplMint:** - - Validate: mint_signer is provided and signing - - Create: SPL Token 2022 mint account via CPI - - Create: Token pool PDA account - - Initialize: mint with ctoken PDA as mint/freeze authority - - Mint: existing supply to token pool - **MintToCToken:** - Validate: mint authority matches signer - - Calculate: sum recipient amounts - - Update: mint supply += sum_amounts - - If SPL mint exists: mint to pool, then transfer to recipients - - If no SPL mint: directly update ctoken account balances + - Calculate: sum recipient amount + - Update: mint supply += amount + - Update ctoken account balance via decompress operation **UpdateMetadataField:** - Validate: metadata authority matches signer (defaults to mint authority) @@ -213,20 +211,35 @@ Packed accounts (remaining accounts): - `ProgramError::InvalidInstructionData` (error code: 3) - Failed to deserialize instruction data or invalid action configuration - `ProgramError::InvalidAccountData` (error code: 4) - Account validation failures (wrong program ownership, invalid PDA derivation) -- `ProgramError::InvalidArgument` (error code: 1) - Invalid authority or action parameters -- `ErrorCode::MintActionProofMissing` (error code: 6070) - ZK proof required but not provided -- `ErrorCode::InvalidAuthorityMint` (error code: 6076) - Signer doesn't match mint authority -- `ErrorCode::MintActionAmountTooLarge` (error code: 6101) - Arithmetic overflow in mint amount calculations -- `ErrorCode::MintAccountMismatch` (error code: 6102) - SPL mint account doesn't match expected cmint -- `ErrorCode::InvalidAddressTree` (error code: 6069) - Wrong address merkle tree for mint creation -- `ErrorCode::MintActionMissingSplMintSigner` (error code: 6058) - Missing mint signer for SPL mint creation -- `ErrorCode::MintActionMissingMintAccount` (error code: 6061) - Missing SPL mint account when required -- `ErrorCode::MintActionMissingTokenPoolAccount` (error code: 6062) - Missing token pool PDA when required -- `ErrorCode::MintActionMissingTokenProgram` (error code: 6063) - Missing token program when required -- `ErrorCode::MintActionInvalidExtensionIndex` (error code: 6079) - Extension index out of bounds -- `ErrorCode::MintActionInvalidExtensionType` (error code: 6081) - Extension is not TokenMetadata type -- `ErrorCode::MintActionMetadataKeyNotFound` (error code: 6082) - Metadata key not found for removal -- `ErrorCode::MintActionMissingExecutingAccounts` (error code: 6083) - Missing required execution accounts +- `ProgramError::NotEnoughAccountKeys` - Missing required accounts +- `ErrorCode::MintActionProofMissing` (error code: 6055) - ZK proof required but not provided +- `ErrorCode::InvalidAuthorityMint` (error code: 6018) - Signer doesn't match mint authority +- `ErrorCode::MintActionAmountTooLarge` (error code: 6069) - Arithmetic overflow in mint amount calculations +- `ErrorCode::MintAccountMismatch` (error code: 6051) - CMint account doesn't match expected mint +- `ErrorCode::InvalidAddressTree` (error code: 6094) - Wrong address merkle tree for mint creation +- `ErrorCode::MintActionMissingMintSigner` (error code: 6108) - Missing mint signer account +- `ErrorCode::MintActionMissingCMintAccount` (error code: 6109) - Missing CMint account for decompress mint action +- `ErrorCode::MintActionInvalidExtensionIndex` (error code: 6059) - Extension index out of bounds +- `ErrorCode::MintActionInvalidExtensionType` (error code: 6062) - Extension is not TokenMetadata type +- `ErrorCode::MintActionMetadataKeyNotFound` (error code: 6063) - Metadata key not found for removal +- `ErrorCode::MintActionMissingExecutingAccounts` (error code: 6064) - Missing required execution accounts +- `ErrorCode::MintActionInvalidMintPda` (error code: 6066) - Invalid mint PDA derivation +- `ErrorCode::MintActionOutputSerializationFailed` (error code: 6068) - Account data serialization failed +- `ErrorCode::MintActionInvalidInitialSupply` (error code: 6070) - Initial supply must be 0 for new mint creation +- `ErrorCode::MintActionUnsupportedVersion` (error code: 6071) - Mint version not supported +- `ErrorCode::MintActionInvalidCompressionState` (error code: 6072) - New mint must start as compressed +- `ErrorCode::MintActionUnsupportedOperation` (error code: 6073) - Unsupported operation - `ErrorCode::CpiContextExpected` (error code: 6085) - CPI context required but not provided -- `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing -- `AccountError::NotEnoughAccountKeys` (error code: 12020) - Missing required accounts +- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Account index out of bounds for MintToCToken +- `ErrorCode::MintActionInvalidCpiContextForCreateMint` (error code: 6104) - Invalid CPI context for create mint operation +- `ErrorCode::MintActionInvalidCpiContextAddressTreePubkey` (error code: 6105) - Invalid address tree pubkey in CPI context +- `ErrorCode::MintActionInvalidCompressedMintAddress` (error code: 6103) - Invalid compressed mint address derivation +- `ErrorCode::MintDataRequired` (error code: 6125) - Mint data required in instruction when not decompressed +- `ErrorCode::CannotDecompressAndCloseInSameInstruction` (error code: 6123) - Cannot combine DecompressMint and CompressAndCloseCMint in same instruction +- `ErrorCode::CompressAndCloseCMintMustBeOnlyAction` (error code: 6169) - CompressAndCloseCMint must be the only action in the instruction +- `ErrorCode::CpiContextSetNotUsable` (error code: 6035) - Mint to ctokens or decompress mint not allowed when writing to CPI context +- `CTokenError::MaxTopUpExceeded` - Max top-up budget exceeded + +### Spl mint migration +- cmint to spl mint migration is unimplemented and not planned. +- A way to support it in the future would require a new instruction that creates an spl mint in the mint pda solana account and mints the supply to the spl interface. diff --git a/programs/compressed-token/program/docs/instructions/compressed_token/THAW.md b/programs/compressed-token/program/docs/compressed_token/THAW.md similarity index 100% rename from programs/compressed-token/program/docs/instructions/compressed_token/THAW.md rename to programs/compressed-token/program/docs/compressed_token/THAW.md diff --git a/programs/compressed-token/program/docs/instructions/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md similarity index 79% rename from programs/compressed-token/program/docs/instructions/TRANSFER2.md rename to programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index 1bf886d016..c6feb2c5b3 100644 --- a/programs/compressed-token/program/docs/instructions/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -4,17 +4,18 @@ | I want to... | Go to | |-------------|-------| -| Transfer compressed tokens | → [Path B](#path-b-with-compressed-accounts-full-transfer-operations) (line 161) + [System accounts](#system-accounts-when-compressed-accounts-involved) (line 60) | -| Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) (line 134) + [Compressions-only accounts](#compressions-only-accounts-when-no_compressed_accounts) (line 99) | -| Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) (line 217) | -| Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) (line 227) | -| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) (line 243) - compression_authority only | -| Use CPI context | → [Write mode](#cpi-context-write-path) (line 192) or [Execute mode](#cpi-context-support-for-cross-program-invocations) (line 27) | -| Debug errors | → [Error reference](#errors) (line 275) | +| Understand which accounts to pass | → [Path Selection and Account Requirements](#path-selection-and-account-requirements) | +| Transfer compressed tokens | → [Path B](#path-b-with-compressed-accounts-full-transfer-operations) + [System accounts](#system-accounts-when-compressed-accounts-involved) | +| Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) + [Compressions-only accounts](#compressions-only-accounts-path-a-when-no_compressed_accountstrue) | +| Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) | +| Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) | +| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) - compression_authority only | +| Use CPI context | → [Write mode](#cpi-context-write-path) or [Execute mode](#cpi-context-support-for-cross-program-invocations) | +| Debug errors | → [Error reference](#errors) | **discriminator:** 101 **enum:** `InstructionType::Transfer2` -**path:** programs/compressed-token/program/src/transfer2/ +**path:** programs/compressed-token/program/src/compressed_token/transfer2/ **description:** 1. Batch transfer instruction supporting multiple token operations in a single transaction with up to 5 different mints (cmints or spl) @@ -42,7 +43,13 @@ **Instruction data:** 1. instruction data is defined in path: program-libs/ctoken-interface/src/instructions/transfer2/instruction_data.rs - `with_transaction_hash`: Compute transaction hash for the complete transaction and include in compressed account data, enables ZK proofs over how compressed accounts are spent - - `with_lamports_change_account_merkle_tree_index`: Track lamport changes in specified tree + - `with_lamports_change_account_merkle_tree_index`: bool - Track lamport changes in specified tree (placeholder, unimplemented) + - `lamports_change_account_merkle_tree_index`: u8 - Merkle tree index for lamport change account (placeholder, unimplemented) + - `lamports_change_account_owner_index`: u8 - Owner index for lamport change account (placeholder, unimplemented) + - `output_queue`: u8 - Output queue index for compressed account outputs + - `max_top_up`: u16 - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + - `cpi_context`: Optional CompressedCpiContext - Required for CPI operations; write mode: set either first_set_context or set_context (not both); execute mode: provide with all flags false + - `compressions`: Optional Vec - Compress/decompress operations - `proof`: Optional CompressedProof - Required for ZK validation of compressed inputs; not needed for proof by index or when no compressed inputs exist - `in_token_data`: Vec - Input compressed token accounts (packed: owner/delegate/mint are indices to packed accounts) with merkle context (root index, tree/queue indices, leaf index, proof-by-index bool) - `out_token_data`: Vec - Output compressed token accounts (packed: owner/delegate/mint/merkle_tree are indices to packed accounts) @@ -50,8 +57,6 @@ - `out_lamports`: Optional lamport amounts for output accounts (unimplemented) - `in_tlv`: Optional TLV data for input accounts (used for CompressedOnly extension during decompress) - `out_tlv`: Optional TLV data for output accounts (used for CompressedOnly extension during CompressAndClose) - - `compressions`: Optional Vec - Compress/decompress operations - - `cpi_context`: Optional CompressedCpiContext - Required for CPI operations; write mode: set either first_set_context or set_context (not both); execute mode: provide with all flags false 2. Compression struct fields (path: program-libs/ctoken-interface/src/instructions/transfer2/compression.rs): - `mode`: CompressionMode enum (Compress, Decompress, CompressAndClose) @@ -62,6 +67,7 @@ - `pool_account_index`: u8 - For SPL: pool account index; For CompressAndClose: rent_sponsor_index - `pool_index`: u8 - For SPL: pool index; For CompressAndClose: compressed_account_index - `bump`: u8 - For SPL: pool PDA bump; For CompressAndClose: destination_index + - `decimals`: u8 - For SPL: decimals for transfer_checked; For CompressAndClose: rent_sponsor_is_signer flag (non-zero = true) **Accounts:** 1. light_system_program @@ -74,49 +80,92 @@ System accounts (when compressed accounts involved): - (signer, mutable) - Pays transaction fees and rent for new compressed accounts -3. authority - - (signer) - - Transaction authority for system operations - -4. cpi_authority_pda - - PDA signer for CPI calls to light system program +3. cpi_authority_pda + - PDA for CPI calls to light system program - Seeds: [CPI_AUTHORITY_SEED] -5. registered_program_pda +4. registered_program_pda - Legacy account for program registration -6. account_compression_authority +5. account_compression_authority - Account compression authority PDA -7. account_compression_program +6. account_compression_program - Merkle tree account compression program -8. system_program +7. system_program - System program for account operations -9. sol_pool_pda (optional) +8. sol_pool_pda (optional) - (mutable) - Required when input_lamports != output_lamports - Handles lamport imbalances in compressed accounts -10. sol_decompression_recipient (optional) - - (mutable) - - Required when decompressing lamports (input_lamports < output_lamports) - - Receives decompressed SOL +9. sol_decompression_recipient (optional) + - (mutable) + - Required when decompressing lamports (input_lamports < output_lamports) + - Receives decompressed SOL -11. cpi_context_account (optional) +10. cpi_context_account (optional) - (mutable) - For storing CPI context data for later execution -Compressions-only accounts (when no_compressed_accounts): -12. compressions_only_cpi_authority_pda - - PDA signer for compression operations +--- + +### Path Selection and Account Requirements + +**Path selection logic:** The instruction determines which path to execute based on the `no_compressed_accounts` flag computed from instruction data: +``` +no_compressed_accounts = in_token_data.is_empty() && out_token_data.is_empty() +``` + +When `no_compressed_accounts=true`, the instruction executes **Path A** (compressions-only). +When `no_compressed_accounts=false`, the instruction executes **Path B** (full transfer with system CPI). + +**Account layout is mutually exclusive:** Callers must pass EITHER the Path A accounts OR the Path B accounts, never both. The account parser reads accounts sequentially and expects a specific layout based on the path: + +- **Path A layout:** `[cpi_authority_pda, fee_payer, ...packed_accounts]` +- **Path B layout:** `[light_system_program, fee_payer, cpi_authority_pda, registered_program_pda, account_compression_authority, account_compression_program, system_program, (optional accounts...), ...packed_accounts]` + +**Account Requirements by Path:** + +| Account | Path A (compressions-only) | Path B (with compressed accounts) | +|---------|---------------------------|----------------------------------| +| light_system_program | Not used | Required (position 0) | +| fee_payer | Required (position 1, signer) | Required (position 1, signer) | +| cpi_authority_pda | Required (position 0) | Required (position 2) | +| registered_program_pda | Not used | Required (position 3) | +| account_compression_authority | Not used | Required (position 4) | +| account_compression_program | Not used | Required (position 5) | +| system_program | Not used | Required (position 6) | +| sol_pool_pda | Not used | Optional (when lamport imbalance) | +| sol_decompression_recipient | Not used | Optional (when decompressing SOL) | +| cpi_context_account | Not used | Optional (for CPI context) | +| packed_accounts | After position 1 | After system/optional accounts | + +**Note:** `cpi_authority_pda` is the **same PDA** in both paths (seeds: `[CPI_AUTHORITY_SEED]`), just at different positions. + +--- + +### Compressions-only accounts (Path A: when no_compressed_accounts=true) + +When `no_compressed_accounts=true`, pass ONLY these accounts (do NOT include the Path B system accounts): + +1. cpi_authority_pda (position 0) + - PDA for signing SPL token transfers during compress/decompress - Seeds: [CPI_AUTHORITY_SEED] + - Same PDA as Path B, different position -13. compressions_only_fee_payer +2. fee_payer (position 1) - (signer, mutable) - Pays for compression/decompression operations +**Path A errors:** +- `CompressionsOnlyMissingCpiAuthority` (6097): cpi_authority_pda not provided at position 0 +- `CompressionsOnlyMissingFeePayer` (6096): fee_payer not provided at position 1 + +--- + Packed accounts (dynamic indexing): - merkle tree and queue accounts - For compressed account storage, nullifier tracking and output storage (must come first, identified by ACCOUNT_COMPRESSION_PROGRAM ownership) - mint accounts - Referenced by index in instruction data (account doesn't need to exist, only pubkey is used) @@ -130,6 +179,7 @@ Packed accounts (dynamic indexing): - Deserialize `CompressedTokenInstructionDataTransfer2` using zero-copy - Validate CPI context via `check_cpi_context`: Ensures `set_context || first_set_context` is false when `cpi_context` is Some - Validate instruction data via `validate_instruction_data`: + - Check input accounts limit (max 8 input compressed accounts, error: TooManyInputAccounts) - Check unimplemented features (`in_lamports`, `out_lamports`) are None - Validate `in_tlv` length matches `in_token_data` length if provided - Validate `out_tlv` length matches `out_token_data` length if provided @@ -299,15 +349,15 @@ When compression processing occurs (in both Path A and Path B): - **Note:** `compress_to_pubkey` is stored in the compressible extension and set during account creation, not per-instruction - Mint: Must match the ctoken account's mint field - Version: Must be ShaFlat (version=3) for security - - Version: Must match the version specified in the token account's compressible extension - **Delegate/Frozen state handling (with CompressedOnly extension):** - - If account has `compression_only` flag set (restricted mint), CompressedOnly extension is REQUIRED in output TLV - - CompressedOnly extension preserves: `is_frozen`, `delegated_amount`, `delegate` (in token_data), `withheld_transfer_fee` + - If account has `compression_only` flag set (restricted mint) or `is_ata` flag set (ATA accounts), CompressedOnly extension is REQUIRED in output TLV + - CompressedOnly extension preserves: `is_frozen`, `delegated_amount`, `delegate` (in token_data), `withheld_transfer_fee`, `is_ata` - Delegate: Must match between ctoken.delegate and compressed output delegate - Delegated amount: Must match between ctoken.delegated_amount and extension.delegated_amount - Frozen state: Must match between ctoken.state==2 and extension.is_frozen - Withheld fee: Must match between ctoken TransferFeeAccount.withheld_amount and extension.withheld_transfer_fee - - Error: `CompressAndCloseDelegatedAmountMismatch`, `CompressAndCloseInvalidDelegate`, `CompressAndCloseFrozenMismatch`, `CompressAndCloseWithheldFeeMismatch` + - is_ata: Must match between compressible_extension.is_ata() and extension.is_ata() + - Error: `CompressAndCloseDelegatedAmountMismatch`, `CompressAndCloseInvalidDelegate`, `CompressAndCloseFrozenMismatch`, `CompressAndCloseWithheldFeeMismatch`, `CompressAndCloseIsAtaMismatch` - **Delegate handling (without CompressedOnly extension):** - Delegate: Must be None (has_delegate=false and delegate=0) - delegates cannot be carried over without extension - Error: `CompressAndCloseDelegateNotAllowed` if source has delegate or output has delegate @@ -318,9 +368,9 @@ When compression processing occurs (in both Path A and Path B): - **Uniqueness validation:** All CompressAndClose operations in a single instruction must use different compressed output account indices. Duplicate output indices are rejected to prevent fund theft attacks where a compression_authority could close multiple accounts but route all funds to a single compressed output - Calculate compressible extension top-up if present (returns Option) - **Transfer deduplication optimization:** - - Collects all transfers into a 40-element array indexed by account + - Collects all transfers into a 40-element array indexed by packed account index - Deduplicates transfers to same account by summing amounts - - Executes single `multi_transfer_lamports` CPI with deduplicated transfers (max 40, error: TooManyCompressionTransfers) + - Executes single `multi_transfer_lamports` CPI with deduplicated transfers (max 32 compressions per instruction, error: TooManyCompressionTransfers) **Errors:** @@ -340,15 +390,17 @@ When compression processing occurs (in both Path A and Path B): - `CTokenError::CompressInsufficientFunds` (error code: 18019) - Insufficient balance for compression - `CTokenError::InsufficientSupply` (error code: 18010) - Insufficient token supply for operation - `CTokenError::ArithmeticOverflow` (error code: 18003) - Arithmetic overflow in balance calculations +- `CTokenError::TooManyInputAccounts` (error code: 18038) - Too many input compressed accounts. Maximum 8 input accounts allowed per instruction +- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds sender's max_top_up limit - `ErrorCode::SumCheckFailed` (error code: 6005) - Input/output token amounts don't match -- `ErrorCode::InputsOutOfOrder` (error code: 6054) - Sum inputs mint indices not in ascending order -- `ErrorCode::TooManyMints` (error code: 6055) - Sum check, too many mints (max 5) -- `ErrorCode::DuplicateMint` (error code: 6056) - Duplicate mint index detected in inputs, outputs, or compressions (same mint referenced by multiple indices or same index used multiple times) +- `ErrorCode::InputsOutOfOrder` (error code: 6038) - Sum inputs mint indices not in ascending order +- `ErrorCode::TooManyMints` (error code: 6039) - Sum check, too many mints (max 5) +- `ErrorCode::DuplicateMint` (error code: 6102) - Duplicate mint index detected in inputs, outputs, or compressions (same mint referenced by multiple indices or same index used multiple times) - `ErrorCode::ComputeOutputSumFailed` (error code: 6002) - Output mint not in inputs or compressions -- `ErrorCode::TooManyCompressionTransfers` (error code: 6106) - Too many compression transfers. Maximum 40 transfers allowed per instruction +- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Too many compression transfers. Maximum 32 compressions allowed per instruction - `ErrorCode::NoInputsProvided` (error code: 6025) - No compressions provided in early exit path (no compressed accounts) -- `ErrorCode::CompressionsOnlyMissingFeePayer` (error code: 6026) - Missing fee payer for compressions-only operations -- `ErrorCode::CompressionsOnlyMissingCpiAuthority` (error code: 6027) - Missing CPI authority PDA for compressions-only operations +- `ErrorCode::CompressionsOnlyMissingFeePayer` (error code: 6096) - Missing fee payer for compressions-only operations +- `ErrorCode::CompressionsOnlyMissingCpiAuthority` (error code: 6097) - Missing CPI authority PDA for compressions-only operations - `ErrorCode::OwnerMismatch` (error code: 6075) - Authority doesn't match account owner or delegate - `ErrorCode::Transfer2CpiContextWriteInvalidAccess` (error code: 6082) - Invalid access to system accounts during CPI write - `ErrorCode::Transfer2CpiContextWriteWithSolPool` (error code: 6083) - SOL pool operations not supported with CPI context write @@ -360,17 +412,18 @@ When compression processing occurs (in both Path A and Path B): - `ErrorCode::CompressAndCloseAmountMismatch` (error code: 6090) - Compression amount must match the full token balance - `ErrorCode::CompressAndCloseBalanceMismatch` (error code: 6091) - Token account balance must match compressed output amount - `ErrorCode::CompressAndCloseDelegateNotAllowed` (error code: 6092) - Source token account has delegate OR compressed output has delegate (delegates not supported) -- `ErrorCode::CompressAndCloseInvalidVersion` (error code: 6093) - Compressed token version must be 3 (ShaFlat) and must match compressible extension's account_version -- `ErrorCode::CompressAndCloseInvalidMint` (error code: 6108) - Compressed token mint does not match source token account mint -- `ErrorCode::CompressAndCloseMissingCompressedOnlyExtension` (error code: 6109) - Missing required CompressedOnly extension for restricted mint or frozen account -- `ErrorCode::CompressAndCloseDelegatedAmountMismatch` (error code: 6116) - Delegated amount mismatch between ctoken and CompressedOnly extension -- `ErrorCode::CompressAndCloseInvalidDelegate` (error code: 6118) - Delegate mismatch between ctoken and compressed token output -- `ErrorCode::CompressAndCloseWithheldFeeMismatch` (error code: 6120) - Withheld transfer fee mismatch -- `ErrorCode::CompressAndCloseFrozenMismatch` (error code: 6122) - Frozen state mismatch between ctoken and CompressedOnly extension -- `ErrorCode::CompressedOnlyRequiresCTokenDecompress` (error code: 6144) - CompressedOnly inputs must decompress to CToken account, not SPL token account -- `ErrorCode::TlvRequiresVersion3` (error code: 6123) - TLV extensions only supported with version 3 (ShaFlat) -- `ErrorCode::CompressAndCloseDuplicateOutput` (error code: 6420) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) -- `ErrorCode::CompressAndCloseOutputMissing` (error code: 6421) - Compressed token account output required but not provided +- `ErrorCode::CompressAndCloseInvalidVersion` (error code: 6093) - Compressed token version must be 3 (ShaFlat) +- `ErrorCode::CompressAndCloseInvalidMint` (error code: 6132) - Compressed token mint does not match source token account mint +- `ErrorCode::CompressAndCloseMissingCompressedOnlyExtension` (error code: 6133) - Missing required CompressedOnly extension for restricted mint or frozen account +- `ErrorCode::CompressAndCloseDelegatedAmountMismatch` (error code: 6135) - Delegated amount mismatch between ctoken and CompressedOnly extension +- `ErrorCode::CompressAndCloseInvalidDelegate` (error code: 6136) - Delegate mismatch between ctoken and compressed token output +- `ErrorCode::CompressAndCloseWithheldFeeMismatch` (error code: 6137) - Withheld transfer fee mismatch +- `ErrorCode::CompressAndCloseFrozenMismatch` (error code: 6138) - Frozen state mismatch between ctoken and CompressedOnly extension +- `ErrorCode::CompressAndCloseIsAtaMismatch` (error code: 6168) - is_ata mismatch between CompressibleExtension and CompressedOnly extension +- `ErrorCode::CompressedOnlyRequiresCTokenDecompress` (error code: 6149) - CompressedOnly inputs must decompress to CToken account, not SPL token account +- `ErrorCode::TlvRequiresVersion3` (error code: 6139) - TLV extensions only supported with version 3 (ShaFlat) +- `ErrorCode::CompressAndCloseDuplicateOutput` (error code: 6106) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) +- `ErrorCode::CompressAndCloseOutputMissing` (error code: 6107) - Compressed token account output required but not provided - `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing - `AccountError::AccountNotMutable` (error code: 12008) - Required mutable account is not mutable - Additional errors from close_token_account for CompressAndClose operations diff --git a/programs/compressed-token/program/docs/instructions/CLAIM.md b/programs/compressed-token/program/docs/compressible/CLAIM.md similarity index 51% rename from programs/compressed-token/program/docs/instructions/CLAIM.md rename to programs/compressed-token/program/docs/compressible/CLAIM.md index d49af25124..0395f3fb12 100644 --- a/programs/compressed-token/program/docs/instructions/CLAIM.md +++ b/programs/compressed-token/program/docs/compressible/CLAIM.md @@ -2,28 +2,32 @@ **discriminator:** 104 **enum:** `InstructionType::Claim` -**path:** programs/compressed-token/program/src/claim.rs +**path:** programs/compressed-token/program/src/compressible/claim.rs **description:** 1. Claims rent from compressible CToken and CMint solana accounts that have passed their rent expiration epochs 2. Supports both account types: - CToken (account_type = 2): decompressed token accounts, layout defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs - CMint (account_type = 1): decompressed mint accounts, layout defined in program-libs/ctoken-interface/src/state/mint/compressed_mint.rs -3. CompressionInfo is embedded directly in both account types (not as an extension), defined in program-libs/compressible/src/compression_info.rs +3. CompressionInfo storage differs by account type: + - CToken: CompressionInfo is stored inside a Compressible extension (not embedded directly) + - CMint: CompressionInfo is embedded directly in the mint struct at `compression` field + - CompressionInfo type defined in program-libs/compressible/src/compression_info.rs 4. Processes multiple token accounts in a single instruction for efficiency 5. For each eligible compressible account: - - Updates the account's RentConfig from the CompressibleConfig - - Updates the config_account_version to match current config version + - Validates config_account_version matches CompressibleConfig version - Calculates claimable rent based on completed epochs since last claim - - Updates the `last_claimed_slot` in the compressible extension + - Updates the `last_claimed_slot` in the CompressionInfo + - Updates the account's RentConfig from the CompressibleConfig (after claim calculation) - Transfers claimable lamports from token account to rent sponsor PDA -6. RentConfig is updated for ALL accounts with compressible extension (even those without claimable rent) -7. Only accounts with compressible extension can be claimed from +6. RentConfig is updated for ALL accounts that pass validation (even those without claimable rent) +7. CToken accounts must have Compressible extension; CMint accounts have CompressionInfo embedded directly 8. Only the compression authority (from CompressibleConfig) can execute claims 9. **Config validation:** Config must not be inactive (active or deprecated allowed) -10. Accounts that don't meet claim criteria are skipped without error -11. Only completed epochs are claimed, partial epochs remain with the account -12. The instruction is designed to be called periodically by foresters +10. Accounts that don't match compression_authority or rent_sponsor are skipped without error (returns None) +11. Accounts with mismatched config_account_version return error (CompressibleError::InvalidVersion) +12. Only completed epochs are claimed, partial epochs remain with the account +13. The instruction is designed to be called periodically by foresters **Instruction data:** - Empty (zero bytes required) @@ -50,9 +54,10 @@ 4. accounts (remaining accounts) - (mutable, variable number) - CToken or CMint accounts to claim rent from - - Account type determined by byte 165 (1 = CMint, 2 = CToken) or size (165 bytes = CToken) + - Account type determined by size: exactly 165 bytes = CToken, otherwise read byte 165 (1 = CMint, 2 = CToken) - Each account is processed independently - - Invalid accounts (wrong authority/recipient/type) are skipped without error + - Accounts with wrong compression_authority or rent_sponsor are skipped without error (returns None) + - Accounts with wrong owner, invalid size, or invalid type discriminator return error **Instruction Logic and Checks:** @@ -74,52 +79,55 @@ 4. **Process each account:** For each account in remaining accounts: - a. **Determine account type:** - - If account size < 165 bytes: invalid, skip - - If account size == 165 bytes: CToken (legacy) + a. **Verify account ownership:** + - Account must be owned by the compressed token program + - Uses `check_owner` with CTOKEN program ID + + b. **Determine account type:** + - If account size < 165 bytes: invalid, return error + - If account size == 165 bytes: CToken (legacy size without extensions) - If account size > 165 bytes: read byte 165 for discriminator (1 = CMint, 2 = CToken) - b. **Parse account data:** + c. **Parse account data:** - Borrow mutable data - Deserialize as CToken or CMint based on account type with zero-copy - - c. **Validate compression info:** - - Access embedded CompressionInfo from account - - Validate compression_authority matches - - Validate rent_sponsor matches - - d. **Validate version:** - - Verify `compression.config_account_version` matches CompressibleConfig version - - Error with `CompressibleError::InvalidVersion` if versions don't match (prevents cross-version claims) - - e. **Calculate and claim rent:** - - Get account size and current lamports - - Calculate rent exemption for account size - - Call `compression.claim()` which: - - Determines completed epochs since last claim using CURRENT RentConfig - - Calculates claimable lamports - - Updates last_claimed_slot if there's claimable rent - - Returns None if no rent to claim (account not yet compressible) - - After claim calculation, always update `compression.rent_config` from CompressibleConfig for future operations - - f. **Transfer lamports:** + - For CToken: uses `CToken::zero_copy_at_mut_checked()` then `get_compressible_extension_mut()` + - For CMint: uses `CompressedMint::zero_copy_at_mut_checked()` then accesses `base.compression` + + d. **Call claim_and_update (in CompressionInfo):** + - Validate compression_authority matches (returns None if mismatch, skips account) + - Validate rent_sponsor matches (returns None if mismatch, skips account) + - Verify `config_account_version` matches CompressibleConfig version + - Returns `CompressibleError::InvalidVersion` error if versions don't match + - Call internal `claim()` method which: + - Calculates claimable lamports based on completed epochs + - Updates `last_claimed_slot` if there's claimable rent + - Returns claimed amount or None if nothing to claim + - Always update `rent_config` from CompressibleConfig (even if claim returned None) + + e. **Transfer lamports:** - If claim amount > 0, transfer from account to rent_sponsor - - Update both account balances + - Uses `transfer_lamports` helper function 5. **Complete successfully:** - - All valid accounts processed - - Invalid accounts silently skipped + - All accounts processed + - Accounts with mismatched compression_authority/rent_sponsor are skipped (no error) **Errors:** - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not empty -- `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig/CToken deserialization fails, account type discriminator invalid, or claim calculation fails -- `ErrorCode::InvalidCompressAuthority` - compression_authority doesn't match CompressibleConfig -- `ErrorCode::InvalidRentSponsor` - rent_sponsor doesn't match CompressibleConfig -- `CompressibleError::InvalidVersion` (error code: 19003) - Account's config_account_version doesn't match CompressibleConfig version +- `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig/CToken/CMint deserialization fails, account size < 165 bytes, or account type discriminator invalid +- `ErrorCode::InvalidCompressAuthority` - compression_authority doesn't match CompressibleConfig (fixed account validation) +- `ErrorCode::InvalidRentSponsor` - rent_sponsor doesn't match CompressibleConfig (fixed account validation) +- `CompressibleError::InvalidVersion` (error code: 19003) - Account's config_account_version doesn't match CompressibleConfig version (per-account validation) - `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account lacks required Compressible extension -- `AccountError::NotEnoughAccountKeys` (error code: 20014) - Missing required accounts +- `AccountError::NotEnoughAccountKeys` (error code: 20014) - Missing required fixed accounts (rent_sponsor, compression_authority, config) - `AccountError::InvalidSigner` (error code: 20009) - compression_authority is not a signer - `AccountError::AccountNotMutable` (error code: 20002) - rent_sponsor is not mutable -- `AccountError::AccountOwnedByWrongProgram` (error code: 20001) - Token account not owned by compressed token program +- `AccountError::AccountOwnedByWrongProgram` (error code: 20001) - Token/Mint account not owned by compressed token program - `CompressibleError::InvalidState` (error code: 19002) - CompressibleConfig is in inactive state + +**Note on error vs skip behavior:** +- Fixed account validation errors (compression_authority, rent_sponsor, config) cause instruction failure +- Per-account compression_authority/rent_sponsor mismatch causes that account to be skipped (returns None) +- Per-account config version mismatch causes instruction failure with InvalidVersion error diff --git a/programs/compressed-token/program/docs/instructions/WITHDRAW_FUNDING_POOL.md b/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md similarity index 81% rename from programs/compressed-token/program/docs/instructions/WITHDRAW_FUNDING_POOL.md rename to programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md index f13a03b0b3..ff89f1d2e3 100644 --- a/programs/compressed-token/program/docs/instructions/WITHDRAW_FUNDING_POOL.md +++ b/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md @@ -2,16 +2,16 @@ **discriminator:** 105 **enum:** `InstructionType::WithdrawFundingPool` -**path:** programs/compressed-token/program/src/withdraw_funding_pool.rs +**path:** programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs **description:** 1. Withdraws lamports from the rent_sponsor PDA pool to a specified destination account 2. The rent_sponsor PDA holds funds collected from rent claims and compression incentives 3. Only the compression_authority from CompressibleConfig can execute withdrawals 4. **Config validation:** Config must not be inactive (active or deprecated allowed) -5. The rent_sponsor PDA is derived from ["rent_sponsor", version_bytes, bump] where version comes from CompressibleConfig +5. The rent_sponsor PDA is derived from ["rent_sponsor", version_bytes, bump] where version is a u16 from CompressibleConfig serialized as little-endian bytes 6. Enables protocol operators to manage collected rent and redirect funds for operational needs -7. The instruction validates PDA derivation matches the config's rent_sponsor +7. The instruction validates rent_sponsor and compression_authority match the config **Instruction data:** - First 8 bytes: withdrawal amount (u64, little-endian) @@ -38,7 +38,7 @@ 4. system_program - (non-mutable) - System program for lamport transfer - - Required for system_instruction::transfer + - Required for pinocchio_system Transfer instruction 5. config - (non-mutable) @@ -54,14 +54,15 @@ - Error if instruction data length < 8 bytes 2. **Validate and parse accounts:** - - Parse all required accounts with correct mutability + - Parse all required accounts with correct mutability using AccountIterator - Verify compression_authority is signer - Parse and validate CompressibleConfig: - - Deserialize using parse_config_account helper + - Check owner is Registry program + - Validate discriminator and deserialize using bytemuck - Check config is not inactive (validate_not_inactive) - Verify compression_authority matches config - Verify rent_sponsor matches config - - Extract rent_sponsor_bump and version for PDA derivation + - Extract rent_sponsor_bump and version (u16 as little-endian bytes) for PDA derivation 3. **Verify sufficient funds:** - Get current pool balance from rent_sponsor.lamports() @@ -69,8 +70,8 @@ - Error if insufficient funds 4. **Execute transfer:** - - Create system_instruction::transfer from rent_sponsor to destination - - Prepare PDA signer seeds: ["rent_sponsor", version_bytes, bump] + - Create pinocchio_system Transfer struct from rent_sponsor to destination + - Prepare PDA signer seeds: [b"rent_sponsor", version_bytes (2 bytes), bump (1 byte)] - Invoke system program with PDA as signer using invoke_signed - Transfer specified amount to destination diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE.md b/programs/compressed-token/program/docs/ctoken/APPROVE.md similarity index 57% rename from programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE.md rename to programs/compressed-token/program/docs/ctoken/APPROVE.md index 8fcac00f4f..5a735f0845 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE.md +++ b/programs/compressed-token/program/docs/ctoken/APPROVE.md @@ -2,7 +2,7 @@ **discriminator:** 4 **enum:** `InstructionType::CTokenApprove` -**path:** programs/compressed-token/program/src/ctoken_approve_revoke.rs +**path:** programs/compressed-token/program/src/ctoken/approve_revoke.rs ### SPL Instruction Format Compatibility This instruction is compatible with the SPL Token instruction format (using `spl_token_2022::instruction::approve` with changed program ID) when **no top-up is required**. @@ -14,10 +14,10 @@ If the CToken account has a compressible extension and requires a rent top-up, t - **NOT SPL-compatible (system program required):** Compressible accounts that need rent top-up based on current slot **description:** -Delegates a specified amount to a delegate authority on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). Before the approve operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible approve semantics. Supports backwards-compatible instruction data format (8 bytes legacy vs 10 bytes with max_top_up). +Delegates a specified amount to a delegate authority on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). After the SPL approve operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible approve semantics. Supports backwards-compatible instruction data format (8 bytes legacy vs 10 bytes with max_top_up). **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 34-66) +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 14-15, 98-106) - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to delegate - Bytes 8-9 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit, default for legacy format) @@ -41,41 +41,38 @@ Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 34-6 **Instruction Logic and Checks:** -1. **Validate minimum accounts:** - - Require source account (index 0) and owner account (index 2) - - Return NotEnoughAccountKeys if either account is missing - - Note: delegate (index 1) is validated by pinocchio during SPL approve - -2. **Parse instruction data:** - - If 8 bytes: legacy format, set max_top_up = 0 (no limit) - - If 10 bytes: parse amount (first 8 bytes) and max_top_up (last 2 bytes) - - Return InvalidInstructionData for any other length - -3. **Process compressible top-up:** - - Borrow source account data mutably - - Deserialize CToken using zero-copy validation - - Initialize lamports_budget based on max_top_up: - - If max_top_up == 0: budget = u64::MAX (no limit) - - Otherwise: budget = max_top_up + 1 (allows exact match) - - Call process_compression_top_up with source account's compression info - - Drop borrow before CPI - - If transfer_amount > 0: - - Check that transfer_amount <= lamports_budget - - Return MaxTopUpExceeded if budget exceeded - - Transfer lamports from owner to source via CPI +1. **Validate minimum accounts and instruction data:** + - Return NotEnoughAccountKeys if accounts array is empty + - Return InvalidInstructionData if instruction data is less than 8 bytes + - Note: delegate (index 1) and owner (index 2) are validated by pinocchio during SPL approve -4. **Process SPL approve:** +2. **Process SPL approve:** - Pass only first 8 bytes (amount) to pinocchio-token-program - Call process_approve with accounts and amount data - Delegate is granted spending rights for the specified amount +3. **Handle compressible top-up (hot path optimization):** + - If source account data length is exactly 165 bytes, skip top-up (no extensions) + - Otherwise, call process_compressible_top_up + +4. **Process compressible top-up (cold path):** + - Parse max_top_up from instruction data: + - If 8 bytes: legacy format, set max_top_up = 0 (no limit) + - If 10 bytes: parse max_top_up from last 2 bytes + - Return InvalidInstructionData for any other length + - Read CompressionInfo directly from account bytes using bytemuck (no full CToken deserialization) + - Calculate transfer_amount using `top_up_lamports_from_account_info_unchecked` + - If transfer_amount > 0: + - If max_top_up > 0 and transfer_amount > max_top_up: return MaxTopUpExceeded + - Get payer account (index 2), return MissingPayer if not present + - Transfer lamports from payer to source via CPI + **Errors:** - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 8 or 10 bytes -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- `ProgramError::NotEnoughAccountKeys` (error code: 11) - No accounts provided - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter +- `CTokenError::MissingPayer` (error code: 18061) - Payer account (index 2) not provided when top-up is required - `ProgramError::MissingRequiredSignature` (error code: 8) - Owner did not sign the transaction (SPL Token error) - Pinocchio token errors (converted to ProgramError::Custom): - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match account owner @@ -102,27 +99,31 @@ CToken Approve maintains compatibility with SPL Token-2022's core approve functi **1. Compressible Extension Top-Up Logic** -CToken Approve includes automatic rent top-up for accounts with the Compressible extension: +CToken Approve includes automatic rent top-up for accounts with the Compressible extension. The top-up happens AFTER the SPL approve operation: ```rust -// Before SPL approve operation -process_compression_top_up( - &ctoken.base.compression, - account, - &mut 0, - &mut transfer_amount, - &mut lamports_budget, -)?; - -// Transfer lamports from owner to source if needed +// After SPL approve operation succeeds +// Hot path: 165-byte accounts have no extensions, skip top-up +if source.data_len() == 165 { + return Ok(()); +} + +// Cold path: calculate and transfer top-up if needed +let transfer_amount = top_up_lamports_from_account_info_unchecked(account, &mut current_slot) + .unwrap_or(0); + if transfer_amount > 0 { + if max_top_up > 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + let payer = payer.ok_or(CTokenError::MissingPayer)?; transfer_lamports_via_cpi(transfer_amount, payer, account)?; } ``` **Purpose**: Prevents accounts from becoming compressible during normal operations by maintaining minimum rent balance. -**Reference**: See `/home/ananas/dev/light-protocol/program-libs/compressible/docs/RENT.md` for rent calculation details. +**Reference**: See `program-libs/compressible/docs/RENT.md` for rent calculation details. **2. max_top_up Parameter** @@ -132,14 +133,8 @@ Extended instruction data format (10 bytes total): **Enforcement**: ```rust -let lamports_budget = if max_top_up == 0 { - u64::MAX // No limit -} else { - (max_top_up as u64).saturating_add(1) // Allow exact match -}; - -if lamports_budget != 0 && transfer_amount > lamports_budget { - return Err(CTokenError::MaxTopUpExceeded); +if max_top_up > 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); } ``` @@ -152,4 +147,4 @@ if lamports_budget != 0 && transfer_amount > lamports_budget { ### Related Instructions -**ApproveChecked:** CToken implements CTokenApproveChecked (discriminator: 13) with full decimals validation. See `CTOKEN_APPROVE_CHECKED.md`. +**Note:** Unlike SPL Token/Token-2022, CToken does NOT implement ApproveChecked (discriminator 13). Only the basic Approve instruction is supported. diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN.md b/programs/compressed-token/program/docs/ctoken/BURN.md similarity index 75% rename from programs/compressed-token/program/docs/instructions/CTOKEN_BURN.md rename to programs/compressed-token/program/docs/ctoken/BURN.md index 86cf799197..93843030e7 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN.md +++ b/programs/compressed-token/program/docs/ctoken/BURN.md @@ -2,7 +2,7 @@ **discriminator:** 8 **enum:** `InstructionType::CTokenBurn` -**path:** programs/compressed-token/program/src/ctoken_burn.rs +**path:** programs/compressed-token/program/src/ctoken/burn.rs **description:** Burns tokens from a decompressed CToken account and decreases the CMint supply, fully compatible with SPL Token burn semantics. Account layout `CToken` is defined in `program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs`. Account layout `CompressedMint` (CMint) is defined in `program-libs/ctoken-interface/src/state/mint/compressed_mint.rs`. Extension layout `CompressionInfo` is defined in `program-libs/compressible/src/compression_info.rs` and is embedded in both CToken and CMint structs. Uses pinocchio-token-program to process the burn (handles balance/supply updates, authority check, frozen check). After the burn, automatically tops up compressible accounts with additional lamports if needed. Top-up is calculated for both CMint and source CToken based on current slot and account balance. Top-up prevents accounts from becoming compressible during normal operations. Enforces max_top_up limit if provided (transaction fails if exceeded). Supports max_top_up parameter to limit rent top-up costs (0 = no limit). Instruction data is backwards-compatible: 8-byte format (legacy, no max_top_up enforcement) and 10-byte format (with max_top_up). This instruction only works with CMints (compressed mints). CMints do not support restricted Token-2022 extensions (Pausable, TransferFee, TransferHook, PermanentDelegate, DefaultAccountState) - only TokenMetadata is allowed. To burn tokens from spl or T22 mints, use Transfer2 with decompress mode to convert to SPL tokens first, then burn via SPL Token-2022. @@ -67,20 +67,23 @@ Format 2 (10 bytes): - Initialize lamports_budget to max_top_up + 1 (allowing exact match when total == max_top_up) b. **Calculate CMint top-up:** - - Borrow CMint data and deserialize using `CompressedMint::zero_copy_at` - - Access compression info directly from mint.base.compression (embedded in all CMints) - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded (current_slot == 0) - - Call `compression.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call optimized `cmint_top_up_lamports_from_account_info(cmint, current_slot, program_id)` + - Verifies account owner matches expected program ID + - Validates minimum size (262 bytes) and account_type field + - Reads CompressionInfo directly from fixed byte offset (166) + - Lazy loads Clock sysvar if current_slot == 0 + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Returns None if any validation fails (no error, gracefully skipped) + - Subtracts calculated top-up from lamports_budget c. **Calculate CToken top-up:** - - Skip if CToken data length is 165 bytes (no extensions, standard SPL token account) - - Borrow CToken data and deserialize using `CToken::zero_copy_at_checked` - - Get Compressible extension via `token.get_compressible_extension()` - - Fail with MissingCompressibleExtension if CToken has extensions but no Compressible extension - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded (current_slot == 0) - - Call `compressible.info.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call optimized `top_up_lamports_from_account_info_unchecked(ctoken, current_slot)` + - Validates minimum size (272 bytes), account_type, Option discriminator, and first extension type + - Reads CompressionInfo directly from fixed byte offset (176) + - Returns None if CToken is 165 bytes (no extensions) or lacks Compressible as first extension + - Lazy loads Clock sysvar if current_slot == 0 + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Subtracts calculated top-up from lamports_budget d. **Validate budget:** - If no compressible accounts were found (current_slot == 0), exit early @@ -88,6 +91,7 @@ Format 2 (10 bytes): - If max_top_up != 0 and lamports_budget == 0, fail with MaxTopUpExceeded e. **Execute transfers:** + - Fail with MissingPayer if payer account is not provided - Call `multi_transfer_lamports(payer, &transfers)` to atomically transfer lamports - Updates account balances for both CMint and CToken if needed @@ -96,18 +100,12 @@ Format 2 (10 bytes): - `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data length is not 8 or 10 bytes - `ProgramError::InsufficientFunds` (error code: 6) - Source CToken balance less than burn amount (from pinocchio burn), or payer has insufficient funds for top-up transfers -- `ProgramError::ArithmeticOverflow` (error code: 24) - Overflow when calculating total top-up amount - Pinocchio token errors (converted to ProgramError::Custom): - `TokenError::OwnerMismatch` (error code: 4) - Authority is not owner or delegate - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Account data length is too small, calculate top-up failed, or invalid account format -- `CTokenError::InvalidAccountState` (error code: 18036) - CToken account is not initialized (from zero-copy parsing) -- `CTokenError::InvalidAccountType` (error code: 18053) - Account is not a CToken account type (from zero-copy parsing) -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account has extensions but missing required Compressible extension +- `CTokenError::MissingPayer` (error code: 18061) - Payer account not provided but top-up is required ## Comparison with Token-2022 diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN_CHECKED.md b/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md similarity index 75% rename from programs/compressed-token/program/docs/instructions/CTOKEN_BURN_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md index bfb0712561..9ea1e627d7 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md @@ -2,7 +2,7 @@ **discriminator:** 15 **enum:** `InstructionType::CTokenBurnChecked` -**path:** programs/compressed-token/program/src/ctoken_burn.rs +**path:** programs/compressed-token/program/src/ctoken/burn.rs **description:** Burns tokens from a decompressed CToken account and decreases the CMint supply with decimals validation, fully compatible with SPL Token BurnChecked semantics. Account layout `CToken` is defined in `program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs`. Account layout `CompressedMint` (CMint) is defined in `program-libs/ctoken-interface/src/state/mint/compressed_mint.rs`. Extension layout `CompressionInfo` is defined in `program-libs/compressible/src/compression_info.rs` and is embedded in both CToken and CMint structs. Uses pinocchio-token-program to process the burn_checked (handles balance/supply updates, authority check, frozen check, decimals validation). After the burn, automatically tops up compressible accounts with additional lamports if needed. Top-up prevents accounts from becoming compressible during normal operations. Enforces max_top_up limit if provided (transaction fails if exceeded). Account order is REVERSED from mint_to instruction: [source_ctoken, cmint, authority] vs mint_to's [cmint, destination_ctoken, authority]. @@ -71,25 +71,33 @@ Format 2 (11 bytes): - Initialize lamports_budget to max_top_up + 1 (allowing exact match when total == max_top_up) b. **Calculate CMint top-up:** - - Borrow CMint data and deserialize using `CompressedMint::zero_copy_at` - - Access compression info directly from mint.base.compression - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded - - Call `compression.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call `cmint_top_up_lamports_from_account_info(cmint, current_slot, program_id)` + - Verifies CMint is owned by the expected program + - Checks data length >= 262 bytes (minimum for CMint with CompressionInfo) + - Validates account_type byte at offset 165 is ACCOUNT_TYPE_MINT + - Reads CompressionInfo directly from bytes using `CompressionInfo::zero_copy_at` + - Lazy loads Clock sysvar for current_slot if needed + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Returns None (skip) if any validation fails + - Subtracts calculated top-up from lamports_budget c. **Calculate CToken top-up:** - - Skip if CToken data length is 165 bytes (no extensions, standard SPL token account) - - Borrow CToken data and deserialize using `CToken::zero_copy_at_checked` - - Get Compressible extension via `token.get_compressible_extension()` - - Fail with MissingCompressibleExtension if CToken has extensions but no Compressible extension - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded - - Call `compressible.info.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call `top_up_lamports_from_account_info_unchecked(ctoken, current_slot)` + - Returns None (skip) if CToken data length < 272 bytes (minimum for Compressible extension) + - Validates account_type byte at offset 165 is ACCOUNT_TYPE_TOKEN_ACCOUNT + - Validates Option discriminator at offset 166 is Some + - Validates first extension discriminator at offset 171 is Compressible + - Reads CompressionInfo directly via bytemuck from bytes at offset 176 + - Lazy loads Clock sysvar for current_slot if needed + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Returns None (skip) if any validation fails + - Subtracts calculated top-up from lamports_budget d. **Validate budget:** - If no compressible accounts were found (current_slot == 0), exit early - If both top-up amounts are 0, exit early - If max_top_up != 0 and lamports_budget == 0, fail with MaxTopUpExceeded + - If payer is None but top-up is needed, fail with MissingPayer e. **Execute transfers:** - Call `multi_transfer_lamports(payer, &transfers)` to atomically transfer lamports @@ -100,19 +108,13 @@ Format 2 (11 bytes): - `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data length is not 9 or 11 bytes - `ProgramError::InsufficientFunds` (error code: 6) - Source CToken balance less than burn amount (from pinocchio burn), or payer has insufficient funds for top-up transfers -- `ProgramError::ArithmeticOverflow` (error code: 24) - Overflow when calculating total top-up amount -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::OwnerMismatch` (error code: 4) - Authority is not owner or delegate - - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - - `TokenError::MintDecimalsMismatch` (error code: 18) - Decimals don't match CMint's decimals - - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Account data length is too small, calculate top-up failed, or invalid account format -- `CTokenError::InvalidAccountState` (error code: 18036) - CToken account is not initialized (from zero-copy parsing) -- `CTokenError::InvalidAccountType` (error code: 18053) - Account is not a CToken account type (from zero-copy parsing) -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- Pinocchio token errors (converted to ErrorCode variants via convert_pinocchio_token_error): + - `ErrorCode::OwnerMismatch` - Authority is not owner or delegate + - `ErrorCode::MintMismatch` - CToken mint doesn't match CMint + - `ErrorCode::MintDecimalsMismatch` - Decimals don't match CMint's decimals + - `ErrorCode::AccountFrozen` - CToken account is frozen - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account has extensions but missing required Compressible extension +- `CTokenError::MissingPayer` (error code: 18061) - Payer account required for top-up but not provided ## Comparison with Token-2022 @@ -167,7 +169,7 @@ CToken BurnChecked implements similar core functionality to SPL Token-2022's Bur 3. **Decimals Validation:** - Pinocchio validates instruction decimals against CMint's decimals field - - Returns MintDecimalsMismatch (error code: 18) on mismatch + - Returns ErrorCode::MintDecimalsMismatch on mismatch ### Security Properties diff --git a/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/CLOSE.md similarity index 71% rename from programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/CLOSE.md index be6e2fa198..9b179d0535 100644 --- a/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/CLOSE.md @@ -1,16 +1,16 @@ ## Close Token Account **discriminator:** 9 -**enum:** `CTokenInstruction::CloseTokenAccount` -**path:** programs/compressed-token/program/src/close_token_account/ +**enum:** `InstructionType::CloseTokenAccount` +**path:** programs/compressed-token/program/src/ctoken/close/ **description:** 1. Closes decompressed ctoken solana accounts and distributes remaining lamports to destination account. 2. Account layout `CToken` is defined in path: program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs 3. Supports both regular (non-compressible) and compressible token accounts (with compressible extension) 4. For compressible accounts (with compressible extension): - - Rent exemption + unclaimed rent lamports are returned to the rent_sponsor - - Remaining user lamports are returned to the destination account + - Completed epoch rent lamports are returned to the rent_sponsor + - Partial/unutilized epoch lamports are returned to the destination account (user) - Only the owner or close_authority (if set) can close using this instruction (balance must be zero) - **Note:** To compress and close with non-zero balance, use CompressAndClose mode in Transfer2 (compression_authority only) - **Note:** It is impossible to set a close authority. @@ -45,31 +45,29 @@ 4. rent_sponsor (optional, required for compressible accounts) - (mutable) - - Receives rent exemption + unclaimed rent for compressible accounts + - Receives completed epoch rent lamports for compressible accounts - Must match the rent_sponsor field in the compressible extension - Not required for non-compressible accounts (only 3 accounts needed) **Instruction Logic and Checks:** 1. **Parse and validate accounts** (`validate_and_parse` in `accounts.rs`): - - Extract token_account (index 0), destination (index 1), authority (index 2) - - Extract rent_sponsor (index 3) if accounts.len() >= 4 (required for compressible accounts) - - Verify token_account is mutable via `check_mut` + - Extract token_account (index 0) via `iter.next_mut()` (validates mutability) - Verify token_account is owned by ctoken program via `check_owner` - - Verify destination is mutable via `check_mut` - - Verify authority is a signer via `check_signer` - - If rent_sponsor provided: verify rent_sponsor is mutable via `check_mut` + - Extract destination (index 1) via `iter.next_mut()` (validates mutability) + - Extract authority (index 2) via `iter.next_signer()` (validates signer) + - If accounts.len() >= 4: extract rent_sponsor (index 3) via `iter.next_mut()` (validates mutability) 2. **Deserialize and validate token account** (`process_close_token_account` in `processor.rs`): - Borrow token account data mutably - - Parse as `CToken` using `zero_copy_at_mut_checked` (validates initialized state and account type) - - Call `validate_token_account` (CHECK_RENT_AUTH=false for regular close) + - Parse as `CToken` using `CToken::from_account_info_mut_checked` (validates program ownership, initialized state and account type) + - Call `validate_token_account_close` to validate closure requirements -3. **Validate closure requirements** (`validate_token_account`): +3. **Validate closure requirements** (`validate_token_account_close` in `processor.rs`): 3.1. **Basic validation**: - Verify token_account.key() != destination.key() (prevents self-transfer) - 3.2. **Balance check** (only when COMPRESS_AND_CLOSE=false): + 3.2. **Balance check**: - Convert ctoken.amount from U64 to u64 - Verify amount == 0 (non-zero returns `ErrorCode::NonNativeHasBalance`) @@ -80,7 +78,8 @@ - Fall through to close_authority/owner check (compression_authority cannot use this instruction) 3.4. **Account state check**: - - Check account state field equals AccountState::Initialized (value 1): + - Note: `from_account_info_mut_checked` already validates that state == Initialized (1) + - Additional validation for frozen/uninitialized states (redundant but explicit): - If state == AccountState::Frozen (value 2): return `ErrorCode::AccountFrozen` - If state is any other value: return `ProgramError::UninitializedAccount` @@ -97,25 +96,21 @@ 4.2. **Check for compressible extension**: - Borrow token account data (read-only this time) - - Parse as CToken using `zero_copy_at_checked` - - Look for `ZExtensionStruct::Compressible` in extensions + - Parse as CToken using `CToken::from_account_info_checked` + - Look for `ZExtensionStruct::Compressible` in extensions via `get_compressible_extension()` 4.3. **For compressible accounts** (if extension found): - Get current_slot from Clock::get() sysvar - - Calculate base_lamports using `get_rent_exemption_lamports(account.data_len)` + - Calculate base_lamports from `compression.info.rent_exemption_paid` - Create `AccountRentState` with: - num_bytes, current_slot, current_lamports, last_claimed_slot - Call `calculate_close_distribution` with: - rent_config, base_lamports - Returns `CloseDistribution { to_rent_sponsor, to_user }` - Get rent_sponsor account from accounts (error if missing) - - For regular close (owner/close_authority): - - Transfer to_rent_sponsor lamports to rent_sponsor via `transfer_lamports` (if > 0) - - Transfer to_user lamports to destination via `transfer_lamports` (if > 0) - - For CompressAndClose (compression_authority in Transfer2): - - Extract compression_cost from rent_sponsor portion as forester reward - - Add to_user to rent_sponsor portion (unused funds go to rent_sponsor) - - Transfer adjusted lamports to rent_sponsor and compression_cost to destination (forester) + - Check if authority is compression_authority: + - If compression_authority: Extract compression_cost from rent_sponsor portion as forester reward, add to_user to rent_sponsor portion (unused funds go to rent_sponsor), transfer adjusted lamports to rent_sponsor and compression_cost to destination (forester) + - Otherwise (owner/close_authority): Transfer to_rent_sponsor lamports to rent_sponsor via `transfer_lamports` (if > 0), transfer to_user lamports to destination via `transfer_lamports` (if > 0) - Return early (skip non-compressible path) 4.4. **For non-compressible accounts**: @@ -132,17 +127,30 @@ - Maps resize error to ProgramError::Custom if fails **Errors:** -- `ProgramError::InvalidAccountData` (error code: 4) - token_account == destination, rent_sponsor doesn't match extension, compression_authority mismatch, or account not compressible -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Missing rent_sponsor account for compressible accounts -- `AccountError::InvalidSigner` (error code: 12015) - Authority is not a signer + +*Account parsing errors (from `validate_and_parse`):* - `AccountError::AccountNotMutable` (error code: 12008) - token_account, destination, or rent_sponsor is not mutable +- `AccountError::InvalidSigner` (error code: 12015) - Authority is not a signer - `AccountError::AccountOwnedByWrongProgram` (error code: 12007) - token_account is not owned by ctoken program - `AccountError::NotEnoughAccountKeys` (error code: 12020) - Not enough accounts provided + +*CToken deserialization errors (from `from_account_info_mut_checked`):* +- `CTokenError::InvalidCTokenOwner` (error code: 18063) - token_account not owned by ctoken program +- `CTokenError::BorrowFailed` (error code: 18062) - Failed to borrow account data +- `CTokenError::InvalidAccountState` (error code: 18036) - Account state is not Initialized (state != 1) +- `CTokenError::InvalidAccountType` (error code: 18053) - Account type discriminator is invalid +- `CTokenError::InvalidAccountData` (error code: 18002) - Account has trailing bytes after CToken structure + +*Validation errors (from `validate_token_account_close`):* +- `ProgramError::InvalidAccountData` (error code: 4) - token_account == destination, or rent_sponsor doesn't match extension +- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Missing rent_sponsor account for compressible accounts +- `ErrorCode::NonNativeHasBalance` (error code: 6074) - Account has non-zero token balance - `ErrorCode::AccountFrozen` (error code: 6076) - Account state is Frozen - `ProgramError::UninitializedAccount` (error code: 10) - Account state is Uninitialized or invalid -- `ErrorCode::NonNativeHasBalance` (error code: 6074) - Account has non-zero token balance - `ErrorCode::OwnerMismatch` (error code: 6075) - Authority doesn't match owner or close_authority -- `ProgramError::InsufficientFunds` (error code: 6) - Insufficient funds for lamport transfer during rent calculation + +*Lamport distribution errors (from `distribute_lamports`):* +- `ProgramError::InsufficientFunds` (error code: 6) - Insufficient funds for compression_cost subtraction **Edge Cases and Considerations:** - Only the close_authority (if set) or owner (if close_authority is None) can use this instruction (CloseTokenAccount) diff --git a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/CREATE.md similarity index 69% rename from programs/compressed-token/program/docs/instructions/CREATE_TOKEN_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/CREATE.md index 4713c8c626..2e2c16f2b6 100644 --- a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/CREATE.md @@ -1,21 +1,12 @@ # Instructions - -**Instruction Schema:** -1. every instruction description must include the sections: - - **path** path to instruction code in the program - - **description** highlevel description what the instruction does including accounts used and their state layout (paths to the code), usage flows what the instruction does - - **instruction_data** paths to code where instruction data structs are defined - - **Accounts** accounts in order including checks - - **Instruction logic and checks** - - **Errors** possible errors and description what causes these errors - +- This file documents create ctoken account and create associated ctoken account. ## 1. create ctoken account **discriminator:** 18 **enum:** `CTokenInstruction::CreateTokenAccount` - **path:** programs/compressed-token/program/src/create_token_account.rs + **path:** programs/compressed-token/program/src/ctoken/create.rs **description:** 1. creates ctoken solana accounts with and without Compressible extension @@ -38,7 +29,7 @@ - `compressible_config`: Optional `CompressibleExtensionInstructionData` (None = non-compressible account) 2. Instruction data with compressible extension program-libs/ctoken-interface/src/instructions/extensions/compressible.rs - - `token_account_version`: Version of the compressed token account hashing scheme (u8) + - `token_account_version`: Version of the compressed token account hashing scheme (u8). Must be 3 (ShaFlat) - only version 3 is supported. - `rent_payment`: Number of epochs to prepay for rent (u8) - `rent_payment = 1` is explicitly forbidden to prevent epoch boundary timing edge case (its rent for the current rent epoch) - Allowed values: 0 (no prefunding) or 2+ epochs (safe buffer) @@ -82,33 +73,54 @@ - Otherwise, deserialize as `CreateTokenAccountInstructionData` 2. Parse and check accounts based on is_compressible flag - For compressible: token_account must be signer - - Validate CompressibleConfig is active (not inactive or deprecated) 3. Check mint extensions using `has_mint_extensions()` 4. If with compressible account: - 4.1. Validate rent_payment is not exactly 1 epoch (must cover more than the current rent epoch or be 0) - - Check: `compressible_config.rent_payment != 1` - - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - - Purpose: Prevent accounts from becoming immediately compressible due to epoch boundary timing - 4.2. If with compress_to_pubkey: + 4.1. Parse payer, config, system_program, and rent_payer accounts + 4.2. Validate CompressibleConfig is active (not inactive or deprecated) + - Error: `CompressibleError::InvalidState` if not active + 4.3. If with compress_to_pubkey: - Validates: derives address from provided seeds/bump and verifies it matches token_account pubkey - Security: ensures account is a derivable PDA, preventing compression to non-signable addresses - 4.3. Validate compression_only requirement for restricted extensions: + 4.4. Validate compression_only requirement for restricted extensions: - If mint has restricted extensions (e.g., TransferFee) and compression_only == 0 - Error: `ErrorCode::CompressionOnlyRequired` - 4.4. Calculate account size based on mint extensions (includes Compressible extension) - 4.5. Calculate rent (rent exemption + prepaid epochs rent + compression incentive) - 4.6. Check whether rent_payer is custom fee payer (rent_payer != config.rent_sponsor) - 4.7. If custom rent payer: + 4.5. Validate compression_only is only set for mints with restricted extensions: + - If compression_only != 0 and mint has no restricted extensions + - Error: `ErrorCode::CompressionOnlyNotAllowed` + 4.6. Validate rent_payment is not exactly 1 epoch (must cover more than the current rent epoch or be 0) + - Check: `compressible_config.rent_payment != 1` + - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails + - Purpose: Prevent accounts from becoming immediately compressible due to epoch boundary timing + 4.7. Calculate account size based on mint extensions (includes Compressible extension) + 4.8. Calculate rent (rent exemption + prepaid epochs rent + compression incentive) + 4.9. Check whether rent_payer is custom fee payer (rent_payer != config.rent_sponsor) + 4.10. If custom rent payer: - Verify rent_payer is signer (prevents executable accounts as rent_sponsor) - Create account with custom rent_payer via CPI (pays both rent exemption + additional lamports) - 4.8. If using protocol rent_sponsor: + 4.11. If using protocol rent_sponsor: - Create account with rent_sponsor PDA as fee payer via CPI (pays only rent exemption) - Transfer compression incentive to created ctoken account from payer via CPI - 4.9. `initialize_ctoken_account` (programs/compressed-token/program/src/shared/initialize_ctoken_account.rs) + 4.12. `initialize_ctoken_account` (programs/compressed-token/program/src/shared/initialize_ctoken_account.rs) + - Build extensions Vec including Compressible extension and any mint extension markers - Copy version from config (used to match config PDA version in subsequent instructions) - If custom fee payer, set custom fee payer as ctoken account rent_sponsor - Else set config.rent_sponsor as ctoken account rent_sponsor - Set `last_claimed_slot` to current slot (tracks when rent was last claimed/initialized) + - Validate token_account_version == 3 (ShaFlat) + - Error: `ProgramError::InvalidInstructionData` if version != 3 + - Validate write_top_up <= config.rent_config.max_top_up + - Error: `CTokenError::WriteTopUpExceedsMaximum` if exceeded + - Validate mint account (if initialized): + - Check mint owner is SPL Token, Token-2022, or CToken program + - Error: `ProgramError::IncorrectProgramId` if invalid owner + - Check mint structure is valid (82 bytes for SPL, or has AccountType marker for T22) + - Error: `ProgramError::InvalidAccountData` if invalid structure + - Cache decimals from mint account in extension + 5. If without compressible account (non-compressible path): + 5.1. Validate mint does not have restricted extensions + - Check: `!mint_extensions.has_restricted_extensions()` + - Error: `ErrorCode::MissingCompressibleConfig` if mint has restricted extensions + - Rationale: Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) must be marked as compression_only, and that marker is part of the Compressible extension **Errors:** - `ProgramError::BorshIoError` (error code: 15) - Failed to deserialize CreateTokenAccountInstructionData from instruction_data bytes @@ -116,22 +128,26 @@ - `AccountError::InvalidSigner` (error code: 12015) - token_account or payer is not a signer when required - `AccountError::AccountNotMutable` (error code: 12008) - token_account or payer is not mutable when required - `AccountError::AccountOwnedByWrongProgram` (error code: 12007) - Config account not owned by LightRegistry program - - `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig pod deserialization fails or compress_to_pubkey.check_seeds() fails - - `ProgramError::InvalidInstructionData` (error code: 3) - compressible_config is None in instruction data when compressible accounts provided, or extension data invalid + - `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig pod deserialization fails, compress_to_pubkey.check_seeds() fails, or invalid mint structure + - `ProgramError::InvalidInstructionData` (error code: 3) - compressible_config is None in instruction data when compressible accounts provided, extension data invalid, or token_account_version != 3 - `ProgramError::MissingRequiredSignature` (error code: 8) - Custom rent_payer is not a signer - `ProgramError::UnsupportedSysvar` (error code: 17) - Failed to get Clock sysvar + - `ProgramError::IncorrectProgramId` (error code: 1) - Mint account owner is not SPL Token, Token-2022, or CToken program - `CompressibleError::InvalidState` (error code: 19002) - CompressibleConfig is not in active state - `ErrorCode::InsufficientAccountSize` (error code: 6077) - token_account data length < 165 bytes (non-compressible) or < COMPRESSIBLE_TOKEN_ACCOUNT_SIZE (compressible) - - `ErrorCode::InvalidCompressAuthority` (error code: 6052) - compressible_config is Some but compressible_config_account is None during extension initialization - - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6116) - rent_payment is exactly 1 epoch, which is forbidden due to epoch boundary timing edge case + - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6101) - rent_payment is exactly 1 epoch, which is forbidden due to epoch boundary timing edge case - `ErrorCode::CompressionOnlyRequired` (error code: 6131) - Mint has restricted extensions (e.g., TransferFee) but compression_only is not set in instruction data + - `ErrorCode::MissingCompressibleConfig` (error code: 6115) - Either: (1) compressible_config is Some in instruction data but compressible accounts are missing, or (2) non-compressible account creation attempted for mint with restricted extensions + - `ErrorCode::CompressionOnlyNotAllowed` (error code: 6151) - compression_only is set but mint has no restricted extensions + - `CTokenError::WriteTopUpExceedsMaximum` (error code: 18042) - write_top_up exceeds config.rent_config.max_top_up + - `CTokenError::MissingCompressibleExtension` (error code: 18056) - Compressible extension initialization failed internally ## 2. create associated ctoken account **discriminator:** 100 (non-idempotent), 102 (idempotent) **enum:** `CTokenInstruction::CreateAssociatedCTokenAccount` (non-idempotent), `CTokenInstruction::CreateAssociatedTokenAccountIdempotent` (idempotent) - **path:** programs/compressed-token/program/src/create_associated_token_account.rs + **path:** programs/compressed-token/program/src/ctoken/create_ata.rs **description:** 1. Creates deterministic ctoken PDA accounts derived from [owner, ctoken_program_id, mint] @@ -140,11 +156,14 @@ 4. Associated token accounts cannot use compress_to_pubkey (always compress to owner) 5. Owner and mint are provided as accounts, bump is provided via instruction data 6. Token account must be uninitialized (owned by system program) unless idempotent mode + 7. ATAs for mints with restricted extensions must be compressible (the compression_only marker is part of the Compressible extension) **Instruction data:** 1. instruction data is defined in path: program-libs/ctoken-interface/src/instructions/create_associated_token_account.rs - `bump`: PDA bump seed for derivation (u8) - - `compressible_config`: Optional `CompressibleExtensionInstructionData`, same as create ctoken account but compress_to_account_pubkey must be None + - `compressible_config`: Optional `CompressibleExtensionInstructionData`, same as create ctoken account but: + - `compress_to_account_pubkey` must be None (ATAs always compress to owner) + - `compression_only` must be non-zero (compressible ATAs require compression_only) **Accounts:** 1. owner @@ -181,13 +200,16 @@ 4. Verify account is system-owned (uninitialized) - Error: `ProgramError::IllegalOwner` if not owned by system program 5. If compressible: - - Validate rent_payment is not exactly 1 epoch (same as create ctoken account step 3.0) - - Check: `compressible_config.rent_payment != 1` - - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - Reject if compress_to_account_pubkey is Some (not allowed for ATAs) - Error: `ProgramError::InvalidInstructionData` if compress_to_account_pubkey is Some + - Validate compression_only is set (required for compressible ATAs) + - Check: `compressible_config.compression_only != 0` + - Error: `ErrorCode::AtaRequiresCompressionOnly` if compression_only == 0 - Parse additional accounts: config, rent_payer - Validate CompressibleConfig is active (not inactive or deprecated) + - Validate rent_payment is not exactly 1 epoch (same as create ctoken account step 4.6) + - Check: `compressible_config.rent_payment != 1` + - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - Calculate account size based on mint extensions (includes Compressible extension) - Calculate rent (rent exemption + prepaid epochs rent + compression incentive) - Check if custom rent payer (rent_payer != config.rent_sponsor) @@ -198,8 +220,12 @@ - Create ATA PDA with rent_sponsor PDA paying rent exemption - Transfer compression incentive from fee_payer to account via CPI 6. If not compressible: - - Create ATA PDA with fee_payer paying rent exemption (base 165-byte SPL layout) - 7. Initialize token account with is_ata flag set (same as ## 1. create ctoken account step 3.6, but with is_ata=true) + 6.1. Validate mint does not have restricted extensions + - Check: `!mint_extensions.has_restricted_extensions()` + - Error: `ErrorCode::MissingCompressibleConfig` if mint has restricted extensions + - Rationale: Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) must be marked as compression_only, and that marker is part of the Compressible extension + 6.2. Create ATA PDA with fee_payer paying rent exemption (base 165-byte SPL layout) + 7. Initialize token account with is_ata flag set (same as ## 1. create ctoken account step 4.12, but with is_ata=true) **Errors:** Same as create ctoken account with additions: @@ -208,4 +234,6 @@ - `ProgramError::MissingRequiredSignature` (error code: 8) - Custom rent_payer is not a signer - `AccountError::InvalidSigner` (error code: 12015) - fee_payer is not a signer - `AccountError::AccountNotMutable` (error code: 12008) - fee_payer or associated_token_account is not mutable - - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6116) - rent_payment is exactly 1 epoch (see create ctoken account errors) + - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6101) - rent_payment is exactly 1 epoch (see create ctoken account errors) + - `ErrorCode::AtaRequiresCompressionOnly` (error code: 6152) - compressible ATA must have compression_only set (compression_only == 0 is not allowed) + - `ErrorCode::MissingCompressibleConfig` (error code: 6115) - non-compressible ATA creation attempted for mint with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_FREEZE_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md similarity index 81% rename from programs/compressed-token/program/docs/instructions/CTOKEN_FREEZE_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md index c71e90bb2c..3553e86d40 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_FREEZE_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md @@ -2,7 +2,7 @@ **discriminator:** 10 **enum:** `InstructionType::CTokenFreezeAccount` -**path:** programs/compressed-token/program/src/ctoken_freeze_thaw.rs +**path:** programs/compressed-token/program/src/ctoken/freeze_thaw.rs **description:** Freezes a decompressed ctoken account, preventing transfers and other operations while frozen. This is a pass-through instruction that validates mint ownership (must be owned by SPL Token, Token-2022, or CToken program) before delegating to pinocchio-token-program for standard SPL Token freeze validation. After freezing, the account's state field is set to AccountState::Frozen, and only the freeze_authority of the mint can freeze accounts (mint must have freeze_authority set). The account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. @@ -51,17 +51,17 @@ No instruction data required beyond the discriminator byte. - Verifies mint.freeze_authority == Some(freeze_authority.key()) - Verifies token_account state is Initialized (not already Frozen) - Updates token_account.state to AccountState::Frozen - - Map any errors from u64 to ProgramError::Custom(u32) + - Map SPL Token errors via `convert_pinocchio_token_error` to ErrorCode variants **Errors:** - `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 2 accounts provided (cannot get mint account) - `ProgramError::IncorrectProgramId` (error code: 7) - Mint is not owned by a valid token program (SPL Token, Token-2022, or CToken) -- SPL Token errors from pinocchio-token-program (converted from u64 to ProgramError::Custom(u32)): - - `TokenError::MintCannotFreeze` (error code: 16) - Mint's freeze_authority is None - - `TokenError::OwnerMismatch` (error code: 4) - freeze_authority doesn't match mint's freeze_authority - - `TokenError::MintMismatch` (error code: 3) - token_account's mint doesn't match provided mint - - `TokenError::InvalidState` (error code: 13) - Account is already frozen or uninitialized - - `ProgramError::InvalidAccountData` (error code: 4) - Account data is malformed +- SPL Token errors from pinocchio-token-program (converted via `convert_pinocchio_token_error` to ErrorCode variants): + - `ErrorCode::MintHasNoFreezeAuthority` (error code: 6026) - Mint's freeze_authority is None + - `ErrorCode::OwnerMismatch` (error code: 6075) - freeze_authority doesn't match mint's freeze_authority + - `ErrorCode::MintMismatch` (error code: 6155) - token_account's mint doesn't match provided mint + - `ErrorCode::InvalidState` (error code: 6163) - Account is already frozen or uninitialized + - `ErrorCode::InvalidMint` (error code: 6126) - Account data is malformed (SPL Token code 2) ## Comparison with SPL Token diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO.md b/programs/compressed-token/program/docs/ctoken/MINT_TO.md similarity index 83% rename from programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO.md rename to programs/compressed-token/program/docs/ctoken/MINT_TO.md index 642b2154ca..f3757e2f83 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO.md @@ -2,7 +2,7 @@ **discriminator:** 7 **enum:** `InstructionType::CTokenMintTo` -**path:** programs/compressed-token/program/src/ctoken_mint_to.rs +**path:** programs/compressed-token/program/src/ctoken/mint_to.rs **description:** Mints tokens from a decompressed CMint account to a destination CToken account, fully compatible with SPL Token mint_to semantics. Uses pinocchio-token-program to process the mint_to operation which handles balance/supply updates, authority validation, and frozen account checks. After minting, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Both CMint and destination CToken can receive top-ups based on their current slot and account balance. Supports max_top_up parameter to limit rent top-up costs where 0 means no limit. Instruction data is backwards-compatible with two formats: 8-byte format for legacy compatibility without max_top_up enforcement and 10-byte format with max_top_up. This instruction only works with CMints (compressed mints). CMints do not support restricted Token-2022 extensions (Pausable, TransferFee, TransferHook, PermanentDelegate, DefaultAccountState) - only TokenMetadata is allowed. @@ -13,7 +13,7 @@ Account layouts: - `CompressionInfo` extension defined in: program-libs/compressible/src/compression_info.rs **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_mint_to.rs (lines 10-47) +Path: programs/compressed-token/program/src/ctoken/mint_to.rs (see `process_ctoken_mint_to` function) Byte layout: - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to mint @@ -68,14 +68,14 @@ Format variants: 4. **Calculate top-up requirements:** For both CMint and destination CToken accounts: - a. **Deserialize account using zero-copy:** - - CMint: Use `CompressedMint::zero_copy_at` - - CToken: Use `CToken::zero_copy_at_checked` - - Access compression info directly from embedded field (all accounts now have compression embedded) + a. **Access CompressionInfo using optimized byte access:** + - CMint: Use `cmint_top_up_lamports_from_account_info` which reads CompressionInfo at fixed byte offset (166) + - CToken: Use `top_up_lamports_from_account_info_unchecked` which reads CompressionInfo at fixed byte offset (176) + - Returns None if account lacks CompressionInfo (CMint without compression, or CToken without Compressible extension as first extension) b. **Calculate top-up amount:** - - Get current slot from Clock sysvar (lazy loaded, only if needed) - - Get rent exemption from Rent sysvar + - Get current slot from Clock sysvar (lazy loaded on first compressible account) + - Uses stored rent_exemption_paid from CompressionInfo (not Rent sysvar) - Call `calculate_top_up_lamports` which: - Checks if account is compressible - Calculates rent deficit if any @@ -83,7 +83,7 @@ Format variants: - Returns 0 if account is well-funded c. **Track lamports budget:** - - Initialize budget to max_top_up + 1 (allowing exact match) + - Initialize budget to max_top_up.saturating_add(1) (allowing exact match) - Subtract CMint top-up amount from budget - Subtract CToken top-up amount from budget - If budget reaches 0 and max_top_up is not 0, fail with MaxTopUpExceeded @@ -102,11 +102,8 @@ Format variants: - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match CMint mint_authority - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account or calculate top-up amount -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account (not 165 bytes) is missing the Compressible extension +- `CTokenError::MissingPayer` (error code: 18061) - Payer account missing when top-ups are needed --- @@ -116,7 +113,7 @@ Format variants: CToken delegates core logic to `pinocchio_token_program::processor::mint_to::process_mint_to`, which implements SPL Token-compatible mint semantics: - Authority validation, balance/supply updates, frozen check, mint matching, overflow protection -- **MintToChecked:** CToken implements CTokenMintToChecked (discriminator: 14) with full decimals validation. See `CTOKEN_MINT_TO_CHECKED.md`. +- **MintToChecked:** CToken implements CTokenMintToChecked (discriminator: 14) with full decimals validation. See `MINT_TO_CHECKED.md`. ### CToken-Specific Features diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO_CHECKED.md b/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md similarity index 74% rename from programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md index 08dc938c9e..41fc21e3ba 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md @@ -2,7 +2,7 @@ **discriminator:** 14 **enum:** `InstructionType::CTokenMintToChecked` -**path:** programs/compressed-token/program/src/ctoken_mint_to.rs +**path:** programs/compressed-token/program/src/ctoken/mint_to.rs **description:** Mints tokens from a decompressed CMint account to a destination CToken account with decimals validation, fully compatible with SPL Token MintToChecked semantics. Uses pinocchio-token-program to process the mint_to_checked operation which handles balance/supply updates, authority validation, frozen account checks, and decimals validation. After minting, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Both CMint and destination CToken can receive top-ups based on their current slot and account balance. Supports max_top_up parameter to limit rent top-up costs where 0 means no limit. @@ -13,7 +13,8 @@ Account layouts: - `CompressionInfo` extension defined in: program-libs/compressible/src/compression_info.rs **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_mint_to.rs (lines 62-112, function `process_ctoken_mint_to_checked`) +Path: programs/compressed-token/program/src/ctoken/mint_to.rs (function `process_ctoken_mint_to_checked`) +Shared implementation: programs/compressed-token/program/src/ctoken/burn.rs (function `process_ctoken_supply_change_inner`) Byte layout: - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to mint @@ -66,28 +67,25 @@ Format variants: - Checks destination CToken is not frozen - Increases destination CToken balance by amount - Increases CMint supply by amount - - Errors are converted from pinocchio errors to ProgramError::Custom + - Errors are converted from pinocchio errors to ErrorCode variants 4. **Calculate and execute top-up transfers:** - - Calculate lamports needed for CMint based on compression state - - Calculate lamports needed for CToken based on compression state + - Calculate lamports needed for CMint based on compression state (skipped if not compressible) + - Calculate lamports needed for CToken based on compression state (skipped if no Compressible extension) - Validate total against max_top_up budget - Transfer lamports from authority to both accounts if needed **Errors:** -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided -- `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data length is not 9 or 11 bytes -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match CMint mint_authority - - `TokenError::MintDecimalsMismatch` (error code: 18) - Decimals don't match CMint's decimals - - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account or calculate top-up amount -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation -- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account (not 165 bytes) is missing the Compressible extension +- `ProgramError::NotEnoughAccountKeys` - Less than 3 accounts provided +- `ProgramError::InvalidInstructionData` - Instruction data length is not 9 or 11 bytes +- Pinocchio token errors (converted to ErrorCode variants via `convert_pinocchio_token_error`): + - `ErrorCode::MintMismatch` (6155) - CToken mint doesn't match CMint + - `ErrorCode::OwnerMismatch` (6075) - Authority doesn't match CMint mint_authority + - `ErrorCode::MintDecimalsMismatch` (6166) - Decimals don't match CMint's decimals + - `ErrorCode::AccountFrozen` (6076) - CToken account is frozen +- `CTokenError::MaxTopUpExceeded` (18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit +- `CTokenError::MissingPayer` (18061) - Payer account not provided but top-ups are needed --- diff --git a/programs/compressed-token/program/docs/ctoken/REVOKE.md b/programs/compressed-token/program/docs/ctoken/REVOKE.md new file mode 100644 index 0000000000..88e3474bbb --- /dev/null +++ b/programs/compressed-token/program/docs/ctoken/REVOKE.md @@ -0,0 +1,91 @@ +## CToken Revoke + +**discriminator:** 5 +**enum:** `InstructionType::CTokenRevoke` +**path:** programs/compressed-token/program/src/ctoken/approve_revoke.rs + +### SPL Instruction Format Compatibility + +**Important:** This instruction is only compatible with the SPL Token instruction format (using `spl_token_2022::instruction::revoke` with changed program ID) when **no top-up is required**. + +If the CToken account has a compressible extension and requires a rent top-up, the instruction needs the **payer account** to transfer lamports. Without the payer account, the top-up CPI will fail. + +**Compatibility scenarios:** +- **SPL-compatible (no payer needed):** Non-compressible accounts, or compressible accounts with sufficient prepaid rent +- **NOT SPL-compatible (payer required):** Compressible accounts that need rent top-up based on current slot + +**description:** +Revokes any previously granted delegation on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). After the revoke operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible revoke semantics. Supports backwards-compatible instruction data format (0 bytes legacy vs 2 bytes with max_top_up). The revoke operation follows SPL Token rules exactly (clears delegate and delegated_amount). + +**Instruction data:** +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 49-59 for revoke, lines 86-124 for top-up processing) + +- Empty (0 bytes): legacy format, no max_top_up enforcement (max_top_up = 0, no limit) +- Bytes 0-1 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit) + +**Accounts:** +1. source + - (mutable) + - The source ctoken account to revoke delegation on + - May receive rent top-up if compressible + +2. owner + - (signer, mutable) + - Owner of the source account + - Must sign the transaction + - Acts as payer for rent top-up if compressible extension present + +**Instruction Logic and Checks:** + +1. **Validate minimum accounts:** + - Require at least 1 account (pinocchio's process_revoke requires at least 2: source, owner) + - Return NotEnoughAccountKeys if insufficient + +2. **Process revoke (inline via pinocchio-token-program library):** + - Call process_revoke with accounts + - Clears the delegate field and delegated_amount on the source account + - Validates owner authority and account state + +3. **Handle compressible top-up (if applicable):** + - Fast path: if account data length is 165 bytes (no extensions), skip top-up + - Otherwise, process compressible top-up: + - Parse instruction data to get max_top_up: + - If 0 bytes: legacy format, set max_top_up = 0 (no limit) + - If 2 bytes: parse max_top_up (u16, little-endian) + - Return InvalidInstructionData for any other length + - Calculate required top-up using `top_up_lamports_from_account_info_unchecked` + - If transfer_amount > 0: + - If max_top_up > 0 and transfer_amount > max_top_up: return MaxTopUpExceeded + - Get payer from accounts[1], return MissingPayer if not present + - Transfer lamports from payer to source via CPI + +**Errors:** + +- `ProgramError::NotEnoughAccountKeys` (error code: 11) - No accounts provided +- `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 0 or 2 bytes +- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter +- `CTokenError::MissingPayer` (error code: 18061) - Top-up required but payer account not provided +- Pinocchio token errors (mapped to ErrorCode via convert_pinocchio_token_error): + - `TokenError::OwnerMismatch` (error code: 6075) - Authority doesn't match account owner + - `TokenError::AccountFrozen` (error code: 6076) - Account is frozen + +## Comparison with SPL Token + +### Functional Parity + +CToken delegates core logic to `pinocchio_token_program::processor::revoke::process_revoke`, which implements SPL Token-compatible revoke semantics: +- Delegate clearing, owner authority validation, frozen account check + +### CToken-Specific Features + +**1. Compressible Top-Up Logic** +Automatically tops up accounts with rent lamports after revoking to prevent accounts from becoming compressible. + +**2. max_top_up Parameter** +2-byte instruction format adds `max_top_up` (u16) to limit top-up costs. Fails with `MaxTopUpExceeded` (18043) if exceeded. + +### Unsupported SPL & Token-2022 Features + +**1. No Multisig Support** +**2. No Dual Authority Model** - Token-2022 allows both owner AND delegate to revoke; CToken only accepts owner +**3. No CPI Guard Extension Check** diff --git a/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md new file mode 100644 index 0000000000..5017e1a1fc --- /dev/null +++ b/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md @@ -0,0 +1,97 @@ +## CToken Thaw Account + +**discriminator:** 11 +**enum:** `InstructionType::CTokenThawAccount` +**path:** programs/compressed-token/program/src/ctoken/freeze_thaw.rs + +**description:** +Thaws a frozen decompressed ctoken account, restoring normal operation. This is a pass-through instruction that validates mint ownership (must be owned by SPL Token, Token-2022, or CToken program) before delegating to pinocchio-token-program for standard SPL Token thaw validation. After thawing, the account's state field is set to AccountState::Initialized, and only the freeze_authority of the mint can thaw accounts (mint must have freeze_authority set). The account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. + +**Instruction data:** +No instruction data required beyond the discriminator byte. + +**Accounts:** +1. token_account + - (mutable) + - The frozen ctoken account to thaw + - Must be frozen (AccountState::Frozen) + - Must not be a native token account + - Will have state field updated to AccountState::Initialized + +2. mint + - The mint account associated with the token account + - Must be owned by SPL Token, Token-2022, or CToken program + - Must have freeze_authority set (not None) + - Must match token_account.mint + +3. freeze_authority + - (signer, or multisig with signers in remaining accounts) + - Must match the mint's freeze_authority + - Must sign the transaction (or provide sufficient multisig signers) + +4. remaining accounts (optional) + - Additional signer accounts if freeze_authority is a multisig + +**Instruction Logic and Checks:** + +1. **Validate minimum accounts (CToken layer):** + - Require at least 2 accounts to access mint account (index 1) + - Return NotEnoughAccountKeys if insufficient + +2. **Validate mint ownership (CToken layer):** + - Get mint account (accounts[1]) + - Call `check_token_program_owner(mint_info)` from programs/compressed-token/program/src/shared/owner_validation.rs + - Verify mint is owned by one of: + - SPL Token program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) + - Token-2022 program (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb) + - CToken program (cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m) + - Return IncorrectProgramId if mint owner doesn't match + +3. **Process thaw (pinocchio-token-program layer):** + - Call `process_thaw_account(accounts)` from pinocchio-token-program + - Internally calls `process_toggle_account_state(accounts, false)` which: + - Requires at least 3 accounts: [source_account, mint, authority, remaining...] + - Loads token account mutably and validates it is initialized + - Verifies token_account state is Frozen (returns InvalidState if already Initialized) + - Verifies token_account is not a native token (returns NativeNotSupported) + - Verifies token_account.mint == mint.key() (returns MintMismatch) + - Loads mint and verifies freeze_authority is set (returns MintCannotFreeze if None) + - Validates owner via `validate_owner()`: + - Checks freeze_authority key matches expected authority + - If authority is a multisig account, validates sufficient signers from remaining accounts + - If authority is a regular account, verifies it is a signer + - Updates token_account.state to AccountState::Initialized + - Errors are converted via `convert_pinocchio_token_error` to anchor ErrorCode variants + +**Errors:** +- `ProgramError::NotEnoughAccountKeys` - Less than 2 accounts provided (CToken check), or less than 3 accounts for pinocchio processor +- `ProgramError::IncorrectProgramId` - Mint is not owned by a valid token program (SPL Token, Token-2022, or CToken) +- SPL Token errors from pinocchio-token-program (converted to anchor ErrorCode variants): + - `ErrorCode::MintHasNoFreezeAuthority` (SPL code 16) - Mint's freeze_authority is None + - `ErrorCode::OwnerMismatch` (SPL code 4) - freeze_authority doesn't match mint's freeze_authority + - `ErrorCode::MintMismatch` (SPL code 3) - token_account's mint doesn't match provided mint + - `ErrorCode::InvalidState` (SPL code 13) - Account is not frozen (already Initialized or uninitialized) + - `ErrorCode::NativeNotSupported` (SPL code 10) - Cannot thaw native token accounts + - `ProgramError::MissingRequiredSignature` - Authority is not a signer or multisig threshold not met + +## Comparison with SPL Token + +### Functional Parity + +CToken delegates core logic to `pinocchio_token_program::processor::thaw_account::process_thaw_account`, which implements SPL Token-compatible thaw semantics: +- State transition (Frozen -> Initialized), freeze authority validation, mint association check + +### CToken-Specific Features + +**1. Explicit Mint Ownership Validation** +CToken adds `check_token_program_owner(mint)` before delegating to thaw logic, validating mint is owned by SPL Token, Token-2022, or CToken program. This allows CToken mints to be thawed as well as standard SPL/Token-2022 mints. + +### Supported SPL Features + +**1. Multisig Support** +The pinocchio-token-program implementation supports multisig freeze authorities. If the freeze_authority is a multisig account, additional signer accounts can be passed in the remaining accounts to meet the signature threshold. + +### Unsupported Token-2022 Features + +**1. No CPI Guard Extension Check** +Token-2022's CPI guard extension check is not performed. diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER.md b/programs/compressed-token/program/docs/ctoken/TRANSFER.md similarity index 77% rename from programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER.md rename to programs/compressed-token/program/docs/ctoken/TRANSFER.md index 5e4f95fc93..be7e484bcd 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER.md +++ b/programs/compressed-token/program/docs/ctoken/TRANSFER.md @@ -2,7 +2,7 @@ **discriminator:** 3 **enum:** `InstructionType::CTokenTransfer` -**path:** programs/compressed-token/program/src/transfer/default.rs +**path:** programs/compressed-token/program/src/ctoken/transfer/default.rs ### SPL Instruction Format Compatibility @@ -25,7 +25,7 @@ When accounts require rent top-up, lamports are transferred directly from the au - Top-up prevents accounts from becoming compressible during normal operations 6. Supports standard SPL Token transfer features including delegate authority and permanent delegate (multisig not supported) 7. The transfer amount and authority validation follow SPL Token rules exactly -8. Validates T22 extension markers match between source and destination (pausable, permanent_delegate, transfer_fee, transfer_hook) +8. Validates T22 extension markers match between source and destination (pausable, permanent_delegate, transfer_fee, transfer_hook, default_account_state) **Instruction data:** After discriminator byte, the following formats are supported: @@ -60,24 +60,32 @@ Note: The authority account (index 2) also serves as the payer for top-ups when - Require at least 3 accounts (source, destination, authority) - Return NotEnoughAccountKeys if insufficient -2. **Validate instruction data:** +2. **Validate minimum instruction data:** - Must be at least 8 bytes (amount) + - Return InvalidInstructionData if less than 8 bytes + +3. **Hot path optimization (no extensions):** + - If both source and destination accounts are exactly 165 bytes (standard SPL token account size without extensions): + - Skip all extension processing and max_top_up validation + - Pass only the first 8 bytes (amount) directly to pinocchio SPL transfer + - This is the fast path for accounts without compressible or T22 extensions + +4. **Parse max_top_up (extended path only):** - If 10 bytes, parse max_top_up from bytes [8..10] - If 8 bytes, set max_top_up = 0 (legacy, no limit) - Any other length returns InvalidInstructionData -3. **Process transfer extensions:** +5. **Process transfer extensions:** - Call `process_transfer_extensions` from shared.rs with source, destination, authority (no mint) a. **Validate sender (source account):** - Deserialize source account (CToken) using zero-copy - Check for T22 restricted extensions (pausable, permanent_delegate, transfer_fee, transfer_hook, default_account_state) - - If source has restricted extensions, deserialize and validate mint extensions: - - Mint must not be paused - - Transfer fees must be zero - - Transfer hooks must have nil program_id - - Extract permanent delegate if present - - Validate permanent delegate authority if applicable + - If source has restricted extension markers: + - Require mint account (MintRequiredForTransfer error if not provided) + - Fail with MintHasRestrictedExtensions if mint has any restricted extensions + - Note: CTokenTransfer does NOT support restricted extensions; use CTokenTransferChecked instead + - Validate permanent delegate authority if applicable (from mint extension) - Calculate top-up lamports from compression info b. **Validate recipient (destination account):** @@ -93,8 +101,9 @@ Note: The authority account (index 2) also serves as the payer for top-ups when - Check max_top_up budget if set (non-zero) - Execute multi_transfer_lamports from authority to accounts -4. **Process SPL transfer:** +6. **Process SPL transfer:** - Call pinocchio_token_program::processor::transfer::process_transfer + - Pass only the first 8 bytes (amount) to the processor - Pass signer_is_validated flag if permanent delegate was validated **Errors:** @@ -109,9 +118,7 @@ Note: The authority account (index 2) also serves as the payer for top-ups when - `TokenError::AccountFrozen` (error code: 17) - Source or destination account is frozen - `TokenError::InsufficientFunds` (error code: 1) - Delegate has insufficient allowance - `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account, mint mismatch, or invalid extension data -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up limit -- `ErrorCode::MintRequiredForTransfer` (error code: 6128) - Account has restricted extensions but mint account not provided -- `ErrorCode::MintPaused` (error code: 6127) - Mint has pausable extension and is currently paused -- `ErrorCode::NonZeroTransferFeeNotSupported` (error code: 6129) - Mint has non-zero transfer fee configured -- `ErrorCode::TransferHookNotSupported` (error code: 6130) - Mint has transfer hook with non-nil program_id +- `ErrorCode::MintRequiredForTransfer` (error code: 6128) - Source account has restricted extension markers but mint account not provided +- `ErrorCode::MintHasRestrictedExtensions` (error code: 6142) - Mint has restricted extensions; CTokenTransfer does not support restricted extensions (use CTokenTransferChecked instead) diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER_CHECKED.md b/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md similarity index 85% rename from programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md index e532e9f375..6c360b3e1b 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md @@ -2,7 +2,7 @@ **discriminator:** 12 **enum:** `InstructionType::CTokenTransferChecked` -**path:** programs/compressed-token/program/src/transfer/checked.rs +**path:** programs/compressed-token/program/src/ctoken/transfer/checked.rs ### SPL Instruction Format Compatibility @@ -14,6 +14,9 @@ When accounts require rent top-up, lamports are transferred directly from the au - **SPL-compatible:** When using 9-byte instruction data (amount + decimals) with no top-up needed - **Extended format:** When using 11-byte instruction data (amount + decimals + max_top_up) for compressible accounts +**Hot path optimization:** +When both source and destination accounts are exactly 165 bytes (no extensions), the instruction bypasses all extension processing and directly calls pinocchio process_transfer_checked for maximum performance. + **description:** Transfers tokens between decompressed ctoken solana accounts with mint decimals validation, fully compatible with SPL Token TransferChecked semantics. Account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. Compression info for rent top-up is defined in program-libs/compressible/src/compression_info.rs. Uses pinocchio-token-program to process the transfer (lightweight SPL-compatible implementation). After the transfer, automatically tops up compressible accounts with additional lamports if needed based on current slot and account balance. Top-up prevents accounts from becoming compressible during normal operations. Supports standard SPL Token transfer features including delegate authority and permanent delegate (multisig not supported). The transfer amount, authority validation, and decimals validation follow SPL Token TransferChecked rules exactly. Validates that mint decimals match the provided decimals parameter. Difference from CTokenTransfer: Requires mint account (4 accounts vs 3) for decimals validation and T22 extension validation. @@ -55,22 +58,28 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals **Instruction Logic and Checks:** 1. **Validate minimum accounts:** - - Require exactly 4 accounts (source, mint, destination, authority) + - Require at least 4 accounts (source, mint, destination, authority) - Return NotEnoughAccountKeys if insufficient -2. **Validate instruction data:** +2. **Hot path for accounts without extensions:** + - If both source and destination are exactly 165 bytes (no extensions): + - Directly call pinocchio process_transfer_checked with first 9 bytes of instruction data + - Skip all extension processing for maximum performance + - Return immediately + +3. **Validate instruction data:** - Must be at least 9 bytes (amount + decimals) - If 11 bytes, parse max_top_up from bytes [9..11] - If 9 bytes, set max_top_up = 0 (legacy, no limit) - Any other length returns InvalidInstructionData -3. **Parse max_top_up parameter:** +4. **Parse max_top_up parameter:** - 0 = no limit on top-up lamports - Non-zero = maximum combined lamports for source + destination top-up - Transaction fails if calculated top-up exceeds max_top_up -4. **Process transfer extensions:** - - Call process_transfer_extensions from shared.rs with source, destination, authority, mint, and max_top_up +5. **Process transfer extensions:** + - Call process_transfer_extensions_transfer_checked from shared.rs with source, destination, authority, mint, and max_top_up - Validate sender (source account): - Deserialize source account (CToken) and extract extension information - Validate mint account matches source token's mint field @@ -89,19 +98,18 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals - Verify sender and destination have matching T22 extension markers - Calculate top-up amounts for both accounts based on compression info: - Get current slot from Clock sysvar (lazy loaded once) - - Get rent exemption from Rent sysvar - Call calculate_top_up_lamports for each account - Transfer lamports from authority to accounts if top-up needed: - Check max_top_up budget if set (non-zero) - Execute multi_transfer_lamports atomically - - Return (signer_is_validated, decimals) tuple + - Return (signer_is_validated, extension_decimals) tuple -5. **Extract decimals and execute transfer:** +6. **Extract decimals and execute transfer:** - Parse amount and decimals from instruction data using unpack_amount_and_decimals - If source account has cached decimals in compressible extension (extension_decimals is Some): - Validate extension_decimals == instruction decimals parameter - Create accounts slice without mint: [source, destination, authority] - - Call pinocchio process_transfer with expected_decimals = None + - Call pinocchio process_transfer with expected_decimals = None (3 accounts) - signer_is_validated flag from permanent delegate check skips redundant owner/delegate validation - If no cached decimals (extension_decimals is None): - Validate mint account owner is token program @@ -122,7 +130,7 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 9 or 11 bytes, or decimals validation failed - `ProgramError::MissingRequiredSignature` (error code: 8) - Authority is permanent delegate but not a signer - `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account, mint mismatch, or invalid extension data -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up limit - `ProgramError::InsufficientFunds` (error code: 6) - Source balance less than amount (pinocchio error) - Pinocchio token errors (converted to ProgramError::Custom): @@ -140,8 +148,10 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals ### Functional Parity -CToken delegates core logic to `pinocchio_token_program::processor::transfer_checked::process_transfer_checked`, which implements SPL Token-compatible transfer semantics: -- Authority validation, balance updates, frozen check, mint matching, decimals validation +CToken delegates core logic to `pinocchio_token_program::processor::shared::transfer::process_transfer`, which implements SPL Token-compatible transfer semantics. When `expected_decimals` is Some, it performs decimals validation against the mint account: +- Authority validation, balance updates, frozen check, mint matching, decimals validation (when expected_decimals is Some) + +Note: For the hot path (165-byte accounts without extensions), `pinocchio_token_program::processor::transfer_checked::process_transfer_checked` is called directly. ### CToken-Specific Features diff --git a/programs/compressed-token/program/docs/instructions/CLAUDE.md b/programs/compressed-token/program/docs/instructions/CLAUDE.md deleted file mode 100644 index 31bc2c2d9c..0000000000 --- a/programs/compressed-token/program/docs/instructions/CLAUDE.md +++ /dev/null @@ -1,102 +0,0 @@ -# Documentation Structure - -## Overview -This documentation is organized to provide clear navigation through the compressed token program's functionality. - -## Structure -- **`CLAUDE.md`** (this file) - Documentation structure guide -- **`../CLAUDE.md`** (parent) - Main entry point with summary and instruction index -- **`ACCOUNTS.md`** - Complete account layouts and data structures -- **`instructions/`** - Detailed instruction documentation - - `CREATE_TOKEN_ACCOUNT.md` - Create token account & associated token account instructions - - `MINT_ACTION.md` - Mint operations and compressed mint management - - `TRANSFER2.md` - Batch transfer instruction for compressed/decompressed operations - - `CLAIM.md` - Claim rent from expired compressible accounts - - `CLOSE_TOKEN_ACCOUNT.md` - Close decompressed token accounts - - `CTOKEN_TRANSFER.md` - Transfer between decompressed accounts - - `CTOKEN_TRANSFER_CHECKED.md` - Transfer with decimals validation - - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool - - `CREATE_TOKEN_POOL.md` - Create initial token pool for SPL/T22 mint compression - - `ADD_TOKEN_POOL.md` - Add additional token pools (up to 5 per mint) - - `CTOKEN_APPROVE.md` - Approve delegate on decompressed CToken account - - `CTOKEN_REVOKE.md` - Revoke delegate on decompressed CToken account - - `CTOKEN_MINT_TO.md` - Mint tokens to decompressed CToken account - - `CTOKEN_BURN.md` - Burn tokens from decompressed CToken account - - `CTOKEN_FREEZE_ACCOUNT.md` - Freeze decompressed CToken account - - `CTOKEN_THAW_ACCOUNT.md` - Thaw frozen decompressed CToken account - - `CTOKEN_APPROVE_CHECKED.md` - Approve delegate with decimals validation - - `CTOKEN_MINT_TO_CHECKED.md` - Mint tokens with decimals validation - - `CTOKEN_BURN_CHECKED.md` - Burn tokens with decimals validation - - `compressed_token/` - Anchor program instructions for compressed token accounts - - `FREEZE.md` - Freeze compressed token accounts - - `THAW.md` - Thaw frozen compressed token accounts - -## Discriminator Reference - -| Instruction | Discriminator | Enum Variant | SPL Token Compatible | -|-------------|---------------|--------------|----------------------| -| CTokenTransfer | 3 | `InstructionType::CTokenTransfer` | Transfer | -| CTokenApprove | 4 | `InstructionType::CTokenApprove` | Approve | -| CTokenRevoke | 5 | `InstructionType::CTokenRevoke` | Revoke | -| CTokenMintTo | 7 | `InstructionType::CTokenMintTo` | MintTo | -| CTokenBurn | 8 | `InstructionType::CTokenBurn` | Burn | -| CloseTokenAccount | 9 | `InstructionType::CloseTokenAccount` | CloseAccount | -| CTokenFreezeAccount | 10 | `InstructionType::CTokenFreezeAccount` | FreezeAccount | -| CTokenThawAccount | 11 | `InstructionType::CTokenThawAccount` | ThawAccount | -| CTokenTransferChecked | 12 | `InstructionType::CTokenTransferChecked` | TransferChecked | -| CTokenApproveChecked | 13 | `InstructionType::CTokenApproveChecked` | ApproveChecked | -| CTokenMintToChecked | 14 | `InstructionType::CTokenMintToChecked` | MintToChecked | -| CTokenBurnChecked | 15 | `InstructionType::CTokenBurnChecked` | BurnChecked | -| CreateTokenAccount | 18 | `InstructionType::CreateTokenAccount` | InitializeAccount3 | -| CreateAssociatedCTokenAccount | 100 | `InstructionType::CreateAssociatedCTokenAccount` | - | -| Transfer2 | 101 | `InstructionType::Transfer2` | - | -| CreateAssociatedTokenAccountIdempotent | 102 | `InstructionType::CreateAssociatedTokenAccountIdempotent` | - | -| MintAction | 103 | `InstructionType::MintAction` | - | -| Claim | 104 | `InstructionType::Claim` | - | -| WithdrawFundingPool | 105 | `InstructionType::WithdrawFundingPool` | - | -| Freeze | Anchor | `anchor_compressed_token::freeze` | - | -| Thaw | Anchor | `anchor_compressed_token::thaw` | - | - -**SPL Token Compatibility Notes:** -- Instructions with SPL Token equivalents share the same discriminator and accept the same instruction data format -- CreateTokenAccount (18) accepts 32-byte owner pubkey for InitializeAccount3 compatibility -- CToken-specific instructions (100+) have no SPL Token equivalent - -## Navigation Tips -- Start with `../../CLAUDE.md` for the instruction index and overview -- Use `../ACCOUNTS.md` for account structure reference -- Refer to specific instruction docs for implementation details - - -# Instructions - -**Instruction Schema:** -every instruction description must include the sections: - - **path** path to instruction code in the program - - **description** highlevel description what the instruction does including accounts used and their state layout (paths to the code), usage flows what the instruction does - - **instruction_data** paths to code where instruction data structs are defined - - **Accounts** accounts in order including checks - - **instruciton logic and checks** - - **Errors** possible errors and description what causes these errors - -1. **Create Token Account Instructions** - Create regular and associated ctoken accounts -2. **Transfer2** - Batch transfer instruction supporting compress/decompress/transfer operations -3. **MintAction** - Batch instruction for compressed mint management and mint operations (supports 9 actions: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, CreateSplMint, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey) -4. **Claim** - Rent reclamation from expired compressible accounts -5. **Close Token Account** - Close decompressed token accounts with rent distribution -6. **Decompressed Transfer** - SPL-compatible transfers between decompressed accounts -7. **Withdraw Funding Pool** - Withdraw funds from rent recipient pool -8. **Create Token Pool** - Create initial token pool PDA for SPL/T22 mint compression -9. **Add Token Pool** - Add additional token pools for a mint (up to 5 per mint) -10. **CToken MintTo** - Mint tokens to decompressed CToken account -11. **CToken Burn** - Burn tokens from decompressed CToken account -12. **CToken Freeze/Thaw** - Freeze and thaw decompressed CToken accounts -13. **CToken Approve/Revoke** - Approve and revoke delegate on decompressed CToken accounts -14. **CToken Checked Operations** - ApproveChecked, MintToChecked, BurnChecked with decimals validation - -## Anchor Program Instructions (Compressed Token Accounts) - -These instructions operate on compressed token accounts (stored in Merkle trees) and require ZK proofs: - -15. **Compressed Token Freeze** (`compressed_token/FREEZE.md`) - Freeze compressed token accounts -16. **Compressed Token Thaw** (`compressed_token/THAW.md`) - Thaw frozen compressed token accounts diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE_CHECKED.md b/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE_CHECKED.md deleted file mode 100644 index c6ed24fe45..0000000000 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE_CHECKED.md +++ /dev/null @@ -1,120 +0,0 @@ -## CToken ApproveChecked - -**discriminator:** 13 -**enum:** `InstructionType::CTokenApproveChecked` -**path:** programs/compressed-token/program/src/ctoken_approve_revoke.rs - -**description:** -Delegates a specified amount to a delegate authority on a decompressed ctoken account with decimals validation, fully compatible with SPL Token ApproveChecked semantics. Account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. Extension layout `CompressionInfo` is defined in program-libs/compressible/src/compression_info.rs. Uses pinocchio-token-program to process the approve operation. Before the approve operation, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Supports max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses cached decimals optimization: if source CToken has cached decimals, validates against instruction decimals and skips mint read. Cached decimals allow users to choose whether a cmint is required to be decompressed at account creation or transfer. - -**Instruction data:** -Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 163-217) - -- Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to delegate -- Byte 8: `decimals` (u8) - Expected token decimals -- Bytes 9-10 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit) - -Format variants: -- 9 bytes: amount + decimals (legacy, no max_top_up enforcement) -- 11 bytes: amount + decimals + max_top_up - -**Accounts:** -1. source - - (mutable) - - The source ctoken account to approve delegation on - - May receive rent top-up if compressible - - May have cached decimals for validation optimization - -2. mint - - (immutable) - - The mint account for the token - - Must match source account's mint - - Decimals field must match instruction data decimals parameter - - Only read if source account has no cached decimals - -3. delegate - - (immutable) - - The delegate authority who will be granted spending rights - - Does not need to sign - -4. owner - - (signer, mutable) - - Owner of the source account - - Must sign the transaction - - Acts as payer for rent top-up if compressible extension present - -**Instruction Logic and Checks:** - -1. **Validate minimum accounts:** - - Require at least 4 accounts (source, mint, delegate, owner) - - Return NotEnoughAccountKeys if insufficient - -2. **Parse instruction data:** - - Require at least 9 bytes (amount + decimals) - - Parse amount (u64) and decimals (u8) using unpack_amount_and_decimals - - If 11 bytes: parse max_top_up from bytes 9-10 - - If 9 bytes: set max_top_up = 0 (no limit) - - Return InvalidInstructionData for any other length - -3. **Get cached decimals and process compressible top-up:** - - Borrow source account data mutably - - Deserialize CToken using zero-copy validation - - Get cached decimals via `ctoken.base.decimals()` (returns Option) - - Initialize lamports_budget based on max_top_up: - - If max_top_up == 0: budget = u64::MAX (no limit) - - Otherwise: budget = max_top_up + 1 (allows exact match) - - Call process_compression_top_up with source account's compression info - - Drop borrow before CPI - - If transfer_amount > 0: - - Check that transfer_amount <= lamports_budget - - Return MaxTopUpExceeded if budget exceeded - - Transfer lamports from owner to source via CPI - -4. **Process SPL approve based on cached decimals:** - - **If cached decimals present:** - - Validate cached_decimals == instruction decimals - - Return InvalidInstructionData if mismatch - - Create 3-account slice [source, delegate, owner] (skip mint) - - Call process_approve with expected_decimals = None (skip pinocchio mint validation) - - **If no cached decimals:** - - Validate mint is owned by valid token program (SPL, Token-2022, or CToken) - - Call process_approve with full 4-account layout and expected_decimals = Some(decimals) - -**Errors:** - -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 4 accounts provided -- `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 9 or 11 bytes, or cached decimals != instruction decimals -- `ProgramError::IncorrectProgramId` (error code: 7) - Mint is not owned by a valid token program (when no cached decimals) -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation -- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter -- `ProgramError::MissingRequiredSignature` (error code: 8) - Owner did not sign the transaction (SPL Token error) -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match account owner - - `TokenError::AccountFrozen` (error code: 17) - Account is frozen - - `TokenError::MintMismatch` (error code: 3) - Mint doesn't match source account's mint - - `TokenError::MintDecimalsMismatch` (error code: 18) - Decimals don't match mint's decimals - -## Comparison with Token-2022 - -### Functional Parity - -CToken ApproveChecked maintains compatibility with SPL Token-2022's ApproveChecked: - -- **Delegate Authorization**: Both delegate spending authority to a delegate pubkey for a specified token amount -- **Owner Signature**: Transaction must be signed by the account owner (single owner only, no multisig support in CToken) -- **Account State Validation**: Both check that the source account is initialized and not frozen -- **Decimals Validation**: Both validate instruction decimals against mint decimals - -### CToken-Specific Features - -1. **Cached Decimals Optimization**: If source CToken has cached decimals, validates against instruction and skips mint read -2. **Compressible Top-Up Logic**: Automatically tops up accounts with the Compressible extension -3. **max_top_up Parameter**: Limits rent top-up costs (0 = no limit) -4. **Static 4-Account Layout**: Always requires mint account, but may skip reading it when cached decimals are available - - -### Unsupported SPL & Token-2022 Features - -**1. No Multisig Support** -**2. No CPI Guard Extension Check** diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_REVOKE.md b/programs/compressed-token/program/docs/instructions/CTOKEN_REVOKE.md deleted file mode 100644 index da057bae30..0000000000 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_REVOKE.md +++ /dev/null @@ -1,97 +0,0 @@ -## CToken Revoke - -**discriminator:** 5 -**enum:** `InstructionType::CTokenRevoke` -**path:** programs/compressed-token/program/src/ctoken_approve_revoke.rs - -### SPL Instruction Format Compatibility - -**Important:** This instruction is only compatible with the SPL Token instruction format (using `spl_token_2022::instruction::revoke` with changed program ID) when **no top-up is required**. - -If the CToken account has a compressible extension and requires a rent top-up, the instruction needs the **system program account** to perform the lamports transfer. Without the system program account, the top-up CPI will fail. - -**Compatibility scenarios:** -- **SPL-compatible (no system program needed):** Non-compressible accounts, or compressible accounts with sufficient prepaid rent -- **NOT SPL-compatible (system program required):** Compressible accounts that need rent top-up based on current slot - -**description:** -Revokes any previously granted delegation on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). Before the revoke operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible revoke semantics. Supports backwards-compatible instruction data format (0 bytes legacy vs 2 bytes with max_top_up). The revoke operation follows SPL Token rules exactly (clears delegate and delegated_amount). - -**Instruction data:** -Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 71-106) - -- Empty (0 bytes): legacy format, no max_top_up enforcement (max_top_up = 0, no limit) -- Bytes 0-1 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit) - -**Accounts:** -1. source - - (mutable) - - The source ctoken account to revoke delegation on - - May receive rent top-up if compressible - -2. owner - - (signer, mutable) - - Owner of the source account - - Must sign the transaction - - Acts as payer for rent top-up if compressible extension present - -**Instruction Logic and Checks:** - -1. **Parse instruction data:** - - If 0 bytes: legacy format, set max_top_up = 0 (no limit) - - If 2 bytes: parse max_top_up (u16, little-endian) - - Return InvalidInstructionData for any other length - -2. **Validate minimum accounts:** - - Require at least 2 accounts (source, owner) - - Return NotEnoughAccountKeys if insufficient - -3. **Process compressible top-up:** - - Borrow source account data mutably - - Deserialize CToken using zero-copy validation - - Initialize lamports_budget based on max_top_up: - - If max_top_up == 0: budget = u64::MAX (no limit) - - Otherwise: budget = max_top_up + 1 (allows exact match) - - Call process_compression_top_up with source account's compression info - - Drop borrow before CPI - - If transfer_amount > 0: - - Check that transfer_amount <= lamports_budget - - Return MaxTopUpExceeded if budget exceeded - - Transfer lamports from owner to source via CPI - -4. **Process revoke (inline via pinocchio-token-program library):** - - Call process_revoke with accounts - - Clears the delegate field and delegated_amount on the source account - -**Errors:** - -- `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 0 or 2 bytes -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 2 accounts provided -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation -- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter -- `ProgramError::MissingRequiredSignature` (error code: 8) - Owner did not sign the transaction (SPL Token error) -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match account owner - - `TokenError::AccountFrozen` (error code: 17) - Account is frozen - -## Comparison with SPL Token - -### Functional Parity - -CToken delegates core logic to `pinocchio_token_program::processor::revoke::process_revoke`, which implements SPL Token-compatible revoke semantics: -- Delegate clearing, owner authority validation, frozen account check - -### CToken-Specific Features - -**1. Compressible Top-Up Logic** -Automatically tops up accounts with rent lamports before revoking to prevent accounts from becoming compressible. - -**2. max_top_up Parameter** -2-byte instruction format adds `max_top_up` (u16) to limit top-up costs. Fails with `MaxTopUpExceeded` (18043) if exceeded. - -### Unsupported SPL & Token-2022 Features - -**1. No Multisig Support** -**2. No Dual Authority Model** - Token-2022 allows both owner AND delegate to revoke; CToken only accepts owner -**3. No CPI Guard Extension Check** diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_THAW_ACCOUNT.md b/programs/compressed-token/program/docs/instructions/CTOKEN_THAW_ACCOUNT.md deleted file mode 100644 index e5c4386f0a..0000000000 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_THAW_ACCOUNT.md +++ /dev/null @@ -1,81 +0,0 @@ -## CToken Thaw Account - -**discriminator:** 11 -**enum:** `InstructionType::CTokenThawAccount` -**path:** programs/compressed-token/program/src/ctoken_freeze_thaw.rs - -**description:** -Thaws a frozen decompressed ctoken account, restoring normal operation. This is a pass-through instruction that validates mint ownership (must be owned by SPL Token, Token-2022, or CToken program) before delegating to pinocchio-token-program for standard SPL Token thaw validation. After thawing, the account's state field is set to AccountState::Initialized, and only the freeze_authority of the mint can thaw accounts (mint must have freeze_authority set). The account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. - -**Instruction data:** -No instruction data required beyond the discriminator byte. - -**Accounts:** -1. token_account - - (mutable) - - The frozen ctoken account to thaw - - Must be frozen (AccountState::Frozen) - - Will have state field updated to AccountState::Initialized - -2. mint - - The mint account associated with the token account - - Must be owned by SPL Token, Token-2022, or CToken program - - Must have freeze_authority set (not None) - -3. freeze_authority - - (signer) - - Must match the mint's freeze_authority - - Must sign the transaction - -**Instruction Logic and Checks:** - -1. **Validate minimum accounts:** - - Require at least 2 accounts to get mint account (index 1) - - Return NotEnoughAccountKeys if insufficient - -2. **Validate mint ownership:** - - Get mint account (accounts[1]) - - Call `check_token_program_owner(mint_info)` from programs/compressed-token/program/src/shared/owner_validation.rs - - Verify mint is owned by one of: - - SPL Token program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) - - Token-2022 program (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb) - - CToken program (cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m) - - Return IncorrectProgramId if mint owner doesn't match - -3. **Process thaw (inline via pinocchio-token-program library):** - - Call `process_thaw_account(accounts)` from pinocchio-token-program - - This performs standard SPL Token thaw validation: - - Verifies token_account is mutable - - Verifies freeze_authority is signer - - Verifies token_account.mint == mint.key() - - Verifies mint.freeze_authority == Some(freeze_authority.key()) - - Verifies token_account state is Frozen (not already Initialized) - - Updates token_account.state to AccountState::Initialized - - Map any errors from u64 to ProgramError::Custom(u32) - -**Errors:** -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 2 accounts provided (cannot get mint account) -- `ProgramError::IncorrectProgramId` (error code: 7) - Mint is not owned by a valid token program (SPL Token, Token-2022, or CToken) -- SPL Token errors from pinocchio-token-program (converted from u64 to ProgramError::Custom(u32)): - - `TokenError::MintCannotFreeze` (error code: 16) - Mint's freeze_authority is None - - `TokenError::OwnerMismatch` (error code: 4) - freeze_authority doesn't match mint's freeze_authority - - `TokenError::MintMismatch` (error code: 3) - token_account's mint doesn't match provided mint - - `TokenError::InvalidState` (error code: 13) - Account is not frozen or is uninitialized - - `ProgramError::InvalidAccountData` (error code: 4) - Account data is malformed - -## Comparison with SPL Token - -### Functional Parity - -CToken delegates core logic to `pinocchio_token_program::processor::thaw_account::process_thaw_account`, which implements SPL Token-compatible thaw semantics: -- State transition (Frozen → Initialized), freeze authority validation, mint association check - -### CToken-Specific Features - -**1. Explicit Mint Ownership Validation** -CToken adds `check_token_program_owner(mint)` before delegating to thaw logic, validating mint is owned by SPL Token, Token-2022, or CToken program. - -### Unsupported SPL & Token-2022 Features - -**1. No Multisig Support** -**2. No CPI Guard Extension Check** diff --git a/programs/compressed-token/program/src/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs similarity index 86% rename from programs/compressed-token/program/src/mint_action/accounts.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index 36ae0594e6..94fa8322b5 100644 --- a/programs/compressed-token/program/src/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -1,6 +1,7 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::solana_program::program_error::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; +use light_compressible::config::CompressibleConfig; use light_ctoken_interface::{ instructions::mint_action::{ZAction, ZMintActionCompressedInstructionData}, CMINT_ADDRESS_TREE, @@ -12,13 +13,13 @@ use spl_pod::solana_msg::msg; use crate::shared::{ accounts::{CpiContextLightSystemAccounts, LightSystemAccounts}, - AccountIterator, + next_config_account, AccountIterator, }; pub struct MintActionAccounts<'info> { pub light_system_program: &'info AccountInfo, - /// Seed for spl mint pda. - /// Required for mint and spl mint creation. + /// Seed for mint PDA derivation. + /// Required for compressed mint creation and DecompressMint. /// Note: mint_signer is not in executing accounts since create mint /// is allowed in combination with write to cpi context. pub mint_signer: Option<&'info AccountInfo>, @@ -41,10 +42,10 @@ pub struct MintActionAccounts<'info> { /// Required accounts to execute an instruction /// with or without cpi context. pub struct ExecutingAccounts<'info> { - /// CompressibleConfig account - required when creating CMint (always compressible). - pub compressible_config: Option<&'info AccountInfo>, + /// CompressibleConfig - parsed and validated (active state) when creating CMint. + pub compressible_config: Option<&'info CompressibleConfig>, /// CMint Solana account (decompressed compressed mint). - /// Required for DecompressMint action and when syncing with existing CMint. + /// Required for DecompressMint, CompressAndCloseCMint, and operations on decompressed mints. pub cmint: Option<&'info AccountInfo>, /// Rent sponsor PDA - required when creating CMint (pays for account). pub rent_sponsor: Option<&'info AccountInfo>, @@ -70,13 +71,11 @@ impl<'info> MintActionAccounts<'info> { accounts: &'info [AccountInfo], config: &AccountsConfig, 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")?; - // mint_signer needs to sign for create_mint/create_spl_mint, but not for decompress_mint + // mint_signer needs to sign for create_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 { @@ -101,11 +100,14 @@ impl<'info> MintActionAccounts<'info> { packed_accounts: ProgramPackedAccounts { accounts: &[] }, }) } else { - // Parse compressible config when creating or closing CMint - let compressible_config = - iter.next_option("compressible_config", config.needs_compressible_accounts())?; + // Parse and validate compressible config when creating or closing CMint + let compressible_config = if config.needs_compressible_accounts() { + Some(next_config_account(&mut iter)?) + } else { + None + }; - // CMint account required if already decompressed (for sync) OR being decompressed/closed + // CMint account required if already decompressed OR being decompressed/closed let cmint = iter.next_option_mut("cmint", config.needs_cmint_account())?; // Parse rent_sponsor when creating or closing CMint @@ -134,7 +136,7 @@ impl<'info> MintActionAccounts<'info> { let in_output_queue = iter.next_option("in_output_queue", !config.create_mint)?; // Only needed for minting to compressed token accounts let tokens_out_queue = - iter.next_option("tokens_out_queue", config.has_mint_to_actions)?; + iter.next_option("tokens_out_queue", config.require_token_output_queue)?; let mint_accounts = MintActionAccounts { mint_signer, @@ -156,7 +158,7 @@ impl<'info> MintActionAccounts<'info> { accounts: iter.remaining_unchecked()?, }, }; - mint_accounts.validate_accounts(cmint_pubkey, token_pool_index, token_pool_bump)?; + mint_accounts.validate_accounts(cmint_pubkey)?; Ok(mint_accounts) } @@ -304,14 +306,13 @@ impl<'info> MintActionAccounts<'info> { pub fn validate_accounts( &self, 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)?; + // TODO: check whether we can simplify or move to decompress action processor. // 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) { @@ -345,11 +346,11 @@ pub struct AccountsConfig { /// 2. cpi context.first_set() || cpi context.set() pub write_to_cpi_context: 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. + /// When true, the CMint account is the decompressed (compressed account is empty). pub cmint_decompressed: bool, /// 5. Mint - pub has_mint_to_actions: bool, - /// 6. Either compressed mint and/or spl mint is created. + pub require_token_output_queue: bool, + /// 6. Compressed mint is created or DecompressMint action is present. pub with_mint_signer: bool, /// 7. Compressed mint is created. pub create_mint: bool, @@ -360,11 +361,11 @@ pub struct AccountsConfig { } impl AccountsConfig { - /// Returns true when CMint Solana account is the source of truth for mint data. + /// Returns true when CMint Solana account is the decompressed 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 { + pub fn cmint_output_decompressed(&self) -> bool { (self.has_decompress_mint_action || self.cmint_decompressed) && !self.has_compress_and_close_cmint_action } @@ -386,11 +387,11 @@ impl AccountsConfig { } /// Returns true if mint_signer must be a signer. - /// Required for create_mint and create_spl_mint, but NOT for decompress_mint. + /// Required for create_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 + self.create_mint } /// Initialize AccountsConfig based in instruction data. - @@ -418,11 +419,6 @@ impl AccountsConfig { .as_ref() .map(|x| x.first_set_context() || x.set_context()) .unwrap_or_default(); - // An action in this instruction creates a the spl mint corresponding to a compressed mint. - let create_spl_mint = parsed_instruction_data - .actions - .iter() - .any(|action| matches!(action, ZAction::CreateSplMint(_))); // Check if DecompressMint action is present let has_decompress_mint_action = parsed_instruction_data @@ -442,19 +438,20 @@ impl AccountsConfig { return Err(ErrorCode::CannotDecompressAndCloseInSameInstruction.into()); } - // We need mint signer if create mint, create spl mint, or decompress mint. + // Validation: CompressAndCloseCMint must be the only action + if has_compress_and_close_cmint_action && parsed_instruction_data.actions.len() != 1 { + msg!("CompressAndCloseCMint must be the only action in the instruction"); + return Err(ErrorCode::CompressAndCloseCMintMustBeOnlyAction.into()); + } + + // We need mint signer if create 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 with_mint_signer = + parsed_instruction_data.create_mint.is_some() || has_decompress_mint_action; + // CMint account needed when mint is already decompressed (metadata flag) + // When mint is None, CMint is decompressed (data lives in CMint account, compressed account is empty) let cmint_decompressed = parsed_instruction_data.mint.is_none(); - if cmint_decompressed && create_spl_mint { - return Err(ProgramError::InvalidInstructionData); - } - if write_to_cpi_context { // Must not have any MintToCToken actions let has_mint_to_ctoken_actions = parsed_instruction_data @@ -465,28 +462,24 @@ impl AccountsConfig { msg!("Mint to ctokens not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } - if create_spl_mint { - msg!("Create spl mint not allowed when writing to cpi context"); + if has_decompress_mint_action { + msg!("Decompress 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"); + + if cmint_decompressed { + msg!("CMint decompressed not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } - let has_mint_to_actions = parsed_instruction_data + let require_token_output_queue = parsed_instruction_data .actions .iter() .any(|action| matches!(action, ZAction::MintToCompressed(_))); - 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, cmint_decompressed, - has_mint_to_actions, + require_token_output_queue, with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), has_decompress_mint_action, @@ -496,7 +489,7 @@ impl AccountsConfig { // For MintToCompressed actions // - needed for tokens_out_queue (only MintToCompressed creates new compressed outputs) // - MintToCToken mints to existing decompressed accounts, doesn't need tokens_out_queue - let has_mint_to_actions = parsed_instruction_data + let require_token_output_queue = parsed_instruction_data .actions .iter() .any(|action| matches!(action, ZAction::MintToCompressed(_))); @@ -505,7 +498,7 @@ impl AccountsConfig { with_cpi_context, write_to_cpi_context, cmint_decompressed, - has_mint_to_actions, + require_token_output_queue, with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), has_decompress_mint_action, diff --git a/programs/compressed-token/program/src/mint_action/actions/authority.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/authority.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/actions/authority.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/authority.rs diff --git a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs similarity index 92% rename from programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs index 4434953a1c..9ec9ddf8b9 100644 --- a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs @@ -11,7 +11,7 @@ use pinocchio::{ use spl_pod::solana_msg::msg; use crate::{ - mint_action::accounts::MintActionAccounts, + compressed_token::mint_action::accounts::MintActionAccounts, shared::{convert_program_error, transfer_lamports::transfer_lamports}, }; @@ -40,10 +40,9 @@ pub fn process_compress_and_close_cmint_action( ) -> Result<(), ProgramError> { // NOTE: CompressAndCloseCMint is permissionless - anyone can compress if is_compressible() returns true // All lamports returned to rent_sponsor - - // 1. Idempotent check - if CMint doesn't exist and idempotent is set, succeed silently + // 1. Idempotent check - if CMint doesn't exist and idempotent is set, return early exit error to skip CPI if action.is_idempotent() && !compressed_mint.metadata.cmint_decompressed { - return Ok(()); + return Err(ErrorCode::IdempotentEarlyExit.into()); } // 2. Check CMint exists (is decompressed) @@ -71,10 +70,9 @@ pub fn process_compress_and_close_cmint_action( return Err(ErrorCode::InvalidCMintAccount.into()); } - // 4. Access compression info directly (all cmints now have embedded compression) let compression_info = &compressed_mint.compression; - // 5. Verify rent_sponsor matches compression info + // 4. Verify rent_sponsor matches compression info if !pubkey_eq(rent_sponsor.key(), &compression_info.rent_sponsor) { msg!("Rent sponsor does not match compression info"); return Err(ErrorCode::InvalidRentSponsor.into()); @@ -92,7 +90,7 @@ pub fn process_compress_and_close_cmint_action( if is_compressible.is_none() { if action.is_idempotent() { - return Ok(()); + return Err(ErrorCode::IdempotentEarlyExit.into()); } msg!("CMint is not compressible (rent not expired)"); return Err(ErrorCode::CMintNotCompressible.into()); @@ -107,9 +105,7 @@ pub fn process_compress_and_close_cmint_action( unsafe { cmint.assign(&[0u8; 32]); } - cmint - .resize(0) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000))?; + cmint.resize(0).map_err(convert_program_error)?; } // 8. Set cmint_decompressed = false compressed_mint.metadata.cmint_decompressed = false; diff --git a/programs/compressed-token/program/src/mint_action/actions/create_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs similarity index 94% rename from programs/compressed-token/program/src/mint_action/actions/create_mint.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs index 0aef09ed01..561ee709c3 100644 --- a/programs/compressed-token/program/src/mint_action/actions/create_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs @@ -26,7 +26,7 @@ pub fn process_create_mint_action( // 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( + let mint_pda = solana_pubkey::Pubkey::find_program_address( &[COMPRESSED_MINT_SEED, mint_signer.as_slice()], &crate::ID, ) @@ -38,7 +38,7 @@ pub fn process_create_mint_action( .as_ref() .ok_or(ProgramError::InvalidInstructionData)?; - if !pubkey_eq(&spl_mint_pda, mint.metadata.mint.array_ref()) { + if !pubkey_eq(&mint_pda, mint.metadata.mint.array_ref()) { msg!("Invalid mint PDA derivation"); return Err(ErrorCode::MintActionInvalidMintPda.into()); } @@ -56,11 +56,11 @@ pub fn process_create_mint_action( return Err(ErrorCode::MintActionInvalidCpiContextAddressTreePubkey.into()); } let address = light_compressed_account::address::derive_address( - &spl_mint_pda, + &mint_pda, &cpi_context.address_tree_pubkey, &crate::LIGHT_CPI_SIGNER.program_id, ); - if address != parsed_instruction_data.compressed_address { + if address != mint.metadata.compressed_address { msg!("Invalid compressed mint address derivation"); return Err(ErrorCode::MintActionInvalidCompressedMintAddress.into()); } @@ -68,7 +68,7 @@ pub fn process_create_mint_action( // 2. Create NewAddressParams cpi_instruction_struct.new_address_params[0].set( - spl_mint_pda, + mint_pda, parsed_instruction_data.root_index, Some( parsed_instruction_data diff --git a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs similarity index 71% rename from programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index b84bd1c955..69303471f9 100644 --- a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -1,6 +1,7 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; -use light_compressible::{compression_info::CompressionInfo, rent::RentConfig}; +use light_array_map::pubkey_eq; +use light_compressible::compression_info::CompressionInfo; use light_ctoken_interface::{ instructions::mint_action::ZDecompressMintAction, state::CompressedMint, COMPRESSED_MINT_SEED, }; @@ -8,14 +9,13 @@ use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, instruction::Seed, - sysvars::{clock::Clock, Sysvar}, + sysvars::{clock::Clock, rent::Rent, Sysvar}, }; use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; use crate::{ - create_token_account::parse_config_account, - mint_action::accounts::MintActionAccounts, + compressed_token::mint_action::accounts::MintActionAccounts, shared::{ convert_program_error, create_pda_account::{create_pda_account, verify_pda}, @@ -33,16 +33,11 @@ use crate::{ /// 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) +/// 8. **Flag Update**: Set cmint_decompressed flag /// /// ## Note -/// DecompressMint is **permissionless** - anyone can call it (they pay for the CMint creation). +/// DecompressMint is **permissionless** - the caller pays initial rent, rent exemption is sponsored by the rent_sponsor. /// 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, @@ -51,16 +46,13 @@ pub fn process_decompress_mint_action( 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()); } - // rent_payment == 1 is rejected - epoch boundary edge case + // 2. rent_payment == 1 is rejected - epoch boundary edge case if action.rent_payment == 1 { msg!("Prefunding for exactly 1 epoch is not allowed. Use 0 or 2+ epochs."); return Err(ErrorCode::OneEpochPrefundingNotAllowed.into()); @@ -75,14 +67,12 @@ pub fn process_decompress_mint_action( .cmint .ok_or(ErrorCode::MintActionMissingCMintAccount)?; - // 3. Get and validate CompressibleConfig account - let config_account = executing + // 3. Get CompressibleConfig (already parsed and validated as active) + let config = executing .compressible_config .ok_or(ErrorCode::MissingCompressibleConfig)?; - let config = parse_config_account(config_account)?; - - // 5. Validate write_top_up doesn't exceed max_top_up + // 4. 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 {}", @@ -92,7 +82,7 @@ pub fn process_decompress_mint_action( return Err(ErrorCode::WriteTopUpExceedsMaximum.into()); } - // 6. Get rent_sponsor and verify it matches config + // Get rent_sponsor and verify it matches config let rent_sponsor = executing .rent_sponsor .ok_or(ErrorCode::MissingRentSponsor)?; @@ -102,12 +92,12 @@ pub fn process_decompress_mint_action( return Err(ErrorCode::InvalidRentSponsor.into()); } - // 7. Get current slot for last_claimed_slot + // Get current slot for last_claimed_slot let current_slot = Clock::get() .map_err(|_| ProgramError::UnsupportedSysvar)? .slot; - // 8. Set compression info directly on compressed_mint (all cmints now have embedded compression) + // 5. Set compression info on compressed_mint (rent_exemption_paid set after account_size calculation) compressed_mint.compression = CompressionInfo { config_account_version: config.version, compress_to_pubkey: 0, // Not applicable for CMint @@ -116,16 +106,12 @@ pub fn process_decompress_mint_action( 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, - }, + rent_exemption_paid: 0, // Updated below after account_size calculation + _reserved: 0, + rent_config: config.rent_config, }; - // 9. Verify PDA derivation + // 6. Verify PDA derivation let seeds: [&[u8]; 2] = [COMPRESSED_MINT_SEED, mint_signer.key()]; verify_pda( cmint.key(), @@ -133,18 +119,32 @@ pub fn process_decompress_mint_action( action.cmint_bump, &crate::LIGHT_CPI_SIGNER.program_id, )?; + // 6b. Verify CMint account matches compressed_mint.metadata.mint + if !pubkey_eq(cmint.key(), &compressed_mint.metadata.mint.to_bytes()) { + msg!("CMint account does not match compressed_mint.metadata.mint"); + return Err(ErrorCode::InvalidCMintAccount.into()); + } - // 10. Calculate account size AFTER adding extension (using borsh serialization) + // 7. Account creation: rent_sponsor pays rent exemption, fee_payer pays Light rent + // 7a. 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) + // 7a.1. Store rent exemption at creation (only query Rent sysvar here, never again) + let rent_exemption_paid: u32 = Rent::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .minimum_balance(account_size) + .try_into() + .map_err(|_| ProgramError::ArithmeticOverflow)?; + compressed_mint.compression.rent_exemption_paid = rent_exemption_paid; + + // 7b. 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) + // 7c. 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 = [ @@ -153,7 +153,7 @@ pub fn process_decompress_mint_action( Seed::from(rent_sponsor_bump_bytes.as_ref()), ]; - // 13. Build seeds for CMint PDA + // 7d. Build seeds for CMint PDA let cmint_bump_bytes = [action.cmint_bump]; let cmint_seeds = [ Seed::from(COMPRESSED_MINT_SEED), @@ -161,7 +161,7 @@ pub fn process_decompress_mint_action( Seed::from(cmint_bump_bytes.as_ref()), ]; - // 14. Create CMint PDA account + // 7e. 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( @@ -173,7 +173,7 @@ pub fn process_decompress_mint_action( None, // rent_sponsor pays ONLY rent exemption )?; - // 15. fee_payer pays the Light Protocol rent + // 7f. fee_payer pays the Light Protocol rent Transfer { from: fee_payer, to: cmint, @@ -182,7 +182,7 @@ pub fn process_decompress_mint_action( .invoke() .map_err(convert_program_error)?; - // 16. Set the cmint_decompressed flag + // 8. Set the cmint_decompressed flag compressed_mint.metadata.cmint_decompressed = true; Ok(()) diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs similarity index 81% rename from programs/compressed-token/program/src/mint_action/actions/mint_to.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs index dc538f4f9b..1f096e686c 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs @@ -9,7 +9,7 @@ use light_program_profiler::profile; use light_sdk_pinocchio::instruction::ZOutputCompressedAccountWithPackedContextMut; use crate::{ - mint_action::{accounts::MintActionAccounts, check_authority}, + compressed_token::mint_action::{accounts::MintActionAccounts, check_authority}, shared::token_output::set_output_compressed_account, }; @@ -18,15 +18,8 @@ use crate::{ /// ## Process Steps /// 1. **Authority Validation**: Verify signer matches current mint authority from compressed mint state /// 2. **Amount Calculation**: Sum recipient amounts with overflow protection -/// 3. **Lamports Calculation**: Calculate total lamports for compressed accounts (if specified) -/// 4. **Supply Update**: Calculate new total supply with overflow protection -/// 5. **SPL Mint Synchronization**: For initialized SPL mints, validate accounts and mint equivalent tokens to token pool via CPI -/// 6. **Compressed Account Creation**: Create new compressed token account for each recipient -/// -/// ## SPL Mint Synchronization -/// 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. +/// 3. **Supply Update**: Calculate new total supply with overflow protection +/// 4. **Compressed Account Creation**: Create new compressed token account for each recipient #[allow(clippy::too_many_arguments)] #[profile] pub fn process_mint_to_compressed_action<'a>( diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs similarity index 91% rename from programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs index 91001d5f4b..42c16fd356 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs @@ -8,7 +8,7 @@ use light_ctoken_interface::{ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; -use crate::{ +use crate::compressed_token::{ mint_action::{accounts::MintActionAccounts, check_authority}, transfer2::compression::{compress_or_decompress_ctokens, CTokenCompressionInputs}, }; @@ -41,8 +41,7 @@ pub fn process_mint_to_ctoken_action( let token_account_info = packed_accounts.get_u8(action.account_index, "ctoken mint to recipient")?; - // Authority check now performed above - safe to proceed with decompression - // Use the mint_ctokens constructor for simple decompression operations + // Authority check performed above - proceed with minting to CToken account let inputs = CTokenCompressionInputs::mint_ctokens( amount, mint.to_bytes(), diff --git a/programs/compressed-token/program/src/mint_action/actions/mod.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mod.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/actions/mod.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/mod.rs diff --git a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs similarity index 92% rename from programs/compressed-token/program/src/mint_action/actions/process_actions.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs index 38a9bd0b72..2527be3a99 100644 --- a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs @@ -14,7 +14,7 @@ use pinocchio::account_info::AccountInfo; use spl_pod::solana_msg::msg; use crate::{ - mint_action::{ + compressed_token::mint_action::{ accounts::MintActionAccounts, check_authority, compress_and_close_cmint::process_compress_and_close_cmint_action, @@ -86,17 +86,6 @@ pub fn process_actions<'a>( compressed_mint.base.freeze_authority = update_action.new_authority.as_ref().map(|a| **a); } - // TODO: Remove CreateSplMint - dead code, never activated - ZAction::CreateSplMint(_create_spl_action) => { - return Err(ErrorCode::MintActionUnsupportedOperation.into()); - // process_create_spl_mint_action( - // create_spl_action, - // validated_accounts, - // &parsed_instruction_data.mint, - // parsed_instruction_data.token_pool_bump, - // )?; - // compressed_mint.metadata.cmint_decompressed = true; - } ZAction::MintToCToken(mint_to_ctoken_action) => { let account_index = mint_to_ctoken_action.account_index as usize; if account_index >= MAX_PACKED_ACCOUNTS { diff --git a/programs/compressed-token/program/src/mint_action/actions/update_metadata.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs similarity index 97% rename from programs/compressed-token/program/src/mint_action/actions/update_metadata.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs index 5048ee7bdc..7738b17be2 100644 --- a/programs/compressed-token/program/src/mint_action/actions/update_metadata.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs @@ -10,7 +10,7 @@ use light_ctoken_interface::{ use light_program_profiler::profile; use spl_pod::solana_msg::msg; -use crate::mint_action::check_authority; +use crate::compressed_token::mint_action::check_authority; /// Get mutable reference to metadata extension at specified index #[profile] @@ -122,7 +122,7 @@ pub fn process_update_metadata_authority_action( Ok(()) } -/// Only checks authority, the key is removed during data allocation. +/// Removes a metadata key from additional_metadata after authority check. #[profile] pub fn process_remove_metadata_key_action( action: &ZRemoveMetadataKeyAction, diff --git a/programs/compressed-token/program/src/mint_action/mint_input.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs similarity index 54% rename from programs/compressed-token/program/src/mint_action/mint_input.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs index ef4c8ff306..bf88821327 100644 --- a/programs/compressed-token/program/src/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs @@ -1,44 +1,37 @@ use anchor_lang::solana_program::program_error::ProgramError; use borsh::BorshSerialize; use light_compressed_account::instruction_data::with_readonly::ZInAccountMut; -use light_ctoken_interface::{ - instructions::mint_action::ZMintActionCompressedInstructionData, state::CompressedMint, -}; +use light_ctoken_interface::state::CompressedMint; use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; use light_sdk::instruction::PackedMerkleContext; +use light_zero_copy::U16; -use crate::{constants::COMPRESSED_MINT_DISCRIMINATOR, mint_action::accounts::AccountsConfig}; +use crate::{ + compressed_token::mint_action::accounts::AccountsConfig, + constants::COMPRESSED_MINT_DISCRIMINATOR, +}; /// 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. Determine if CMint is source of truth (use zero values) or data from instruction +/// 1. Determine if CMint is decompressed (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, + root_index: U16, merkle_context: PackedMerkleContext, accounts_config: &AccountsConfig, + compressed_mint: &CompressedMint, ) -> 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 + // When CMint was decompressed (input state BEFORE actions), use zero values 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)?; - // Return it so that we dont deserialize it twice. - let compressed_mint = CompressedMint::try_from(mint_data)?; let bytes = compressed_mint .try_to_vec() .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; @@ -53,9 +46,9 @@ pub fn create_input_compressed_mint_account( discriminator, input_data_hash, &merkle_context, - mint_instruction_data.root_index, + root_index, 0, - Some(mint_instruction_data.compressed_address.as_ref()), + Some(compressed_mint.metadata.compressed_address.as_ref()), )?; Ok(()) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs new file mode 100644 index 0000000000..490a50eae2 --- /dev/null +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -0,0 +1,195 @@ +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, +}; +use light_hasher::{sha256::Sha256BE, Hasher}; +use light_program_profiler::profile; +use pinocchio::sysvars::{clock::Clock, Sysvar}; +use spl_pod::solana_msg::msg; + +use crate::{ + compressed_token::mint_action::{ + accounts::{AccountsConfig, MintActionAccounts}, + actions::process_actions, + queue_indices::QueueIndices, + }, + constants::COMPRESSED_MINT_DISCRIMINATOR, + shared::{convert_program_error, transfer_lamports::transfer_lamports}, +}; + +/// Processes the output compressed mint account. +/// When decompressed, writes mint data to CMint account (compressed account is empty). +#[profile] +pub fn process_output_compressed_account<'a>( + parsed_instruction_data: &ZMintActionCompressedInstructionData, + validated_accounts: &MintActionAccounts, + output_compressed_accounts: &'a mut [ZOutputCompressedAccountWithPackedContextMut<'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); + + process_actions( + parsed_instruction_data, + validated_accounts, + &mut token_accounts.iter_mut(), + hash_cache, + queue_indices, + &validated_accounts.packed_accounts, + &mut compressed_mint, + )?; + + if compressed_mint.metadata.cmint_decompressed { + serialize_decompressed_mint(validated_accounts, accounts_config, &mut compressed_mint)?; + } + + serialize_compressed_mint(mint_account, compressed_mint, queue_indices) +} + +#[inline(always)] +fn split_mint_and_token_accounts<'a>( + output_compressed_accounts: &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], +) -> ( + &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, + &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], +) { + if output_compressed_accounts.len() == 1 { + (&mut output_compressed_accounts[0], &mut []) + } else { + let (mint_account, token_accounts) = output_compressed_accounts.split_at_mut(1); + (&mut mint_account[0], token_accounts) + } +} + +fn serialize_compressed_mint<'a>( + mint_account: &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, + compressed_mint: CompressedMint, + queue_indices: &QueueIndices, +) -> Result<(), ProgramError> { + let compressed_account_data = mint_account + .compressed_account + .data + .as_mut() + .ok_or(ErrorCode::MintActionOutputSerializationFailed)?; + + let (discriminator, data_hash) = if compressed_mint.metadata.cmint_decompressed { + if !compressed_account_data.data.is_empty() { + msg!( + "Data allocation for output mint account is wrong: {} (expected) != {} ", + 0, + compressed_account_data.data.len() + ); + return Err(ProgramError::InvalidAccountData); + } + // Zeroed discriminator and data hash preserve the address + // of a closed compressed account without any data. + ([0u8; 8], [0u8; 32]) + } else { + 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: {} (expected) != {}", + data.len(), + compressed_account_data.data.len() + ); + return Err(ProgramError::InvalidAccountData); + } + + compressed_account_data + .data + .copy_from_slice(data.as_slice()); + + ( + COMPRESSED_MINT_DISCRIMINATOR, + Sha256BE::hash(compressed_account_data.data)?, + ) + }; + + mint_account.set( + crate::LIGHT_CPI_SIGNER.program_id.into(), + 0, + Some(compressed_mint.metadata.compressed_address), + queue_indices.output_queue_index, + discriminator, + data_hash, + )?; + Ok(()) +} + +fn serialize_decompressed_mint( + validated_accounts: &MintActionAccounts, + accounts_config: &AccountsConfig, + compressed_mint: &mut CompressedMint, +) -> Result<(), ProgramError> { + let cmint_account = validated_accounts + .get_cmint() + .ok_or(ErrorCode::CMintNotFound)?; + + // STEP 1: Serialize FIRST to know final size + let serialized = compressed_mint + .try_to_vec() + .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)?; + let required_size = serialized.len(); + + // STEP 2: Resize if needed (before lamport calculations) + if cmint_account.data_len() != required_size { + cmint_account + .resize(required_size) + .map_err(|_| ErrorCode::CMintResizeFailed)?; + } + + // STEP 3: Calculate rent exemption deficit FIRST (based on final size) + let num_bytes = required_size as u64; + let rent_exemption = + get_rent_exemption_lamports(num_bytes).map_err(|_| ErrorCode::CMintRentExemptionFailed)?; + + // Only update rent_exemption_paid if new rent exemption is higher + // (sponsor should get back what they originally paid) + let rent_exemption_u32: u32 = rent_exemption + .try_into() + .map_err(|_| ProgramError::ArithmeticOverflow)?; + let mut deficit = 0u64; + if rent_exemption_u32 > compressed_mint.compression.rent_exemption_paid { + deficit = (rent_exemption_u32 - compressed_mint.compression.rent_exemption_paid) as u64; + compressed_mint.compression.rent_exemption_paid = rent_exemption_u32; + } + + // STEP 4: Add compressible top-up if not a fresh decompress + if !accounts_config.has_decompress_mint_action { + let current_lamports = cmint_account.lamports(); + let current_slot = Clock::get().map_err(convert_program_error)?.slot; + let top_up = compressed_mint + .compression + .calculate_top_up_lamports(num_bytes, current_slot, current_lamports) + .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; + // Add compressible top-up to rent deficit + deficit = deficit.saturating_add(top_up); + } + + // STEP 5: Transfer lamports if needed + if deficit > 0 { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + transfer_lamports(deficit, fee_payer, cmint_account).map_err(convert_program_error)?; + } + + // STEP 6: Write serialized data + let mut cmint_data = cmint_account + .try_borrow_mut_data() + .map_err(|_| ProgramError::AccountBorrowFailed)?; + cmint_data[..serialized.len()].copy_from_slice(&serialized); + + Ok(()) +} diff --git a/programs/compressed-token/program/src/mint_action/mod.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mod.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/mod.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/mod.rs diff --git a/programs/compressed-token/program/src/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs similarity index 80% rename from programs/compressed-token/program/src/mint_action/processor.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index 14ba3ec9dd..a9ab508107 100644 --- a/programs/compressed-token/program/src/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -1,4 +1,4 @@ -use anchor_compressed_token::ErrorCode; +use anchor_compressed_token::{is_idempotent_early_exit, ErrorCode}; use anchor_lang::prelude::ProgramError; use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnly; use light_ctoken_interface::{ @@ -10,7 +10,7 @@ use light_zero_copy::{traits::ZeroCopyAt, ZeroCopyNew}; use pinocchio::account_info::AccountInfo; use crate::{ - mint_action::{ + compressed_token::mint_action::{ accounts::{AccountsConfig, MintActionAccounts}, create_mint::process_create_mint_action, mint_input::create_input_compressed_mint_account, @@ -38,37 +38,34 @@ pub fn process_mint_action( .as_ref() .map(|m| m.metadata.mint.into()); // Validate and parse - let validated_accounts = MintActionAccounts::validate_and_parse( - accounts, - &accounts_config, - cmint_pubkey.as_ref(), - parsed_instruction_data.token_pool_index, - parsed_instruction_data.token_pool_bump, - )?; + let validated_accounts = + MintActionAccounts::validate_and_parse(accounts, &accounts_config, cmint_pubkey.as_ref())?; // 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() { + // 3. CMint is decompressed: read from CMint account (cmint_decompressed = true) + let mint = if accounts_config.create_mint { // 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() { + } else if !accounts_config.cmint_decompressed { // Existing compressed mint with data in instruction + // In case that cmint is not actually compressed proof verification will fail. + let mint_data = parsed_instruction_data + .mint + .as_ref() + .ok_or(ErrorCode::MintDataRequired)?; CompressedMint::try_from(mint_data)? } else { - // CMint is source of truth - read from CMint account + // CMint is decompressed - 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, - )? + CompressedMint::from_account_info_checked(cmint_account)? }; let (config, mut cpi_bytes, _) = @@ -95,7 +92,7 @@ pub fn process_mint_action( let queue_keys_match = validated_accounts.queue_keys_match(); let queue_indices = QueueIndices::new( parsed_instruction_data.cpi_context.as_ref(), - parsed_instruction_data.create_mint.is_some(), + accounts_config.create_mint, tokens_out_queue_exists, queue_keys_match, accounts_config.write_to_cpi_context, @@ -105,7 +102,7 @@ pub fn process_mint_action( // 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() { + if accounts_config.create_mint { // Creating new mint - mint data required in instruction process_create_mint_action( &parsed_instruction_data, @@ -118,11 +115,12 @@ pub fn process_mint_action( queue_indices.address_merkle_tree_index, )?; } else { - // Decompressed mint (CMint is source of truth) - data from CMint account - // Set input with zero values (data lives in CMint) + // Existing mint - set input compressed account + // When CMint is decompressed, input has zero values + // When data from instruction, input has real data hash create_input_compressed_mint_account( &mut cpi_instruction_struct.input_compressed_accounts[0], - &parsed_instruction_data, + parsed_instruction_data.root_index, PackedMerkleContext { merkle_tree_pubkey_index: queue_indices.in_tree_index, queue_pubkey_index: queue_indices.in_queue_index, @@ -130,10 +128,11 @@ pub fn process_mint_action( prove_by_index: parsed_instruction_data.prove_by_index(), }, &accounts_config, + &mint, )?; }; - process_output_compressed_account( + let result = process_output_compressed_account( &parsed_instruction_data, &validated_accounts, &mut cpi_instruction_struct.output_compressed_accounts, @@ -141,7 +140,15 @@ pub fn process_mint_action( &queue_indices, mint, &accounts_config, - )?; + ); + + // Check for idempotent early exit - skip CPI and return success + if let Err(ref err) = result { + if is_idempotent_early_exit(err) { + return Ok(()); + } + } + result?; let cpi_accounts = validated_accounts.get_cpi_accounts(queue_indices.deduplicated, accounts)?; if let Some(executing) = validated_accounts.executing.as_ref() { @@ -155,7 +162,7 @@ pub fn process_mint_action( false, // no sol_pool_pda None, executing.system.cpi_context.map(|x| *x.key()), - false, // write to cpi context account + false, // don't write to cpi context account ) } else { if validated_accounts.write_to_cpi_context_system.is_none() { diff --git a/programs/compressed-token/program/src/mint_action/queue_indices.rs b/programs/compressed-token/program/src/compressed_token/mint_action/queue_indices.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/queue_indices.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/queue_indices.rs diff --git a/programs/compressed-token/program/src/mint_action/zero_copy_config.rs b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs similarity index 90% rename from programs/compressed-token/program/src/mint_action/zero_copy_config.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs index 6bd87282af..24f453d035 100644 --- a/programs/compressed-token/program/src/mint_action/zero_copy_config.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs @@ -10,7 +10,7 @@ use spl_pod::solana_msg::msg; use tinyvec::ArrayVec; use crate::{ - mint_action::accounts::AccountsConfig, + compressed_token::mint_action::accounts::AccountsConfig, shared::{ convert_program_error, cpi_bytes_size::{ @@ -47,7 +47,7 @@ pub fn get_zero_copy_configs( ZAction::UpdateFreezeAuthority(_) => {} ZAction::RemoveMetadataKey(_) => {} ZAction::UpdateMetadataAuthority(auth_action) => { - // Update output config for authority revocation + // Validate extension index for authority revocation if auth_action.new_authority.to_bytes() == [0u8; 32] { let extension_index = auth_action.extension_index as usize; if extension_index >= output_extensions_config.len() { @@ -85,8 +85,7 @@ 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 cmint_is_decompressed = accounts_config.cmint_output_decompressed(); let input = CpiConfigInput { input_accounts: { @@ -100,8 +99,8 @@ pub fn get_zero_copy_configs( output_accounts: { let mut outputs = ArrayVec::new(); // First output is always the mint account - // When CMint is source of truth, use data_len=0 (zero discriminator/hash) - let mint_data_len = if cmint_is_source_of_truth { + // When CMint is decompressed, use data_len=0 (zero discriminator & hash) + let mint_data_len = if cmint_is_decompressed { 0 } else { mint_data_len(&output_mint_config) @@ -111,7 +110,7 @@ pub fn get_zero_copy_configs( // Add token accounts for recipients for _ in 0..num_recipients { outputs.push((false, compressed_token_data_len(false))); - // No delegates for simple mint + // No delegates for mint to } outputs }, diff --git a/programs/compressed-token/program/src/compressed_token/mod.rs b/programs/compressed-token/program/src/compressed_token/mod.rs new file mode 100644 index 0000000000..ebc41da8c3 --- /dev/null +++ b/programs/compressed-token/program/src/compressed_token/mod.rs @@ -0,0 +1,2 @@ +pub mod mint_action; +pub mod transfer2; diff --git a/programs/compressed-token/program/src/transfer2/accounts.rs b/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs similarity index 96% rename from programs/compressed-token/program/src/transfer2/accounts.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs index 5b47c229df..1eb0ecd67c 100644 --- a/programs/compressed-token/program/src/transfer2/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs @@ -6,11 +6,11 @@ use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; use spl_pod::solana_msg::msg; use crate::{ + compressed_token::transfer2::config::Transfer2Config, shared::{ accounts::{CpiContextLightSystemAccounts, LightSystemAccounts}, AccountIterator, }, - transfer2::config::Transfer2Config, }; /// 3 Scenarios: @@ -105,7 +105,7 @@ impl<'info> Transfer2Accounts<'info> { } /// Extract CPI accounts slice for light-system-program invocation - /// Includes static accounts + tree accounts based on highest tree index + /// Includes static accounts + tree accounts identified by owner /// Returns (cpi_accounts_slice, tree_accounts) #[profile] #[inline(always)] @@ -137,7 +137,7 @@ impl<'info> Transfer2Accounts<'info> { } } -/// Extract tree accounts by finding the highest tree index and using it as closing offset +/// Extract tree accounts by checking account owner matches account-compression program #[profile] #[inline(always)] pub fn extract_tree_accounts<'info>( diff --git a/programs/compressed-token/program/src/transfer2/change_account.rs b/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs similarity index 96% rename from programs/compressed-token/program/src/transfer2/change_account.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs index 4c5bb6fdd4..e65ea661bb 100644 --- a/programs/compressed-token/program/src/transfer2/change_account.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs @@ -1,4 +1,4 @@ -//! unused +//! Change account handling for lamports differences in Transfer2 use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; @@ -6,7 +6,7 @@ use light_compressed_account::instruction_data::with_readonly::ZInstructionDataI use light_ctoken_interface::instructions::transfer2::ZCompressedTokenInstructionDataTransfer2; use pinocchio::account_info::AccountInfo; -use crate::transfer2::config::Transfer2Config; +use crate::compressed_token::transfer2::config::Transfer2Config; /// Create a change account for excess lamports (following anchor program pattern) pub fn assign_change_account( diff --git a/programs/compressed-token/program/src/transfer2/check_extensions.rs b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs similarity index 66% rename from programs/compressed-token/program/src/transfer2/check_extensions.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs index 2cffecf417..f3c67fd536 100644 --- a/programs/compressed-token/program/src/transfer2/check_extensions.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs @@ -2,8 +2,11 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_array_map::ArrayMap; -use light_ctoken_interface::instructions::{ - extensions::ZExtensionInstructionData, transfer2::ZCompressedTokenInstructionDataTransfer2, +use light_ctoken_interface::{ + instructions::{ + extensions::ZExtensionInstructionData, transfer2::ZCompressedTokenInstructionDataTransfer2, + }, + state::TokenDataVersion, }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; @@ -21,7 +24,7 @@ pub fn validate_tlv_and_get_frozen( version: u8, ) -> Result { // Validate TLV is only used with version 3 (ShaFlat) - if tlv_data.is_some_and(|v| !v.is_empty() && version != 3) { + if tlv_data.is_some_and(|v| !v.is_empty() && version != TokenDataVersion::ShaFlat as u8) { msg!("TLV extensions only supported with version 3 (ShaFlat)"); return Err(ErrorCode::TlvRequiresVersion3.into()); } @@ -79,14 +82,15 @@ pub fn build_mint_extension_cache<'a>( packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, ) -> Result { let mut cache: MintExtensionCache = ArrayMap::new(); - let deny_restricted_extensions = !inputs.out_token_data.is_empty(); + let no_compressed_outputs = inputs.out_token_data.is_empty(); + let deny_restricted_extensions = !no_compressed_outputs; // Collect mints from input token data for input in inputs.in_token_data.iter() { let mint_index = input.mint; if cache.get_by_key(&mint_index).is_none() { let mint_account = packed_accounts.get_u8(mint_index, "mint cache: input")?; - let checks = if inputs.out_token_data.is_empty() { + let checks = if no_compressed_outputs { // No outputs - bypass state checks (full decompress or transfer-only) parse_mint_extensions(mint_account)? } else { @@ -100,45 +104,51 @@ pub fn build_mint_extension_cache<'a>( if let Some(compressions) = inputs.compressions.as_ref() { for compression in compressions.iter() { let mint_index = compression.mint; - if cache.get_by_key(&mint_index).is_none() { let mint_account = packed_accounts.get_u8(mint_index, "mint cache: compression")?; - let no_compressed_outputs = inputs.out_token_data.is_empty(); - let is_full_decompress = compression.mode.is_decompress() && no_compressed_outputs; - let checks = if compression.mode.is_compress_and_close() - || is_full_decompress - || no_compressed_outputs - { + let checks = if compression.mode.is_compress_and_close() || no_compressed_outputs { // Bypass extension state checks (paused, non-zero fees, non-nil transfer hook) - // when exiting compressed state: CompressAndClose, Decompress, or CToken→SPL + // when CompressAndClose, full Decompress, or CToken→SPL (compress and full decompress) parse_mint_extensions(mint_account)? } else { check_mint_extensions(mint_account, deny_restricted_extensions)? }; + cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; + } - // CompressAndClose with restricted extensions requires CompressedOnly output. - // Compress/Decompress don't need additional validation here: - // - Compress: blocked by check_mint_extensions when outputs exist - // - Decompress: bypassed (restoring existing state) - if checks.has_restricted_extensions && compression.mode.is_compress_and_close() { - let output_idx = compression.get_compressed_token_account_index()?; - let has_compressed_only = inputs - .out_tlv - .as_ref() - .and_then(|tlvs| tlvs.get(output_idx as usize)) - .map(|tlv| { - tlv.iter() - .any(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) - }) - .unwrap_or(false); - if !has_compressed_only { - msg!("Mint has restricted extensions - CompressedOnly output required"); - return Err( - ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into() - ); - } + // SAFETY: mint_index was just inserted above if not already present + let checks = cache.get_by_key(&mint_index).unwrap(); + // CompressAndClose with restricted extensions requires CompressedOnly output. + // Compress/Decompress don't need additional validation here: + // - Compress: blocked by check_mint_extensions when outputs exist + // - Decompress: no check it restores existing state + if checks.has_restricted_extensions && compression.mode.is_compress_and_close() { + let output_idx = compression.get_compressed_token_account_index()?; + let has_compressed_only = inputs + .out_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(output_idx as usize)) + .map(|tlv| { + tlv.iter() + .any(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) + }) + .unwrap_or(false); + if !has_compressed_only { + msg!("Mint has restricted extensions - CompressedOnly output required"); + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); } + } + } + } + for output in inputs.out_token_data.iter() { + // All mints of outputs that have non zero amount must have an input or compression. + // Thus we only check outputs with zero amounts here. + if output.amount.get() == 0 { + let mint_index = output.mint; + if cache.get_by_key(&mint_index).is_none() { + let mint_account = packed_accounts.get_u8(mint_index, "mint cache: output")?; + let checks = check_mint_extensions(mint_account, true)?; cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs new file mode 100644 index 0000000000..83449e92e4 --- /dev/null +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -0,0 +1,333 @@ +use anchor_compressed_token::ErrorCode; +use anchor_lang::prelude::ProgramError; +use bitvec::prelude::*; +use light_account_checks::{checks::check_signer, packed_accounts::ProgramPackedAccounts}; +use light_ctoken_interface::{ + instructions::{ + extensions::ZExtensionInstructionData, + transfer2::{ZCompression, ZCompressionMode, ZMultiTokenTransferOutputData}, + }, + state::{TokenDataVersion, ZCTokenMut, ZExtensionStructMut}, + CTokenError, +}; +use light_program_profiler::profile; +use pinocchio::{ + account_info::AccountInfo, + pubkey::{pubkey_eq, Pubkey}, + sysvars::Sysvar, +}; +use spl_pod::solana_msg::msg; + +use super::inputs::CompressAndCloseInputs; +#[cfg(target_os = "solana")] +use crate::ctoken::close::accounts::CloseTokenAccountAccounts; +use crate::{ + compressed_token::transfer2::accounts::Transfer2Accounts, shared::convert_program_error, +}; + +/// Process compress and close operation for a ctoken account. +#[profile] +pub fn process_compress_and_close( + authority: Option<&AccountInfo>, + compress_and_close_inputs: Option, + amount: u64, + token_account_info: &AccountInfo, + ctoken: &mut ZCTokenMut, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, +) -> Result<(), ProgramError> { + let authority = authority.ok_or(ErrorCode::CompressAndCloseAuthorityMissing)?; + check_signer(authority).map_err(|e| { + anchor_lang::solana_program::msg!("Authority signer check failed: {:?}", e); + ProgramError::from(e) + })?; + + let close_inputs = + compress_and_close_inputs.ok_or(ErrorCode::CompressAndCloseDestinationMissing)?; + + // Validate token account - only compressible accounts with compression_authority are allowed + validate_ctoken_account( + token_account_info, + authority, + close_inputs.rent_sponsor, + ctoken, + )?; + + // Validate compressed output matches the account being closed + let compressed_account = close_inputs + .compressed_token_account + .ok_or(ErrorCode::CompressAndCloseOutputMissing)?; + validate_compressed_token_account( + packed_accounts, + amount, + compressed_account, + ctoken, + token_account_info.key(), + close_inputs.tlv, + )?; + + ctoken.base.amount.set(0); + // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) + // This allows the close_token_account validation to pass for frozen accounts + ctoken.base.set_initialized(); + + Ok(()) +} + +/// Validate compressed token account for compress and close operation. +/// +/// Validations: +/// 1. Owner - output owner matches ctoken owner (or token account pubkey for ATA/compress_to_pubkey) +/// 2. Amount - compression_amount == output_amount == ctoken.amount +/// 3. Mint - output mint matches ctoken mint +/// 4. Version - must be ShaFlat +/// 5. Extension required - CompressedOnly extension required for compression_only or ATA accounts +/// 6. Without extension: account must not be frozen, must not have delegate +/// 7. With extension (via `validate_compressed_only_ext`): +/// 7a. Delegated amount must match +/// 7b. Delegate pubkey must match (if present) +/// 7c. Withheld fee must match +/// 7d. Frozen state must match +/// 7e. is_ata must match +fn validate_compressed_token_account( + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + compression_amount: u64, + compressed_token_account: &ZMultiTokenTransferOutputData<'_>, + ctoken: &ZCTokenMut, + token_account_pubkey: &Pubkey, + out_tlv: Option<&[ZExtensionInstructionData<'_>]>, +) -> Result<(), ProgramError> { + let compression = ctoken + .get_compressible_extension() + .ok_or::(CTokenError::MissingCompressibleExtension.into())?; + + // 1. Owner validation + // compress_to_pubkey is derived from the extension (already fetched above) + let output_owner = packed_accounts + .get_u8(compressed_token_account.owner, "owner")? + .key(); + let expected_owner = if compression.info.compress_to_pubkey() || compression.is_ata() { + token_account_pubkey + } else { + &ctoken.owner.to_bytes() + }; + if output_owner != expected_owner { + return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); + } + + // 2. Amount validation + let output_amount = compressed_token_account.amount.get(); + if compression_amount != output_amount || ctoken.amount.get() != output_amount { + return Err(ErrorCode::CompressAndCloseAmountMismatch.into()); + } + + // 3. Mint validation + let output_mint = packed_accounts + .get_u8(compressed_token_account.mint, "mint")? + .key(); + if *output_mint != ctoken.mint.to_bytes() { + return Err(ErrorCode::CompressAndCloseInvalidMint.into()); + } + + // 4. Version validation + if compressed_token_account.version != TokenDataVersion::ShaFlat as u8 { + return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); + } + + // 5. Extension required for compression_only or ATA accounts + let compression_only_ext = out_tlv.and_then(|tlv| { + tlv.iter().find_map(|e| match e { + ZExtensionInstructionData::CompressedOnly(ext) => Some(ext), + _ => None, + }) + }); + if (compression.compression_only() || compression.is_ata()) && compression_only_ext.is_none() { + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); + } + + // 6. Without extension: must not be frozen, must not have delegate + let Some(ext) = compression_only_ext else { + if ctoken.is_frozen() { + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); + } + if ctoken.delegate().is_some() || compressed_token_account.has_delegate() { + return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); + } + return Ok(()); + }; + + // 7. With extension: validate delegate, withheld_fee, frozen, is_ata + validate_compressed_only_ext( + packed_accounts, + compressed_token_account, + ctoken, + ext, + compression, + ) +} + +/// Validate CompressedOnly extension fields match ctoken state. +/// Called from validation 7 in `validate_compressed_token_account`. +fn validate_compressed_only_ext( + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + compressed_token_account: &ZMultiTokenTransferOutputData<'_>, + ctoken: &ZCTokenMut, + ext: &light_ctoken_interface::instructions::extensions::compressed_only::ZCompressedOnlyExtensionInstructionData, + compression: &light_ctoken_interface::state::ZCompressibleExtensionMut<'_>, +) -> Result<(), ProgramError> { + // 7a. Delegated amount must match + let ext_delegated: u64 = ext.delegated_amount.into(); + if ext_delegated != ctoken.delegated_amount.get() { + return Err(ErrorCode::CompressAndCloseDelegatedAmountMismatch.into()); + } + + // 7b. Delegate pubkey must match (bidirectional check) + if let Some(delegate) = ctoken.delegate() { + // CToken has delegate - output must have matching delegate + if !compressed_token_account.has_delegate() { + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); + } + let output_delegate = packed_accounts + .get_u8(compressed_token_account.delegate, "delegate")? + .key(); + if !pubkey_eq(output_delegate, &delegate.to_bytes()) { + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); + } + } else if compressed_token_account.has_delegate() { + // CToken has no delegate - output must not have delegate + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); + } + + // 7c. Withheld fee must match + let ctoken_fee = ctoken + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ZExtensionStructMut::TransferFeeAccount(f) => Some(f.withheld_amount.get()), + _ => None, + }) + }) + .unwrap_or(0); + if u64::from(ext.withheld_transfer_fee) != ctoken_fee { + return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); + } + + // 7d. Frozen state must match + if ctoken.is_frozen() != ext.is_frozen() { + return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); + } + + // 7e. is_ata must match + if compression.is_ata() != ext.is_ata() { + return Err(ErrorCode::CompressAndCloseIsAtaMismatch.into()); + } + + Ok(()) +} + +/// Close ctoken accounts after compress and close operations +#[allow(unused_variables)] +pub fn close_for_compress_and_close( + compressions: &[ZCompression<'_>], + validated_accounts: &Transfer2Accounts, +) -> Result<(), ProgramError> { + // Track used compressed account indices for CompressAndClose to prevent duplicate outputs + let mut used_compressed_account_indices = [0u8; 32]; // 256 bits + let used_bits = used_compressed_account_indices.view_bits_mut::(); + + for compression in compressions + .iter() + .filter(|c| c.mode == ZCompressionMode::CompressAndClose) + { + // Check for duplicate compressed account indices in CompressAndClose operations + let compressed_idx = compression.get_compressed_token_account_index()?; + if let Some(mut bit) = used_bits.get_mut(compressed_idx as usize) { + if *bit { + msg!( + "Duplicate compressed account index {} in CompressAndClose operations", + compressed_idx + ); + return Err(ErrorCode::CompressAndCloseDuplicateOutput.into()); + } + *bit = true; + } else { + msg!("Compressed account index {} out of bounds", compressed_idx); + return Err(ProgramError::InvalidInstructionData); + } + + #[cfg(target_os = "solana")] + { + let token_account_info = validated_accounts.packed_accounts.get_u8( + compression.source_or_recipient, + "CompressAndClose: source_or_recipient", + )?; + let destination = validated_accounts.packed_accounts.get_u8( + compression.get_destination_index()?, + "CompressAndClose: destination", + )?; + let rent_sponsor = validated_accounts.packed_accounts.get_u8( + compression.get_rent_sponsor_index()?, + "CompressAndClose: rent_sponsor", + )?; + let authority = validated_accounts + .packed_accounts + .get_u8(compression.authority, "CompressAndClose: authority")?; + use crate::ctoken::close::processor::close_token_account; + close_token_account(&CloseTokenAccountAccounts { + token_account: token_account_info, + destination, + authority, + rent_sponsor: Some(rent_sponsor), + })?; + } + } + Ok(()) +} + +/// Validates that a ctoken solana account is ready to be compressed and closed. +/// Only the compression_authority can compress the account. +#[profile] +fn validate_ctoken_account( + token_account: &AccountInfo, + authority: &AccountInfo, + rent_sponsor: &AccountInfo, + ctoken: &ZCTokenMut<'_>, +) -> Result<(), ProgramError> { + // Check for Compressible extension + let compressible = ctoken.get_compressible_extension(); + + // CompressAndClose requires Compressible extension + let compression = compressible.ok_or_else(|| { + msg!("compress and close requires compressible extension"); + ProgramError::InvalidAccountData + })?; + + // Validate rent_sponsor matches + if compression.info.rent_sponsor != *rent_sponsor.key() { + msg!("rent recipient mismatch"); + return Err(ProgramError::InvalidAccountData); + } + + if compression.info.compression_authority != *authority.key() { + msg!("compress and close requires compression authority"); + return Err(ProgramError::InvalidAccountData); + } + + let current_slot = pinocchio::sysvars::clock::Clock::get() + .map_err(convert_program_error)? + .slot; + compression + .info + .is_compressible( + token_account.data_len() as u64, + current_slot, + token_account.lamports(), + ) + .map_err(|_| ProgramError::InvalidAccountData)? + .ok_or_else(|| { + msg!("account not compressible"); + ProgramError::InvalidAccountData + })?; + + Ok(()) +} diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs similarity index 72% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 51a6b44029..e1179e41fe 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -8,18 +8,16 @@ use light_ctoken_interface::{ }; use light_program_profiler::profile; use light_zero_copy::traits::ZeroCopyAtMut; -use pinocchio::{ - account_info::AccountInfo, - pubkey::pubkey_eq, - sysvars::{clock::Clock, rent::Rent, Sysvar}, -}; +use pinocchio::pubkey::pubkey_eq; use spl_pod::solana_msg::msg; use super::{ - compress_and_close::process_compress_and_close, decompress::apply_decompress_extension_state, + compress_and_close::process_compress_and_close, decompress::validate_and_apply_compressed_only, inputs::CTokenCompressionInputs, }; -use crate::shared::owner_validation::check_ctoken_owner; +use crate::shared::{ + compressible_top_up::process_compression_top_up, owner_validation::check_ctoken_owner, +}; /// Perform compression/decompression on a ctoken account. /// @@ -83,10 +81,16 @@ pub fn compress_or_decompress_ctokens( Ok(()) } ZCompressionMode::Decompress => { - apply_decompress_extension_state(&mut ctoken, token_account_info, decompress_inputs)?; - - // Decompress: add to solana account - // Update the balance in the compressed token account + validate_and_apply_compressed_only( + token_account_info, + &mut ctoken, + decompress_inputs, + packed_accounts, + amount, + )?; + + // Decompress: add to CToken account + // Update the balance in the CToken solana account ctoken.base.amount.set( current_balance .checked_add(amount) @@ -115,43 +119,6 @@ pub fn compress_or_decompress_ctokens( } } -/// Process compression top-up using embedded compression info. -/// All ctoken accounts now have compression info embedded directly in meta. -#[inline(always)] -pub fn process_compression_top_up( - compression: &light_compressible::compression_info::ZCompressionInfoMut<'_>, - token_account_info: &AccountInfo, - current_slot: &mut u64, - transfer_amount: &mut u64, - lamports_budget: &mut u64, -) -> Result<(), ProgramError> { - if *transfer_amount != 0 { - return Ok(()); - } - - if *current_slot == 0 { - *current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - } - let rent_exemption = Rent::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .minimum_balance(token_account_info.data_len()); - - *transfer_amount = compression - .calculate_top_up_lamports( - token_account_info.data_len() as u64, - *current_slot, - token_account_info.lamports(), - rent_exemption, - ) - .map_err(|_| CTokenError::InvalidAccountData)?; - - *lamports_budget = lamports_budget.saturating_sub(*transfer_amount); - - Ok(()) -} - /// Validate a CToken account for compression/decompression operations. /// /// Checks: @@ -172,13 +139,13 @@ fn validate_ctoken( } // Reject uninitialized accounts (state == 0) - if ctoken.base.state == 0 { + if ctoken.base.is_uninitialized() { msg!("Account is uninitialized"); return Err(CTokenError::InvalidAccountState.into()); } - // Check if account is frozen (SPL Token-2022 compatibility) - // Frozen accounts cannot have their balance modified except for CompressAndClose - else if ctoken.base.state == 2 && !mode.is_compress_and_close() { + + // Frozen accounts can only be modified via CompressAndClose + if ctoken.is_frozen() && !mode.is_compress_and_close() { msg!("Cannot modify frozen account"); return Err(ErrorCode::AccountFrozen.into()); } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs new file mode 100644 index 0000000000..5cd6adbb28 --- /dev/null +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -0,0 +1,175 @@ +use anchor_lang::prelude::ProgramError; +use light_account_checks::packed_accounts::ProgramPackedAccounts; +use light_compressed_account::Pubkey; +use light_ctoken_interface::{ + instructions::extensions::{find_compressed_only, ZCompressedOnlyExtensionInstructionData}, + state::{ZCTokenMut, ZExtensionStructMut}, + CTokenError, +}; +use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; +use spl_pod::solana_msg::msg; + +use super::inputs::DecompressCompressOnlyInputs; + +/// Validate and apply CompressedOnly extension state from compressed account to CToken during decompress. +#[inline(always)] +pub fn validate_and_apply_compressed_only( + destination_account: &AccountInfo, + ctoken: &mut ZCTokenMut, + decompress_inputs: Option, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + compression_amount: u64, +) -> Result<(), ProgramError> { + let Some(inputs) = decompress_inputs else { + return Ok(()); + }; + + let Some(ext_data) = find_compressed_only(inputs.tlv) else { + return Ok(()); + }; + + // === VALIDATE amount matches for ATA or compress_to_pubkey decompress === + let compress_to_pubkey = ctoken + .get_compressible_extension() + .map(|ext| ext.info.compress_to_pubkey()) + .unwrap_or(false); + if ext_data.is_ata() || compress_to_pubkey { + let input_amount: u64 = inputs.input_token_data.amount.into(); + if compression_amount != input_amount { + msg!( + "Decompress: amount mismatch (compression: {}, input: {})", + compression_amount, + input_amount + ); + return Err(CTokenError::DecompressAmountMismatch.into()); + } + } + + // === VALIDATE destination ownership === + let input_owner = packed_accounts.get_u8(inputs.input_token_data.owner, "input owner")?; + validate_destination( + ctoken, + destination_account, + input_owner.key(), + ext_data, + packed_accounts, + )?; + + // === APPLY delegate state === + apply_delegate(ctoken, ext_data, &inputs, packed_accounts)?; + + // === APPLY withheld fee === + apply_withheld_fee(ctoken, ext_data)?; + + // === APPLY frozen state === + if ext_data.is_frozen() { + ctoken.base.set_frozen(); + } + + Ok(()) +} + +/// Validate destination matches the source account for decompress. +/// +/// For non-ATA: CToken owner == input_owner (wallet pubkey) +/// For ATA: destination address == input_owner (ATA pubkey), and CToken owner == wallet_owner +#[inline(always)] +fn validate_destination( + ctoken: &ZCTokenMut, + destination: &AccountInfo, + input_owner_key: &[u8; 32], + ext_data: &ZCompressedOnlyExtensionInstructionData, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, +) -> Result<(), ProgramError> { + // Non-ATA: simple owner match (handle simpler case first) + if !ext_data.is_ata() { + if !pubkey_eq(ctoken.base.owner.array_ref(), input_owner_key) { + msg!("Decompress destination owner mismatch"); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + return Ok(()); + } + + // ATA: destination address == input_owner (ATA pubkey) + if !pubkey_eq(destination.key(), input_owner_key) { + msg!("Decompress ATA: destination address mismatch"); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + + // ATA: wallet owner == CToken owner field + let wallet = packed_accounts.get_u8(ext_data.owner_index, "wallet owner")?; + if !pubkey_eq(wallet.key(), ctoken.base.owner.array_ref()) { + msg!("Decompress ATA: wallet owner mismatch"); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + Ok(()) +} + +/// Apply delegate state. Resolves delegate only when needed (inside the check). +#[inline(always)] +fn apply_delegate( + ctoken: &mut ZCTokenMut, + ext_data: &ZCompressedOnlyExtensionInstructionData, + inputs: &DecompressCompressOnlyInputs, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, +) -> Result<(), ProgramError> { + // Skip if destination already has delegate + if ctoken.delegate().is_some() { + return Ok(()); + } + + let delegated_amount: u64 = ext_data.delegated_amount.into(); + + // Resolve delegate only when needed + let input_delegate = if inputs.input_token_data.has_delegate() { + Some(packed_accounts.get_u8(inputs.input_token_data.delegate, "delegate")?) + } else { + None + }; + + if let Some(delegate_acc) = input_delegate { + ctoken + .base + .set_delegate(Some(Pubkey::from(*delegate_acc.key())))?; + if delegated_amount > 0 { + let current = ctoken.base.delegated_amount.get(); + ctoken.base.delegated_amount.set( + current + .checked_add(delegated_amount) + .ok_or(ProgramError::ArithmeticOverflow)?, + ); + } + } else if delegated_amount > 0 { + msg!("Decompress: delegated_amount > 0 but no delegate"); + return Err(CTokenError::DecompressDelegatedAmountWithoutDelegate.into()); + } + + Ok(()) +} + +/// Apply withheld transfer fee to TransferFeeAccount extension. +#[inline(always)] +fn apply_withheld_fee( + ctoken: &mut ZCTokenMut, + ext_data: &ZCompressedOnlyExtensionInstructionData, +) -> Result<(), ProgramError> { + let fee: u64 = ext_data.withheld_transfer_fee.into(); + if fee == 0 { + return Ok(()); + } + + let fee_ext = ctoken.extensions.as_deref_mut().and_then(|exts| { + exts.iter_mut().find_map(|ext| match ext { + ZExtensionStructMut::TransferFeeAccount(f) => Some(f), + _ => None, + }) + }); + + match fee_ext { + Some(f) => Ok(f.add_withheld_amount(fee)?), + None => { + msg!("Decompress: withheld fee but no TransferFeeAccount extension"); + Err(CTokenError::DecompressWithheldFeeWithoutExtension.into()) + } + } +} diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs similarity index 76% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs index 5e44b3685f..db32dee1d0 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs @@ -4,41 +4,34 @@ use light_ctoken_interface::instructions::{ extensions::ZExtensionInstructionData, transfer2::{ ZCompressedTokenInstructionDataTransfer2, ZCompression, ZCompressionMode, - ZMultiTokenTransferOutputData, + ZMultiInputTokenDataWithContext, ZMultiTokenTransferOutputData, }, }; use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; use spl_pod::solana_msg::msg; -use crate::extensions::MintExtensionChecks; +use crate::{extensions::MintExtensionChecks, MAX_COMPRESSIONS}; /// Decompress-specific inputs from the input compressed account. /// Only required for decompression with CompressedOnly extension. pub struct DecompressCompressOnlyInputs<'a> { /// Input TLV for decompress operations (from the input compressed account being consumed). pub tlv: &'a [ZExtensionInstructionData<'a>], - /// Delegate pubkey from input compressed account (for decompress extension state transfer). - pub delegate: Option<&'a AccountInfo>, - /// Owner pubkey from input compressed account (for decompress destination validation). - /// For is_ata=true, this is the ATA pubkey (not the wallet owner). - pub owner: &'a AccountInfo, - /// Wallet owner for ATA decompress (from owner_index in CompressedOnly extension). - /// Only set when is_ata=true. Used for ATA derivation validation. - pub wallet_owner: Option<&'a AccountInfo>, + /// The input compressed token data being consumed. + pub input_token_data: &'a ZMultiInputTokenDataWithContext<'a>, } impl<'a> DecompressCompressOnlyInputs<'a> { /// Extract decompress inputs for CompressedOnly extension state transfer. /// - /// Extracts TLV, delegate, and owner from the input compressed account for decompress + /// Extracts TLV and input_token_data from the input compressed account for decompress /// operations. Also validates compression-input consistency (mode and mint match). #[inline(always)] pub fn try_extract( compression: &ZCompression, compression_index: usize, - compression_to_input: &[Option; 32], + compression_to_input: &[Option; MAX_COMPRESSIONS], inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, - packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, ) -> Result, ProgramError> { let Some(input_idx) = compression_to_input[compression_index] else { return Ok(None); @@ -55,11 +48,11 @@ impl<'a> DecompressCompressOnlyInputs<'a> { } // Validate mint matches between compression and input - let input_data = inputs + let input_token_data = inputs .in_token_data .get(idx) .ok_or(ProgramError::InvalidInstructionData)?; - if compression.mint != input_data.mint { + if compression.mint != input_token_data.mint { msg!( "Mint mismatch between compression and input at index {}", compression_index @@ -75,37 +68,9 @@ impl<'a> DecompressCompressOnlyInputs<'a> { .map(|v| v.as_slice()) .unwrap_or(&[]); - // Get delegate (optional, only if input has delegate) - let delegate = if input_data.has_delegate() { - Some(packed_accounts.get_u8(input_data.delegate, "input delegate")?) - } else { - None - }; - - // Get owner (required for DecompressCompressOnlyInputs) - let owner = packed_accounts.get_u8(input_data.owner, "input owner")?; - - // For is_ata decompress, extract wallet_owner from owner_index in CompressedOnly extension - let wallet_owner = tlv.iter().find_map(|ext| { - if let ZExtensionInstructionData::CompressedOnly(data) = ext { - if data.is_ata != 0 { - // Get wallet owner from owner_index - packed_accounts - .get_u8(data.owner_index, "wallet owner") - .ok() - } else { - None - } - } else { - None - } - }); - Ok(Some(DecompressCompressOnlyInputs { tlv, - delegate, - owner, - wallet_owner, + input_token_data, })) } } diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs similarity index 93% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs index b52ba4a2d6..f268e7791e 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs @@ -14,9 +14,7 @@ mod decompress; mod inputs; pub use compress_and_close::close_for_compress_and_close; -pub use compress_or_decompress_ctokens::{ - compress_or_decompress_ctokens, process_compression_top_up, -}; +pub use compress_or_decompress_ctokens::compress_or_decompress_ctokens; pub use inputs::{CTokenCompressionInputs, CompressAndCloseInputs, DecompressCompressOnlyInputs}; /// Process compression/decompression for ctoken accounts. diff --git a/programs/compressed-token/program/src/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs similarity index 87% rename from programs/compressed-token/program/src/transfer2/compression/mod.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index e01db37136..28cb5a6e8f 100644 --- a/programs/compressed-token/program/src/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -19,7 +19,7 @@ use crate::{ convert_program_error, transfer_lamports::{multi_transfer_lamports, Transfer}, }, - LIGHT_CPI_SIGNER, MAX_PACKED_ACCOUNTS, + LIGHT_CPI_SIGNER, MAX_COMPRESSIONS, MAX_PACKED_ACCOUNTS, }; pub mod ctoken; @@ -47,13 +47,16 @@ pub fn process_token_compression<'a>( cpi_authority: &AccountInfo, max_top_up: u16, mint_cache: &'a MintExtensionCache, - compression_to_input: &[Option; 32], + compression_to_input: &[Option; MAX_COMPRESSIONS], ) -> Result<(), ProgramError> { if let Some(compressions) = inputs.compressions.as_ref() { - if compressions.len() >= 32 { - // TODO: add meaningful error message - // TODO: use constant instead of 32. - return Err(ProgramError::InvalidInstructionData); + if compressions.len() > MAX_COMPRESSIONS { + msg!( + "Too many compressions: {} provided, maximum {} allowed", + compressions.len(), + MAX_COMPRESSIONS + ); + return Err(ErrorCode::TooManyCompressionTransfers.into()); } let mut transfer_map = [0u64; MAX_PACKED_ACCOUNTS]; // Initialize budget: +1 allows exact match (total == max_top_up) @@ -86,7 +89,6 @@ pub fn process_token_compression<'a>( compression_index, compression_to_input, inputs, - packed_accounts, )?; ctoken::process_ctoken_compressions( @@ -101,8 +103,13 @@ pub fn process_token_compression<'a>( )?; } SPL_TOKEN_ID => { - // SPL Token (not Token-2022) never has restricted extensions. - // Delegation is disregarded for decompression to SPL token accounts. + // CompressedOnly inputs must decompress to CToken accounts to preserve + // extension state (frozen, delegated, withheld fees). + if compression_to_input[compression_index].is_some() { + msg!("CompressedOnly inputs must decompress to CToken account"); + return Err(ErrorCode::CompressedOnlyRequiresCTokenDecompress.into()); + } + spl::process_spl_compressions( compression, &SPL_TOKEN_ID.to_pubkey_bytes(), @@ -115,15 +122,12 @@ pub fn process_token_compression<'a>( SPL_TOKEN_2022_ID => { // CompressedOnly inputs must decompress to CToken accounts to preserve // extension state (frozen, delegated, withheld fees). - if compression.mode.is_decompress() - && compression_to_input[compression_index].is_some() - { + if compression_to_input[compression_index].is_some() { msg!("CompressedOnly inputs must decompress to CToken account"); return Err(ErrorCode::CompressedOnlyRequiresCTokenDecompress.into()); } - // Check if mint has restricted extensions from the cache. - // Delegation is disregarded for decompression to SPL token accounts. + // Propagate whether mint is restricted to enable correct derivation of the spl interface pda. let is_restricted = mint_checks .map(|checks| checks.has_restricted_extensions) .unwrap_or(false); diff --git a/programs/compressed-token/program/src/transfer2/compression/spl.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs similarity index 96% rename from programs/compressed-token/program/src/transfer2/compression/spl.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs index 3d17754694..8e6b5c3d89 100644 --- a/programs/compressed-token/program/src/transfer2/compression/spl.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs @@ -14,7 +14,7 @@ use pinocchio::{ }; use super::validate_compression_mode_fields; -use crate::constants::BUMP_CPI_AUTHORITY; +use crate::{constants::BUMP_CPI_AUTHORITY, shared::convert_pinocchio_token_error}; /// Process compression/decompression for SPL token accounts #[profile] @@ -168,11 +168,11 @@ fn spl_token_transfer_checked_common( match signers { Some(signers) => { pinocchio::cpi::slice_invoke_signed(&instruction, account_infos, signers) - .map_err(|_| ProgramError::InvalidArgument)?; + .map_err(convert_pinocchio_token_error)?; } None => { pinocchio::cpi::slice_invoke(&instruction, account_infos) - .map_err(|_| ProgramError::InvalidArgument)?; + .map_err(convert_pinocchio_token_error)?; } } diff --git a/programs/compressed-token/program/src/transfer2/config.rs b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs similarity index 84% rename from programs/compressed-token/program/src/transfer2/config.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/config.rs index 9a8ad16266..8dc242b060 100644 --- a/programs/compressed-token/program/src/transfer2/config.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs @@ -20,19 +20,15 @@ pub struct Transfer2Config { pub total_output_lamports: u64, /// No compressed accounts (neither input nor output) - determines system CPI path pub no_compressed_accounts: bool, - /// No output compressed accounts - determines mint extension hotpath - pub no_output_compressed_accounts: bool, // TODO: remove dead code } impl Transfer2Config { /// Create configuration from instruction data - /// Centralizes the boolean logic that was previously scattered in processor pub fn from_instruction_data( inputs: &ZCompressedTokenInstructionDataTransfer2, ) -> Result { let no_compressed_accounts = inputs.in_token_data.is_empty() && inputs.out_token_data.is_empty(); - let no_output_compressed_accounts = inputs.out_token_data.is_empty(); Ok(Self { sol_pool_required: false, sol_decompression_required: false, @@ -45,7 +41,6 @@ impl Transfer2Config { total_input_lamports: 0, total_output_lamports: 0, no_compressed_accounts, - no_output_compressed_accounts, }) } } diff --git a/programs/compressed-token/program/src/transfer2/cpi.rs b/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs similarity index 98% rename from programs/compressed-token/program/src/transfer2/cpi.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs index aa06a1671d..98498171f0 100644 --- a/programs/compressed-token/program/src/transfer2/cpi.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs @@ -67,10 +67,9 @@ pub fn allocate_cpi_bytes( output_accounts.push((false, data_len)); // Token accounts don't have addresses } - // Add extra output account for change account if needed (no delegate, no token data) + // Add extra output account for change account if needed (no delegate) if inputs.with_lamports_change_account_merkle_tree_index != 0 { output_accounts.push((false, compressed_token_data_len(false))); - // No delegate } let mut input_accounts = ArrayVec::new(); diff --git a/programs/compressed-token/program/src/transfer2/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/mod.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/mod.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/mod.rs diff --git a/programs/compressed-token/program/src/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs similarity index 92% rename from programs/compressed-token/program/src/transfer2/processor.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 18f49e9fe7..d33ee2dcdf 100644 --- a/programs/compressed-token/program/src/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -20,8 +20,7 @@ use spl_pod::solana_msg::msg; use super::check_extensions::{build_mint_extension_cache, MintExtensionCache}; use crate::{ - shared::{convert_program_error, cpi::execute_cpi_invoke}, - transfer2::{ + compressed_token::transfer2::{ accounts::Transfer2Accounts, compression::{close_for_compress_and_close, process_token_compression}, config::Transfer2Config, @@ -30,6 +29,7 @@ use crate::{ token_inputs::set_input_compressed_accounts, token_outputs::set_output_compressed_accounts, }, + shared::{convert_program_error, cpi::execute_cpi_invoke}, }; /// Process a token transfer instruction @@ -37,12 +37,11 @@ use crate::{ /// 1. Unpack compressed input accounts and input token data, this uses /// standardized signer / delegate and will fail in proof verification in /// case either is invalid. -/// 2. Check that compressed accounts are of same mint. -/// 3. Check that sum of input compressed accounts is equal to sum of output -/// compressed accounts -/// 4. create_output_compressed_accounts -/// 5. Serialize and add token_data data to in compressed_accounts. -/// 6. Invoke light_system_program::execute_compressed_transaction. +/// 2. Check that sum of input compressed accounts equals sum of output +/// compressed accounts (supports multi-mint) +/// 3. create_output_compressed_accounts +/// 4. Serialize and add token_data data to in compressed_accounts. +/// 5. Invoke light_system_program::execute_compressed_transaction. #[profile] pub fn process_transfer2( accounts: &[AccountInfo], @@ -62,7 +61,7 @@ pub fn process_transfer2( if transfer_config.no_compressed_accounts { // No compressed accounts are invalidated or created in this transaction - // -> no need to invoke the light system program. + // so no need to invoke the light system program. process_no_system_program_cpi(&inputs, &validated_accounts, &mint_cache) } else { process_with_system_program_cpi( @@ -141,9 +140,18 @@ pub fn validate_instruction_data( if !allowed { return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); } + // All out_token_data must be version 3 (sha flat) if tlv is present. + let allowed = inputs.out_token_data.iter().all(|c| c.version == 3); + if !allowed { + return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); + } // Output count must match compressions count (no extra outputs) - let compressions_len = inputs.compressions.as_ref().map(|c| c.len()).unwrap_or(0); + let compressions_len = inputs + .compressions + .as_ref() + .map(|c| c.len()) + .ok_or(CTokenError::OutTlvOutputCountMismatch)?; if inputs.out_token_data.len() != compressions_len { msg!("out_tlv requires out_token_data.len() == compressions.len()"); return Err(CTokenError::OutTlvOutputCountMismatch); @@ -186,11 +194,11 @@ fn process_no_system_program_cpi<'a>( let mint_map: ArrayMap = sum_check_multi_mint(&[], &[], Some(compressions.as_slice())) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; // Validate mint uniqueness validate_mint_uniqueness(&mint_map, &validated_accounts.packed_accounts) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; // This is the compression-only hot path (no compressed inputs/outputs). // Extension checks are skipped because balance must be restored immediately @@ -263,11 +271,11 @@ fn process_with_system_program_cpi<'a>( &inputs.out_token_data, inputs.compressions.as_deref(), ) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; // Validate mint uniqueness validate_mint_uniqueness(&mint_map, &validated_accounts.packed_accounts) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; if let Some(system_accounts) = validated_accounts.system.as_ref() { // Process token compressions/decompressions/close_and_compress diff --git a/programs/compressed-token/program/src/transfer2/sum_check.rs b/programs/compressed-token/program/src/compressed_token/transfer2/sum_check.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/sum_check.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/sum_check.rs diff --git a/programs/compressed-token/program/src/transfer2/token_inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs similarity index 83% rename from programs/compressed-token/program/src/transfer2/token_inputs.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs index fa85da81e2..26df5b35d7 100644 --- a/programs/compressed-token/program/src/transfer2/token_inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs @@ -12,10 +12,10 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use super::check_extensions::{validate_tlv_and_get_frozen, MintExtensionCache}; -use crate::shared::token_input::set_input_compressed_account; +use crate::{shared::token_input::set_input_compressed_account, MAX_COMPRESSIONS}; /// Process input compressed accounts and return compression-to-input lookup. -/// Returns `[Option; 32]` where `compression_to_input[compression_idx] = Some(input_idx)`. +/// Returns `[Option; MAX_COMPRESSIONS]` where `compression_to_input[compression_idx] = Some(input_idx)`. #[profile] #[inline(always)] pub fn set_input_compressed_accounts<'a>( @@ -25,9 +25,9 @@ pub fn set_input_compressed_accounts<'a>( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, all_accounts: &[AccountInfo], mint_cache: &'a MintExtensionCache, -) -> Result<[Option; 32], ProgramError> { +) -> Result<[Option; MAX_COMPRESSIONS], ProgramError> { // compression_to_input[compression_index] = Some(input_index), None means unset - let mut compression_to_input: [Option; 32] = [None; 32]; + let mut compression_to_input: [Option; MAX_COMPRESSIONS] = [None; MAX_COMPRESSIONS]; for (i, input_data) in inputs.in_token_data.iter().enumerate() { let input_lamports = if let Some(lamports) = inputs.in_lamports.as_ref() { @@ -53,6 +53,10 @@ pub fn set_input_compressed_accounts<'a>( for ext in tlv { if let ZExtensionInstructionData::CompressedOnly(co) = ext { let idx = co.compression_index as usize; + // Check bounds before array access + if idx >= MAX_COMPRESSIONS { + return Err(CTokenError::CompressionIndexOutOfBounds.into()); + } // Check uniqueness - error if compression_index already used if compression_to_input[idx].is_some() { return Err(CTokenError::DuplicateCompressionIndex.into()); diff --git a/programs/compressed-token/program/src/transfer2/token_outputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs similarity index 97% rename from programs/compressed-token/program/src/transfer2/token_outputs.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs index 17d65942bf..ab6b2ec6d0 100644 --- a/programs/compressed-token/program/src/transfer2/token_outputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs @@ -13,7 +13,7 @@ use pinocchio::account_info::AccountInfo; use super::check_extensions::validate_tlv_and_get_frozen; use crate::shared::token_output::set_output_compressed_account; -/// Process output compressed accounts and return total output lamports +/// Process output compressed accounts #[profile] #[inline(always)] pub fn set_output_compressed_accounts<'a>( diff --git a/programs/compressed-token/program/src/claim.rs b/programs/compressed-token/program/src/compressible/claim.rs similarity index 97% rename from programs/compressed-token/program/src/claim.rs rename to programs/compressed-token/program/src/compressible/claim.rs index 8b5eef1203..5bfed2337b 100644 --- a/programs/compressed-token/program/src/claim.rs +++ b/programs/compressed-token/program/src/compressible/claim.rs @@ -10,10 +10,7 @@ use light_program_profiler::profile; use pinocchio::{account_info::AccountInfo, sysvars::Sysvar}; use spl_pod::solana_msg::msg; -use crate::{ - create_token_account::parse_config_account, - shared::{convert_program_error, transfer_lamports}, -}; +use crate::shared::{convert_program_error, parse_config_account, transfer_lamports}; /// Accounts required for the claim instruction pub struct ClaimAccounts<'a> { @@ -42,7 +39,7 @@ impl<'a> ClaimAccounts<'a> { .map_err(ProgramError::from)?; if *config_account.compression_authority.as_array() != *compression_authority.key() { - msg!("invalid rent authority"); + msg!("invalid compression authority"); return Err(ErrorCode::InvalidCompressAuthority.into()); } if *config_account.rent_sponsor.as_array() != *rent_sponsor.key() { diff --git a/programs/compressed-token/program/src/compressible/mod.rs b/programs/compressed-token/program/src/compressible/mod.rs new file mode 100644 index 0000000000..d1aeb32ff6 --- /dev/null +++ b/programs/compressed-token/program/src/compressible/mod.rs @@ -0,0 +1,5 @@ +pub mod claim; +pub mod withdraw_funding_pool; + +pub use claim::process_claim; +pub use withdraw_funding_pool::process_withdraw_funding_pool; diff --git a/programs/compressed-token/program/src/withdraw_funding_pool.rs b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs similarity index 93% rename from programs/compressed-token/program/src/withdraw_funding_pool.rs rename to programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs index 7d1bd1ddd7..c4be07dd83 100644 --- a/programs/compressed-token/program/src/withdraw_funding_pool.rs +++ b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs @@ -8,7 +8,7 @@ use pinocchio::{ use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; -use crate::create_token_account::parse_config_account; +use crate::shared::{convert_program_error, parse_config_account}; /// Accounts required for the withdraw funding pool instruction pub struct WithdrawFundingPoolAccounts<'a> { @@ -71,7 +71,7 @@ pub fn process_withdraw_funding_pool( account_infos: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - // Parse instruction data: [bump: u8][amount: u64] + // Parse instruction data: [amount: u64] if instruction_data.len() < 8 { msg!("Invalid instruction data length"); return Err(ProgramError::InvalidInstructionData); @@ -98,7 +98,7 @@ pub fn process_withdraw_funding_pool( return Err(ProgramError::InsufficientFunds); } - // Prepare seeds for invoke_signed - the pool PDA is derived from [b"pool", compression_authority] + // Prepare seeds for invoke_signed - rent_sponsor PDA is derived from [b"rent_sponsor", version, bump] let bump_bytes = [rent_sponsor_bump]; let seed_array = [ Seed::from(b"rent_sponsor".as_slice()), @@ -115,5 +115,5 @@ pub fn process_withdraw_funding_pool( transfer .invoke_signed(&[signer]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000)) + .map_err(convert_program_error) } diff --git a/programs/compressed-token/program/src/create_token_account.rs b/programs/compressed-token/program/src/create_token_account.rs deleted file mode 100644 index 54a33df1d3..0000000000 --- a/programs/compressed-token/program/src/create_token_account.rs +++ /dev/null @@ -1,263 +0,0 @@ -use anchor_lang::{prelude::ProgramError, pubkey}; -use borsh::BorshDeserialize; -use light_account_checks::{ - checks::{check_discriminator, check_owner}, - AccountIterator, -}; -use light_compressible::config::CompressibleConfig; -use light_ctoken_interface::instructions::create_ctoken_account::CreateTokenAccountInstructionData; -use light_program_profiler::profile; -use pinocchio::{account_info::AccountInfo, instruction::Seed}; -use spl_pod::{bytemuck, solana_msg::msg}; - -use crate::{ - extensions::has_mint_extensions, - shared::{ - convert_program_error, create_pda_account, - initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, - transfer_lamports_via_cpi, - }, -}; - -/// Validated accounts for the create token account instruction -pub struct CreateCTokenAccounts<'info> { - /// The token account being created (signer, mutable) - pub token_account: &'info AccountInfo, - /// The mint for the token account (only used for pubkey not checked) - pub mint: &'info AccountInfo, - /// Optional compressible configuration accounts (None = non-compressible account) - pub compressible: Option>, -} - -/// Accounts required when creating a compressible token account -pub struct CompressibleAccounts<'info> { - /// Pays for the compression incentive rent when rent_payer is the rent recipient (signer, mutable) - pub payer: &'info AccountInfo, - /// Used for account creation CPI - pub system_program: &'info AccountInfo, - /// Either the rent recipient PDA or a custom fee payer - pub rent_payer: &'info AccountInfo, - /// Parsed configuration from the config account - pub parsed_config: &'info CompressibleConfig, -} - -impl<'info> CreateCTokenAccounts<'info> { - /// Parse and validate accounts from the provided account infos - #[profile] - #[inline(always)] - pub fn parse( - account_infos: &'info [AccountInfo], - is_compressible: bool, - ) -> Result { - let mut iter = AccountIterator::new(account_infos); - - // For compressible accounts: token_account must be signer (account created via CPI) - // For non-compressible accounts: token_account doesn't need to be signer (SPL compatibility) - let token_account = if is_compressible { - iter.next_signer_mut("token_account")? - } else { - iter.next_mut("token_account")? - }; - let mint = iter.next_non_mut("mint")?; - - // Parse optional compressible accounts - let compressible = if is_compressible { - Some(CompressibleAccounts { - payer: iter.next_signer_mut("payer")?, - parsed_config: next_config_account(&mut iter)?, - system_program: iter.next_non_mut("system program")?, - // Must be signer if custom rent payer. - // Rent sponsor is not signer. - rent_payer: iter.next_mut("rent payer")?, - }) - } else { - None - }; - - Ok(Self { - token_account, - mint, - compressible, - }) - } -} - -#[profile] -#[inline(always)] -pub fn parse_config_account( - config_account: &AccountInfo, -) -> Result<&CompressibleConfig, ProgramError> { - // Validate config account owner - check_owner( - &pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX").to_bytes(), - config_account, - )?; - // Parse config data - let data = unsafe { config_account.borrow_data_unchecked() }; - check_discriminator::(data)?; - let config = bytemuck::pod_from_bytes::(&data[8..]).map_err(|e| { - msg!("Failed to deserialize CompressibleConfig: {:?}", e); - ProgramError::InvalidAccountData - })?; - - Ok(config) -} - -#[profile] -#[inline(always)] -pub fn next_config_account<'info>( - iter: &mut AccountIterator<'info, AccountInfo>, -) -> Result<&'info CompressibleConfig, ProgramError> { - let config_account = iter.next_non_mut("compressible config")?; - let config = parse_config_account(config_account)?; - - // Validate config is active (only active allowed for account creation) - config.validate_active().map_err(ProgramError::from)?; - - Ok(config) -} - -/// Process the create token account instruction -#[profile] -pub fn process_create_token_account( - account_infos: &[AccountInfo], - mut instruction_data: &[u8], -) -> Result<(), ProgramError> { - use light_compressed_account::Pubkey; - - use crate::shared::initialize_ctoken_account::CompressibleInitData; - - // SPL compatibility: if instruction_data is exactly 32 bytes, treat as owner-only (no compressible config) - // This matches SPL Token's initialize_account3 which only sends the owner pubkey - let inputs = if instruction_data.len() == 32 { - let owner_bytes: [u8; 32] = instruction_data[..32] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?; - CreateTokenAccountInstructionData { - owner: Pubkey::from(owner_bytes), - compressible_config: None, - } - } else { - CreateTokenAccountInstructionData::deserialize(&mut instruction_data) - .map_err(ProgramError::from)? - }; - - let is_compressible = inputs.compressible_config.is_some(); - - // Parse and validate accounts - let accounts = CreateCTokenAccounts::parse(account_infos, is_compressible)?; - - // Check which extensions the mint has (single deserialization) - let mint_extensions = has_mint_extensions(accounts.mint)?; - - // Handle compressible vs non-compressible account creation - let compressible_init_data = if let Some(ref compressible_config) = inputs.compressible_config { - let compressible = accounts - .compressible - .as_ref() - .ok_or(ProgramError::InvalidAccountData)?; - - // Validate that rent_payment is not exactly 1 epoch (footgun prevention) - if compressible_config.rent_payment == 1 { - msg!("Prefunding for exactly 1 epoch is not allowed. If the account is created near an epoch boundary, it could become immediately compressible. Use 0 or 2+ epochs."); - return Err(anchor_compressed_token::ErrorCode::OneEpochPrefundingNotAllowed.into()); - } - - if let Some(compress_to_pubkey) = compressible_config.compress_to_account_pubkey.as_ref() { - // Compress to pubkey specifies compression to account pubkey instead of the owner. - compress_to_pubkey.check_seeds(accounts.token_account.key())?; - } - - // If restricted extensions exist, compression_only must be set - if mint_extensions.has_restricted_extensions() && compressible_config.compression_only == 0 - { - msg!("Mint has restricted extensions - compression_only must be set"); - return Err(anchor_compressed_token::ErrorCode::CompressionOnlyRequired.into()); - } - - // compression_only can only be set for mints with restricted extensions - if compressible_config.compression_only != 0 && !mint_extensions.has_restricted_extensions() - { - msg!("compression_only can only be set for mints with restricted extensions"); - return Err(anchor_compressed_token::ErrorCode::CompressionOnlyNotAllowed.into()); - } - - // Calculate account size based on extensions (includes Compressible extension) - let account_size = mint_extensions.calculate_account_size(true)?; - - let config_account = compressible.parsed_config; - let rent = config_account - .rent_config - .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); - let account_size = account_size as usize; - - let custom_rent_payer = - *compressible.rent_payer.key() != config_account.rent_sponsor.to_bytes(); - - // Prevents setting executable accounts as rent_sponsor - if custom_rent_payer && !compressible.rent_payer.is_signer() { - msg!("Custom rent payer must be a signer"); - return Err(ProgramError::MissingRequiredSignature); - } - - // Build fee_payer seeds (rent_sponsor PDA or None for custom keypair) - let version_bytes = config_account.version.to_le_bytes(); - let bump_seed = [config_account.rent_sponsor_bump]; - let rent_sponsor_seeds = [ - Seed::from(b"rent_sponsor".as_ref()), - Seed::from(version_bytes.as_ref()), - Seed::from(bump_seed.as_ref()), - ]; - - let fee_payer_seeds = if custom_rent_payer { - None - } else { - Some(rent_sponsor_seeds.as_slice()) - }; - - let additional_lamports = if custom_rent_payer { Some(rent) } else { None }; - - // Create token account (handles DoS prevention internally) - create_pda_account( - compressible.rent_payer, - accounts.token_account, - account_size, - fee_payer_seeds, - None, // token_account is keypair signer - additional_lamports, - )?; - - // When using protocol rent sponsor, payer pays the compression incentive - if !custom_rent_payer { - transfer_lamports_via_cpi(rent, compressible.payer, accounts.token_account) - .map_err(convert_program_error)?; - } - - Some(CompressibleInitData { - ix_data: compressible_config, - config_account: compressible.parsed_config, - custom_rent_payer: if custom_rent_payer { - Some(*compressible.rent_payer.key()) - } else { - None - }, - is_ata: false, - }) - } else { - // Non-compressible account: token_account must already exist and be owned by our program - // This is SPL-compatible initialize_account3 behavior - None - }; - - // Initialize the token account - initialize_ctoken_account( - accounts.token_account, - CTokenInitConfig { - mint: accounts.mint.key(), - owner: &inputs.owner.to_bytes(), - compressible: compressible_init_data, - mint_extensions, - mint_account: accounts.mint, - }, - ) -} diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs new file mode 100644 index 0000000000..8ee40e3db1 --- /dev/null +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -0,0 +1,124 @@ +use anchor_lang::solana_program::program_error::ProgramError; +use pinocchio::account_info::AccountInfo; +use pinocchio_token_program::processor::{approve::process_approve, revoke::process_revoke}; +#[cfg(target_os = "solana")] +use { + crate::shared::{convert_program_error, transfer_lamports_via_cpi}, + light_ctoken_interface::state::top_up_lamports_from_account_info_unchecked, + light_ctoken_interface::CTokenError, +}; + +use crate::shared::convert_pinocchio_token_error; + +/// Approve: 8-byte base (amount), payer at index 2 +const APPROVE_BASE_LEN: usize = 8; +const APPROVE_PAYER_IDX: usize = 2; + +/// Revoke: 0-byte base, payer at index 1 +const REVOKE_BASE_LEN: usize = 0; +const REVOKE_PAYER_IDX: usize = 1; + +/// Process CToken approve instruction. +/// Handles compressible extension top-up after delegating to pinocchio. +/// +/// Instruction data format (backwards compatible): +/// - 8 bytes: amount (legacy, no max_top_up enforcement) +/// - 10 bytes: amount + max_top_up (u16, 0 = no limit) +#[inline(always)] +pub fn process_ctoken_approve( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + if accounts.is_empty() { + return Err(ProgramError::NotEnoughAccountKeys); + } + if instruction_data.len() < APPROVE_BASE_LEN { + return Err(ProgramError::InvalidInstructionData); + } + process_approve(accounts, &instruction_data[..APPROVE_BASE_LEN]) + .map_err(convert_pinocchio_token_error)?; + handle_compressible_top_up::(accounts, instruction_data) +} + +/// Process CToken revoke instruction. +/// Handles compressible extension top-up after delegating to pinocchio. +/// +/// Instruction data format (backwards compatible): +/// - 0 bytes: legacy, no max_top_up enforcement +/// - 2 bytes: max_top_up (u16, 0 = no limit) +#[inline(always)] +pub fn process_ctoken_revoke( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + if accounts.is_empty() { + return Err(ProgramError::NotEnoughAccountKeys); + } + process_revoke(accounts).map_err(convert_pinocchio_token_error)?; + handle_compressible_top_up::(accounts, instruction_data) +} + +/// Handle compressible extension top-up after pinocchio processing. +/// +/// # Type Parameters +/// * `BASE_LEN` - Base instruction data length (8 for approve, 0 for revoke) +/// * `PAYER_IDX` - Index of payer account (2 for approve, 1 for revoke) +#[inline(always)] +fn handle_compressible_top_up( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + let source = &accounts[0]; + + // Hot path: 165-byte accounts have no extensions + if source.data_len() == 165 { + return Ok(()); + } + + process_compressible_top_up::(source, accounts, instruction_data) +} + +/// Calculate and transfer compressible top-up for a single ctoken account. +/// +/// # Type Parameters +/// * `BASE_LEN` - Base instruction data length (8 for approve, 0 for revoke) +/// * `PAYER_IDX` - Index of payer account (2 for approve, 1 for revoke) +#[cold] +#[allow(unused)] +fn process_compressible_top_up( + account: &AccountInfo, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + // Returns None if no Compressible extension, Some(amount) otherwise + #[cfg(target_os = "solana")] + { + let payer = accounts.get(PAYER_IDX); + + let max_top_up = match instruction_data.len() { + len if len == BASE_LEN => 0u16, + len if len == BASE_LEN + 2 => u16::from_le_bytes( + instruction_data[BASE_LEN..BASE_LEN + 2] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + let transfer_amount = { + let mut current_slot = 0; + top_up_lamports_from_account_info_unchecked(account, &mut current_slot).unwrap_or(0) + }; + + if transfer_amount > 0 { + if max_top_up > 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + let payer = payer.ok_or(CTokenError::MissingPayer)?; + transfer_lamports_via_cpi(transfer_amount, payer, account) + .map_err(convert_program_error)?; + } + } + + Ok(()) +} 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..40a30dccdb --- /dev/null +++ b/programs/compressed-token/program/src/ctoken/burn.rs @@ -0,0 +1,118 @@ +use anchor_lang::solana_program::{msg, program_error::ProgramError}; +use light_program_profiler::profile; +use pinocchio::{account_info::AccountInfo, program_error::ProgramError as PinocchioProgramError}; +use pinocchio_token_program::processor::{burn::process_burn, burn_checked::process_burn_checked}; + +use crate::shared::{ + compressible_top_up::calculate_and_execute_compressible_top_ups, convert_pinocchio_token_error, +}; + +pub(crate) type ProcessorFn = fn(&[AccountInfo], &[u8]) -> Result<(), PinocchioProgramError>; + +/// Base instruction data length constants +pub(crate) const BASE_LEN_UNCHECKED: usize = 8; +pub(crate) const BASE_LEN_CHECKED: usize = 9; + +/// Burn account indices: [ctoken=0, cmint=1, authority=2] +const BURN_CMINT_IDX: usize = 1; +const BURN_CTOKEN_IDX: usize = 0; + +/// 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> { + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_burn, + ) +} + +/// Process ctoken burn_checked instruction +/// +/// Instruction data format: +/// - 9 bytes: amount (8) + decimals (1) - legacy, no max_top_up enforcement +/// - 11 bytes: amount (8) + decimals (1) + max_top_up (2, u16, 0 = no limit) +/// +/// Account layout (same as burn): +/// 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_checked( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_burn_checked, + ) +} + +/// Shared inner implementation for ctoken mint_to and burn variants. +/// +/// # Type Parameters +/// * `BASE_LEN` - Base instruction data length (8 for unchecked, 9 for checked) +/// * `CMINT_IDX` - Index of CMint account (0 for mint_to, 1 for burn) +/// * `CTOKEN_IDX` - Index of CToken account (1 for mint_to, 0 for burn) +/// +/// # Arguments +/// * `accounts` - Account layout: [cmint/ctoken, ctoken/cmint, authority] +/// * `instruction_data` - Serialized instruction data +/// * `processor` - Pinocchio processor function +#[inline(always)] +pub(crate) fn process_ctoken_supply_change_inner< + const BASE_LEN: usize, + const CMINT_IDX: usize, + const CTOKEN_IDX: usize, +>( + accounts: &[AccountInfo], + instruction_data: &[u8], + processor: ProcessorFn, +) -> Result<(), ProgramError> { + if accounts.len() < 3 { + msg!( + "CToken: expected at least 3 accounts received {}", + accounts.len() + ); + return Err(ProgramError::NotEnoughAccountKeys); + } + + if instruction_data.len() < BASE_LEN { + return Err(ProgramError::InvalidInstructionData); + } + + let max_top_up = match instruction_data.len() { + len if len == BASE_LEN => 0u16, + len if len == BASE_LEN + 2 => u16::from_le_bytes( + instruction_data[BASE_LEN..BASE_LEN + 2] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + processor(accounts, &instruction_data[..BASE_LEN]).map_err(convert_pinocchio_token_error)?; + + // Calculate and execute top-ups for both CMint and CToken + // SAFETY: accounts.len() >= 3 validated at function entry + let cmint = &accounts[CMINT_IDX]; + let ctoken = &accounts[CTOKEN_IDX]; + let payer = accounts.get(2); + + calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) +} diff --git a/programs/compressed-token/program/src/close_token_account/accounts.rs b/programs/compressed-token/program/src/ctoken/close/accounts.rs similarity index 100% rename from programs/compressed-token/program/src/close_token_account/accounts.rs rename to programs/compressed-token/program/src/ctoken/close/accounts.rs diff --git a/programs/compressed-token/program/src/close_token_account/mod.rs b/programs/compressed-token/program/src/ctoken/close/mod.rs similarity index 100% rename from programs/compressed-token/program/src/close_token_account/mod.rs rename to programs/compressed-token/program/src/ctoken/close/mod.rs diff --git a/programs/compressed-token/program/src/close_token_account/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs similarity index 65% rename from programs/compressed-token/program/src/close_token_account/processor.rs rename to programs/compressed-token/program/src/ctoken/close/processor.rs index 3ce04f9a16..0c8684256d 100644 --- a/programs/compressed-token/program/src/close_token_account/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -1,7 +1,7 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::{checks::check_signer, AccountInfoTrait}; -use light_compressible::rent::{get_rent_exemption_lamports, AccountRentState}; +use light_compressible::rent::AccountRentState; use light_ctoken_interface::state::{AccountState, CToken, ZCTokenMut}; use light_program_profiler::profile; #[cfg(target_os = "solana")] @@ -22,109 +22,37 @@ pub fn process_close_token_account( let accounts = CloseTokenAccountAccounts::validate_and_parse(account_infos)?; { // Try to parse as CToken using zero-copy deserialization - let token_account_data = - &mut AccountInfoTrait::try_borrow_mut_data(accounts.token_account)?; - let (ctoken, _) = CToken::zero_copy_at_mut_checked(token_account_data)?; - validate_token_account_close_instruction(&accounts, &ctoken)?; + let ctoken = CToken::from_account_info_mut_checked(accounts.token_account)?; + validate_token_account_close(&accounts, &ctoken)?; } close_token_account(&accounts)?; Ok(()) } /// Validates that a ctoken solana account is ready to be closed. -/// The rent authority cannot close the account. +/// Only the owner or close_authority can close the account. #[profile] -pub fn validate_token_account_close_instruction( +fn validate_token_account_close( accounts: &CloseTokenAccountAccounts, ctoken: &ZCTokenMut<'_>, ) -> Result<(), ProgramError> { - validate_token_account::(accounts, ctoken)?; - Ok(()) -} - -/// Validates that a ctoken solana account is ready to be closed. -/// The rent authority can close the account. -#[profile] -pub fn validate_token_account_for_close_transfer2( - accounts: &CloseTokenAccountAccounts, - ctoken: &ZCTokenMut<'_>, -) -> Result { - validate_token_account::(accounts, ctoken) -} - -#[inline(always)] -fn validate_token_account( - accounts: &CloseTokenAccountAccounts, - ctoken: &ZCTokenMut<'_>, -) -> Result { if accounts.token_account.key() == accounts.destination.key() { return Err(ProgramError::InvalidAccountData); } - // For compress and close we compress the balance and close. - if !COMPRESS_AND_CLOSE { - // Check that the account has zero balance - if u64::from(ctoken.amount) != 0 { - return Err(ErrorCode::NonNativeHasBalance.into()); - } - // TODO: Non-zero transfer fees not yet supported. If fees != 0 support is added: - // - Check TransferFeeAccount.withheld_amount == 0 before allowing close - // - Implement harvest_withheld_fees instruction to extract fees first - // - T22 blocks close when withheld_amount > 0 to prevent fee loss + // Check that the account has zero balance + if u64::from(ctoken.amount) != 0 { + return Err(ErrorCode::NonNativeHasBalance.into()); } + // Note: Non-zero transfer fees are not yet supported. If fees != 0 support is added: + // - Check TransferFeeAccount.withheld_amount == 0 before allowing close + // - Implement harvest_withheld_fees instruction to extract fees first + // - T22 blocks close when withheld_amount > 0 to prevent fee loss + // Check for Compressible extension let compressible = ctoken.get_compressible_extension(); - if COMPRESS_AND_CLOSE { - // CompressAndClose requires Compressible extension - let compression = compressible.ok_or_else(|| { - msg!("compress and close requires compressible extension"); - ProgramError::InvalidAccountData - })?; - - // Validate rent_sponsor matches - let rent_sponsor = accounts - .rent_sponsor - .ok_or(ProgramError::NotEnoughAccountKeys)?; - if compression.info.rent_sponsor != *rent_sponsor.key() { - msg!("rent recipient mismatch"); - return Err(ProgramError::InvalidAccountData); - } - - // For CompressAndClose: ONLY compression_authority can compress and close - if compression.info.compression_authority != *accounts.authority.key() { - msg!("compress and close requires compression authority"); - return Err(ProgramError::InvalidAccountData); - } - - #[cfg(target_os = "solana")] - let current_slot = pinocchio::sysvars::clock::Clock::get() - .map_err(convert_program_error)? - .slot; - - #[cfg(target_os = "solana")] - { - let is_compressible = compression - .info - .is_compressible( - accounts.token_account.data_len() as u64, - current_slot, - accounts.token_account.lamports(), - ) - .map_err(|_| ProgramError::InvalidAccountData)?; - - if is_compressible.is_none() { - msg!("account not compressible"); - return Err(ProgramError::InvalidAccountData); - } - } - - // Return true if either compress_to_pubkey is set OR this is an ATA - // When true, the compressed account owner will be the token account pubkey - return Ok(compression.info.compress_to_pubkey() || compression.is_ata != 0); - } - - // For regular close: validate rent_sponsor if compressible + // For regular close: validate rent_sponsor if it has a compressible extension if let Some(compression) = compressible { let rent_sponsor = accounts .rent_sponsor @@ -168,7 +96,7 @@ fn validate_token_account( return Err(ErrorCode::OwnerMismatch.into()); } } - Ok(false) + Ok(()) } pub fn close_token_account(accounts: &CloseTokenAccountAccounts<'_>) -> Result<(), ProgramError> { @@ -186,8 +114,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( })?; // Check for compressible extension and handle lamport distribution - let token_account_data = AccountInfoTrait::try_borrow_data(accounts.token_account)?; - let (ctoken, _) = CToken::zero_copy_at_checked(&token_account_data)?; + let ctoken = CToken::from_account_info_checked(accounts.token_account)?; // Check for Compressible extension let compressible = ctoken.get_compressible_extension(); @@ -203,9 +130,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( let compression_cost: u64 = compression.info.rent_config.compression_cost.into(); let (mut lamports_to_rent_sponsor, mut lamports_to_destination) = { - let base_lamports = - get_rent_exemption_lamports(accounts.token_account.data_len() as u64) - .map_err(|_| ProgramError::InvalidAccountData)?; + let base_lamports: u64 = compression.info.rent_exemption_paid.into(); let state = AccountRentState { num_bytes: accounts.token_account.data_len() as u64, diff --git a/programs/compressed-token/program/src/ctoken/create.rs b/programs/compressed-token/program/src/ctoken/create.rs new file mode 100644 index 0000000000..cd36e95435 --- /dev/null +++ b/programs/compressed-token/program/src/ctoken/create.rs @@ -0,0 +1,108 @@ +use anchor_lang::prelude::ProgramError; +use borsh::BorshDeserialize; +use light_account_checks::AccountIterator; +use light_compressed_account::Pubkey; +use light_ctoken_interface::instructions::create_ctoken_account::CreateTokenAccountInstructionData; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use spl_pod::solana_msg::msg; + +use crate::{ + extensions::has_mint_extensions, + shared::{ + create_compressible_account, + initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, + next_config_account, + }, +}; + +/// Process the create token account instruction +#[profile] +pub fn process_create_token_account( + account_infos: &[AccountInfo], + mut instruction_data: &[u8], +) -> Result<(), ProgramError> { + // SPL compatibility: if instruction_data is exactly 32 bytes, treat as owner-only (no compressible config) + // This matches SPL Token's initialize_account3 which only sends the owner pubkey + let inputs = if instruction_data.len() == 32 { + let owner_bytes: [u8; 32] = instruction_data[..32] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?; + CreateTokenAccountInstructionData { + owner: Pubkey::from(owner_bytes), + compressible_config: None, + } + } else { + CreateTokenAccountInstructionData::deserialize(&mut instruction_data) + .map_err(ProgramError::from)? + }; + + let is_compressible = inputs.compressible_config.is_some(); + + let mut iter = AccountIterator::new(account_infos); + + // For compressible accounts: token_account must be signer (account created via CPI) + // For non-compressible accounts: token_account doesn't need to be signer (SPL compatibility) + let token_account = if is_compressible { + iter.next_signer_mut("token_account")? + } else { + iter.next_mut("token_account")? + }; + let mint = iter.next_non_mut("mint")?; + + // Check which extensions the mint has (single deserialization) + let mint_extensions = has_mint_extensions(mint)?; + + // Handle compressible vs non-compressible account creation + let compressible_init_data = if let Some(ref compressible_config) = inputs.compressible_config { + let payer = iter.next_signer_mut("payer")?; + let config_account = next_config_account(&mut iter)?; + let _system_program = iter.next_non_mut("system_program")?; + let rent_payer = iter.next_mut("rent_payer")?; + + if let Some(compress_to_pubkey) = compressible_config.compress_to_account_pubkey.as_ref() { + compress_to_pubkey.check_seeds(token_account.key())?; + } + + // If restricted extensions exist, compression_only must be set + if mint_extensions.has_restricted_extensions() && compressible_config.compression_only == 0 + { + msg!("Mint has restricted extensions - compression_only must be set"); + return Err(anchor_compressed_token::ErrorCode::CompressionOnlyRequired.into()); + } + + // compression_only can only be set for mints with restricted extensions + if compressible_config.compression_only != 0 && !mint_extensions.has_restricted_extensions() + { + msg!("compression_only can only be set for mints with restricted extensions"); + return Err(anchor_compressed_token::ErrorCode::CompressionOnlyNotAllowed.into()); + } + + Some(create_compressible_account( + compressible_config, + &mint_extensions, + config_account, + rent_payer, + token_account, + payer, + None, // token_account is keypair signer + false, + )?) + } else { + // Non-compressible account: token_account must already exist and be owned by CToken program. + // Unlike SPL initialize_account3 (which expects System-owned), this expects a pre-existing + // CToken-owned account. Ownership is implicitly validated when writing to the account. + None + }; + + // Initialize the token account + initialize_ctoken_account( + token_account, + CTokenInitConfig { + owner: &inputs.owner.to_bytes(), + compressible: compressible_init_data, + mint_extensions, + mint_account: mint, + }, + ) +} diff --git a/programs/compressed-token/program/src/create_associated_token_account.rs b/programs/compressed-token/program/src/ctoken/create_ata.rs similarity index 57% rename from programs/compressed-token/program/src/create_associated_token_account.rs rename to programs/compressed-token/program/src/ctoken/create_ata.rs index 07eb70e46e..c5ce1dc004 100644 --- a/programs/compressed-token/program/src/create_associated_token_account.rs +++ b/programs/compressed-token/program/src/ctoken/create_ata.rs @@ -7,14 +7,11 @@ use pinocchio::{account_info::AccountInfo, instruction::Seed}; use spl_pod::solana_msg::msg; use crate::{ - create_token_account::next_config_account, extensions::has_mint_extensions, shared::{ - convert_program_error, create_pda_account, - initialize_ctoken_account::{ - initialize_ctoken_account, CTokenInitConfig, CompressibleInitData, - }, - transfer_lamports_via_cpi, validate_ata_derivation, + create_compressible_account, create_pda_account, + initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, + next_config_account, validate_ata_derivation, }, }; @@ -42,7 +39,8 @@ pub fn process_create_associated_token_account_idempotent( /// 2. fee_payer (signer, mut) /// 3. associated_token_account (mut) /// 4. system_program -/// Optional (only when compressible_config is Some): +/// +/// Optional (only when compressible_config is Some): /// 5. compressible_config /// 6. rent_payer #[profile] @@ -92,90 +90,28 @@ fn process_create_associated_token_account_with_mode( // Handle compressible vs non-compressible account creation let compressible = if let Some(compressible_config) = &inputs.compressible_config { - // Validate that rent_payment is not exactly 1 epoch (footgun prevention) - if compressible_config.rent_payment == 1 { - msg!("Prefunding for exactly 1 epoch is not allowed. If the account is created near an epoch boundary, it could become immediately compressible. Use 0 or 2+ epochs."); - return Err(anchor_compressed_token::ErrorCode::OneEpochPrefundingNotAllowed.into()); - } - - // Associated token accounts must not compress to pubkey if compressible_config.compress_to_account_pubkey.is_some() { msg!("Associated token accounts must not compress to pubkey"); return Err(ProgramError::InvalidInstructionData); } - - // Associated token accounts must always be compression_only if compressible_config.compression_only == 0 { msg!("Associated token accounts must have compression_only set"); return Err(anchor_compressed_token::ErrorCode::AtaRequiresCompressionOnly.into()); } - // Parse additional accounts for compressible path let config_account = next_config_account(&mut iter)?; let rent_payer = iter.next_mut("rent_payer")?; - // Calculate account size based on extensions (includes Compressible extension) - let account_size = mint_extensions.calculate_account_size(true)?; - - let rent = config_account - .rent_config - .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); - let account_size = account_size as usize; - - let custom_rent_payer = *rent_payer.key() != config_account.rent_sponsor.to_bytes(); - - // Prevents setting executable accounts as rent_sponsor - if custom_rent_payer && !rent_payer.is_signer() { - msg!("Custom rent payer must be a signer"); - return Err(ProgramError::MissingRequiredSignature); - } - - // Build rent sponsor seeds if using rent sponsor PDA as fee_payer - let version_bytes = config_account.version.to_le_bytes(); - let rent_sponsor_bump = [config_account.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.as_ref()), - ]; - - let fee_payer_seeds = if custom_rent_payer { - None - } else { - Some(rent_sponsor_seeds.as_slice()) - }; - - let additional_lamports = if custom_rent_payer { Some(rent) } else { None }; - - // Create ATA account - create_pda_account( + Some(create_compressible_account( + compressible_config, + &mint_extensions, + config_account, rent_payer, associated_token_account, - account_size, - fee_payer_seeds, - Some(ata_seeds.as_slice()), - additional_lamports, - )?; - - // When using protocol rent sponsor, fee_payer pays the compression incentive - if !custom_rent_payer { - transfer_lamports_via_cpi(rent, fee_payer, associated_token_account) - .map_err(convert_program_error)?; - } - - // For ATAs, we use is_ata flag in the extension instead of compress_to_pubkey. - // The is_ata flag allows decompress to verify the destination is the correct ATA - // while keeping the compressed account owner as the wallet owner (who can sign). - Some(CompressibleInitData { - ix_data: compressible_config, - config_account, - custom_rent_payer: if custom_rent_payer { - Some(*rent_payer.key()) - } else { - None - }, - is_ata: true, - }) + fee_payer, + Some(ata_seeds.as_slice()), // ATA is a PDA + true, // is_ata = true + )?) } else { // Non-compressible path: fee_payer pays for account creation directly // Non-compressible accounts have no extensions (base 165-byte SPL layout) @@ -197,7 +133,6 @@ fn process_create_associated_token_account_with_mode( initialize_ctoken_account( associated_token_account, CTokenInitConfig { - mint: mint_bytes, owner: owner_bytes, compressible, mint_extensions, diff --git a/programs/compressed-token/program/src/ctoken_freeze_thaw.rs b/programs/compressed-token/program/src/ctoken/freeze_thaw.rs similarity index 80% rename from programs/compressed-token/program/src/ctoken_freeze_thaw.rs rename to programs/compressed-token/program/src/ctoken/freeze_thaw.rs index e9fd15ee71..d3867bd0e8 100644 --- a/programs/compressed-token/program/src/ctoken_freeze_thaw.rs +++ b/programs/compressed-token/program/src/ctoken/freeze_thaw.rs @@ -4,7 +4,7 @@ use pinocchio_token_program::processor::{ freeze_account::process_freeze_account, thaw_account::process_thaw_account, }; -use crate::shared::owner_validation::check_token_program_owner; +use crate::shared::{convert_pinocchio_token_error, owner_validation::check_token_program_owner}; /// Process CToken freeze account instruction. /// Validates mint ownership before calling pinocchio-token-program. @@ -13,7 +13,7 @@ pub fn process_ctoken_freeze_account(accounts: &[AccountInfo]) -> Result<(), Pro // accounts[1] is the mint let mint_info = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; check_token_program_owner(mint_info)?; - process_freeze_account(accounts).map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + process_freeze_account(accounts).map_err(convert_pinocchio_token_error) } /// Process CToken thaw account instruction. @@ -23,5 +23,5 @@ pub fn process_ctoken_thaw_account(accounts: &[AccountInfo]) -> Result<(), Progr // accounts[1] is the mint let mint_info = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; check_token_program_owner(mint_info)?; - process_thaw_account(accounts).map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + process_thaw_account(accounts).map_err(convert_pinocchio_token_error) } 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..2a552e5483 --- /dev/null +++ b/programs/compressed-token/program/src/ctoken/mint_to.rs @@ -0,0 +1,58 @@ +use anchor_lang::solana_program::program_error::ProgramError; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use pinocchio_token_program::processor::{ + mint_to::process_mint_to, mint_to_checked::process_mint_to_checked, +}; + +use super::burn::{process_ctoken_supply_change_inner, BASE_LEN_CHECKED, BASE_LEN_UNCHECKED}; + +/// Mint account indices: [cmint=0, ctoken=1, authority=2] +pub(crate) const MINT_CMINT_IDX: usize = 0; +pub(crate) const MINT_CTOKEN_IDX: usize = 1; + +/// 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> { + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_mint_to, + ) +} + +/// Process ctoken mint_to_checked instruction +/// +/// Instruction data format: +/// - 9 bytes: amount (8) + decimals (1) - legacy, no max_top_up enforcement +/// - 11 bytes: amount (8) + decimals (1) + max_top_up (2, u16, 0 = no limit) +/// +/// Account layout (same as mint_to): +/// 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_checked( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_mint_to_checked, + ) +} diff --git a/programs/compressed-token/program/src/ctoken/mod.rs b/programs/compressed-token/program/src/ctoken/mod.rs new file mode 100644 index 0000000000..44b420ef1b --- /dev/null +++ b/programs/compressed-token/program/src/ctoken/mod.rs @@ -0,0 +1,19 @@ +pub mod approve_revoke; +pub mod burn; +pub mod close; +pub mod create; +pub mod create_ata; +pub mod freeze_thaw; +pub mod mint_to; +pub mod transfer; + +pub use approve_revoke::{process_ctoken_approve, process_ctoken_revoke}; +pub use burn::{process_ctoken_burn, process_ctoken_burn_checked}; +pub use close::processor::process_close_token_account; +pub use create::process_create_token_account; +pub use create_ata::{ + process_create_associated_token_account, process_create_associated_token_account_idempotent, +}; +pub use freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; +pub use mint_to::{process_ctoken_mint_to, process_ctoken_mint_to_checked}; +pub use transfer::{process_ctoken_transfer, process_ctoken_transfer_checked}; diff --git a/programs/compressed-token/program/src/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs similarity index 79% rename from programs/compressed-token/program/src/transfer/checked.rs rename to programs/compressed-token/program/src/ctoken/transfer/checked.rs index 1c850fbdb4..3db6d122cd 100644 --- a/programs/compressed-token/program/src/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -7,7 +7,9 @@ use pinocchio_token_program::processor::{ }; use super::shared::{process_transfer_extensions_transfer_checked, TransferAccounts}; -use crate::shared::owner_validation::check_token_program_owner; +use crate::shared::{ + convert_pinocchio_token_error, convert_token_error, owner_validation::check_token_program_owner, +}; /// Account indices for CToken transfer_checked instruction /// Note: Different from ctoken_transfer - mint is at index 1 const ACCOUNT_SOURCE: usize = 0; @@ -39,26 +41,18 @@ pub fn process_ctoken_transfer_checked( return Err(ProgramError::InvalidInstructionData); } - // Get account references - let source = accounts - .get(ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let destination = accounts - .get(ACCOUNT_DESTINATION) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 4 validated at function entry + let source = &accounts[ACCOUNT_SOURCE]; + let destination = &accounts[ACCOUNT_DESTINATION]; // Hot path: 165-byte accounts have no extensions, skip all extension processing if source.data_len() == 165 && destination.data_len() == 165 { return process_transfer_checked(accounts, &instruction_data[..9], false) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)); + .map_err(convert_pinocchio_token_error); } - let mint = accounts - .get(ACCOUNT_MINT) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let authority = accounts - .get(ACCOUNT_AUTHORITY) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + let mint = &accounts[ACCOUNT_MINT]; + let authority = &accounts[ACCOUNT_AUTHORITY]; // Parse max_top_up based on instruction data length // 0 means no limit @@ -84,7 +78,7 @@ pub fn process_ctoken_transfer_checked( // Pass the first 9 bytes (amount + decimals) to the SPL transfer_checked processor let (amount, decimals) = - unpack_amount_and_decimals(instruction_data).map_err(|e| ProgramError::Custom(e as u32))?; + unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; if let Some(extension_decimals) = extension_decimals { if extension_decimals != decimals { @@ -100,10 +94,10 @@ pub fn process_ctoken_transfer_checked( None, signer_is_validated, ) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + .map_err(convert_pinocchio_token_error) } else { check_token_program_owner(mint)?; process_transfer(accounts, amount, Some(decimals), signer_is_validated) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + .map_err(convert_pinocchio_token_error) } } diff --git a/programs/compressed-token/program/src/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs similarity index 67% rename from programs/compressed-token/program/src/transfer/default.rs rename to programs/compressed-token/program/src/ctoken/transfer/default.rs index 37af0ce4eb..09bb0a8954 100644 --- a/programs/compressed-token/program/src/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -3,7 +3,8 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::transfer::process_transfer; -use crate::transfer::shared::{process_transfer_extensions_transfer, TransferAccounts}; +use super::shared::{process_transfer_extensions_transfer, TransferAccounts}; +use crate::shared::convert_pinocchio_token_error; /// Account indices for CToken transfer instruction const ACCOUNT_SOURCE: usize = 0; @@ -35,15 +36,12 @@ pub fn process_ctoken_transfer( } // Hot path: 165-byte accounts have no extensions, skip all extension processing - let source = accounts - .get(ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let destination = accounts - .get(ACCOUNT_DESTINATION) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 3 validated at function entry + let source = &accounts[ACCOUNT_SOURCE]; + let destination = &accounts[ACCOUNT_DESTINATION]; if source.data_len() == 165 && destination.data_len() == 165 { return process_transfer(accounts, &instruction_data[..8], false) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)); + .map_err(convert_pinocchio_token_error); } // Parse max_top_up based on instruction data length @@ -62,25 +60,16 @@ pub fn process_ctoken_transfer( // Only pass the first 8 bytes (amount) to the SPL transfer processor process_transfer(accounts, &instruction_data[..8], signer_is_validated) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + .map_err(convert_pinocchio_token_error) } -fn process_extensions( - accounts: &[pinocchio::account_info::AccountInfo], - max_top_up: u16, -) -> Result { - let source = accounts - .get(ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let destination = accounts - .get(ACCOUNT_DESTINATION) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let authority = accounts - .get(ACCOUNT_AUTHORITY) - .ok_or(ProgramError::NotEnoughAccountKeys)?; +fn process_extensions(accounts: &[AccountInfo], max_top_up: u16) -> Result { + // SAFETY: accounts.len() >= 3 validated in caller + let source = &accounts[ACCOUNT_SOURCE]; + let destination = &accounts[ACCOUNT_DESTINATION]; + let authority = &accounts[ACCOUNT_AUTHORITY]; - // Ignore decimals - only used for transfer_checked - let (signer_is_validated, _decimals) = process_transfer_extensions_transfer( + let (signer_is_validated, _) = process_transfer_extensions_transfer( TransferAccounts { source, destination, diff --git a/programs/compressed-token/program/src/transfer/mod.rs b/programs/compressed-token/program/src/ctoken/transfer/mod.rs similarity index 100% rename from programs/compressed-token/program/src/transfer/mod.rs rename to programs/compressed-token/program/src/ctoken/transfer/mod.rs diff --git a/programs/compressed-token/program/src/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs similarity index 87% rename from programs/compressed-token/program/src/transfer/shared.rs rename to programs/compressed-token/program/src/ctoken/transfer/shared.rs index 3b2ec322fb..1d63d8f524 100644 --- a/programs/compressed-token/program/src/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -34,6 +34,7 @@ impl AccountExtensionInfo { || self.flags.has_permanent_delegate != other.flags.has_permanent_delegate || self.flags.has_transfer_fee != other.flags.has_transfer_fee || self.flags.has_transfer_hook != other.flags.has_transfer_hook + || self.flags.has_default_account_state != other.flags.has_default_account_state { Err(ProgramError::InvalidInstructionData) } else { @@ -51,7 +52,7 @@ pub struct TransferAccounts<'a> { } /// Process transfer extensions for CTokenTransfer instruction. -/// Restricted extensions are NOT allowed (and will fail anyway due to missing mint). +/// Restricted extensions are NOT allowed (requires mint account which is not provided). #[inline(always)] #[profile] pub fn process_transfer_extensions_transfer( @@ -78,7 +79,7 @@ pub fn process_transfer_extensions_transfer_checked( /// /// # Arguments /// * `transfer_accounts` - Account references for source, destination, authority, and optional mint -/// * `max_top_up` - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) +/// * `max_top_up` - Maximum lamports for top-up. Transaction fails if exceeded. (0 = no limit) /// * `deny_restricted_extensions` - If true, reject source accounts with restricted T22 extensions /// /// Returns: @@ -162,6 +163,7 @@ fn validate_sender( // Get mint checks if any account has extensions (single mint deserialization) let mint_checks = if sender_info.flags.has_restricted_extensions() { + // Transfer instruction with ctoken account with restricted extensions will fail here. let mint_account = transfer_accounts .mint .ok_or(ErrorCode::MintRequiredForTransfer)?; @@ -196,17 +198,19 @@ fn validate_permanent_delegate( mint_checks: Option<&MintExtensionChecks>, authority: &AccountInfo, ) -> Result { - if let Some(checks) = mint_checks { - if let Some(permanent_delegate_pubkey) = checks.permanent_delegate { - if pubkey_eq(authority.key(), &permanent_delegate_pubkey) { - if !authority.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - return Ok(true); - } - } + let Some(checks) = mint_checks else { + return Ok(false); + }; + let Some(permanent_delegate_pubkey) = checks.permanent_delegate else { + return Ok(false); + }; + if !pubkey_eq(authority.key(), &permanent_delegate_pubkey) { + return Ok(false); + } + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); } - Ok(false) + Ok(true) } /// Process account extensions with mutable access. @@ -219,13 +223,7 @@ fn process_account_extensions( current_slot: &mut u64, mint: Option<&AccountInfo>, ) -> Result { - let mut account_data = account - .try_borrow_mut_data() - .map_err(convert_program_error)?; - let (token, remaining) = CToken::zero_copy_at_mut_checked(&mut account_data)?; - if !remaining.is_empty() { - return Err(ProgramError::InvalidAccountData); - } + let token = CToken::from_account_info_mut_checked(account)?; // Validate mint account matches token's mint field if let Some(mint_account) = mint { @@ -239,25 +237,16 @@ fn process_account_extensions( // Only calculate top-up if account has Compressible extension if let Some(compression) = token.get_compressible_extension() { // Get current slot for compressible top-up calculation - use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; + use pinocchio::sysvars::{clock::Clock, Sysvar}; if *current_slot == 0 { *current_slot = Clock::get() .map_err(|_| CTokenError::SysvarAccessError)? .slot; } - let rent_exemption = Rent::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .minimum_balance(account.data_len()); - info.top_up_amount = compression .info - .calculate_top_up_lamports( - account.data_len() as u64, - *current_slot, - account.lamports(), - rent_exemption, - ) + .calculate_top_up_lamports(account.data_len() as u64, *current_slot, account.lamports()) .map_err(|_| CTokenError::InvalidAccountData)?; // Extract cached decimals if set @@ -274,7 +263,7 @@ fn process_account_extensions( ZExtensionStructMut::PermanentDelegateAccount(_) => { info.flags.has_permanent_delegate = true; } - ZExtensionStructMut::TransferFeeAccount(_transfer_fee_ext) => { + ZExtensionStructMut::TransferFeeAccount(_) => { info.flags.has_transfer_fee = true; // Note: Non-zero transfer fees are rejected by check_mint_extensions, // so no fee withholding is needed here. diff --git a/programs/compressed-token/program/src/ctoken_approve_revoke.rs b/programs/compressed-token/program/src/ctoken_approve_revoke.rs deleted file mode 100644 index f532cc684f..0000000000 --- a/programs/compressed-token/program/src/ctoken_approve_revoke.rs +++ /dev/null @@ -1,292 +0,0 @@ -use anchor_lang::solana_program::{msg, program_error::ProgramError}; -use light_ctoken_interface::{state::CToken, CTokenError}; -use pinocchio::account_info::AccountInfo; -use pinocchio_token_program::processor::{ - approve::process_approve, revoke::process_revoke, - shared::approve::process_approve as shared_process_approve, unpack_amount_and_decimals, -}; - -use crate::{ - shared::{ - convert_program_error, owner_validation::check_token_program_owner, - transfer_lamports_via_cpi, - }, - transfer2::compression::ctoken::process_compression_top_up, -}; - -/// Account indices for approve instruction -const APPROVE_ACCOUNT_SOURCE: usize = 0; -const APPROVE_ACCOUNT_OWNER: usize = 2; // owner is payer for top-up - -/// Account indices for approve_checked instruction (static 4-account layout) -const APPROVE_CHECKED_ACCOUNT_SOURCE: usize = 0; -const APPROVE_CHECKED_ACCOUNT_MINT: usize = 1; -const APPROVE_CHECKED_ACCOUNT_DELEGATE: usize = 2; -const APPROVE_CHECKED_ACCOUNT_OWNER: usize = 3; - -/// Account indices for revoke instruction -const REVOKE_ACCOUNT_SOURCE: usize = 0; -const REVOKE_ACCOUNT_OWNER: usize = 1; // owner is payer for top-up - -/// Process CToken approve instruction. -/// Handles compressible extension top-up before delegating to pinocchio. -/// -/// Instruction data format (backwards compatible): -/// - 8 bytes: amount (legacy, no max_top_up enforcement) -/// - 10 bytes: amount + max_top_up (u16, 0 = no limit) -#[inline(always)] -pub fn process_ctoken_approve( - accounts: &[AccountInfo], - instruction_data: &[u8], -) -> Result<(), ProgramError> { - let source = accounts - .get(APPROVE_ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - process_approve(accounts, &instruction_data[..8]).map_err(convert_program_error)?; - // Hot path: 165-byte accounts have no extensions, just call pinocchio directly - if source.data_len() == 165 { - return Ok(()); - } - - let payer = accounts - .get(APPROVE_ACCOUNT_OWNER) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - - // Parse max_top_up based on instruction data length (0 = no limit) - let max_top_up = match instruction_data.len() { - 8 => 0u16, // Legacy: no max_top_up - 10 => u16::from_le_bytes( - instruction_data[8..10] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - process_compressible_top_up(source, payer, max_top_up) -} - -/// Process CToken revoke instruction. -/// Handles compressible extension top-up before delegating to pinocchio. -/// -/// Instruction data format (backwards compatible): -/// - 0 bytes: legacy, no max_top_up enforcement -/// - 2 bytes: max_top_up (u16, 0 = no limit) -#[inline(always)] -pub fn process_ctoken_revoke( - accounts: &[AccountInfo], - instruction_data: &[u8], -) -> Result<(), ProgramError> { - let source = accounts - .get(REVOKE_ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - - process_revoke(accounts).map_err(convert_program_error)?; - - // Hot path: 165-byte accounts have no extensions - if source.data_len() == 165 { - return Ok(()); - } - - let payer = accounts - .get(REVOKE_ACCOUNT_OWNER) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - - // Parse max_top_up based on instruction data length (0 = no limit) - let max_top_up = match instruction_data.len() { - 0 => 0u16, // Legacy: no max_top_up - 2 => u16::from_le_bytes( - instruction_data[0..2] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - process_compressible_top_up(source, payer, max_top_up) -} - -/// Calculate and transfer compressible top-up for a single account. -/// -/// # Arguments -/// * `max_top_up` - Maximum lamports for top-up. Transaction fails if exceeded. (0 = no limit) -#[inline(always)] -fn process_compressible_top_up( - account: &AccountInfo, - payer: &AccountInfo, - max_top_up: u16, -) -> Result<(), ProgramError> { - // Borrow account data to get extensions - let mut account_data = account - .try_borrow_mut_data() - .map_err(convert_program_error)?; - let (ctoken, _) = CToken::zero_copy_at_mut_checked(&mut account_data)?; - - // Only process top-up if account has Compressible extension - let transfer_amount = if let Some(compressible) = ctoken.get_compressible_extension() { - let mut transfer_amount = 0u64; - let mut lamports_budget = if max_top_up == 0 { - u64::MAX - } else { - (max_top_up as u64).saturating_add(1) - }; - - process_compression_top_up( - &compressible.info, - account, - &mut 0, - &mut transfer_amount, - &mut lamports_budget, - )?; - - if transfer_amount > 0 && lamports_budget == 0 { - return Err(CTokenError::MaxTopUpExceeded.into()); - } - transfer_amount - } else { - 0 - }; - - // Drop borrow before CPI - drop(account_data); - - if transfer_amount > 0 { - transfer_lamports_via_cpi(transfer_amount, payer, account) - .map_err(convert_program_error)?; - } - - Ok(()) -} - -/// Process CToken approve_checked instruction. -/// Static 4-account layout with cached decimals optimization. -/// -/// Instruction data format: -/// - 9 bytes: amount (8) + decimals (1) - legacy, no max_top_up enforcement -/// - 11 bytes: amount (8) + decimals (1) + max_top_up (2, u16, 0 = no limit) -/// -/// Account layout (always 4 accounts): -/// 0: source CToken account (writable) - may have cached decimals -/// 1: mint account (immutable) - used for validation if no cached decimals -/// 2: delegate (immutable) - the delegate authority -/// 3: owner (signer, writable) - owner of source, payer for top-ups -#[inline(always)] -pub fn process_ctoken_approve_checked( - accounts: &[AccountInfo], - instruction_data: &[u8], -) -> Result<(), ProgramError> { - if accounts.len() < 4 { - msg!( - "CToken approve_checked: expected at least 4 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 9 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse amount and decimals from instruction data - let (amount, decimals) = - unpack_amount_and_decimals(instruction_data).map_err(|e| ProgramError::Custom(e as u32))?; - - let source = accounts - .get(APPROVE_CHECKED_ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let mint = accounts - .get(APPROVE_CHECKED_ACCOUNT_MINT) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - - // Hot path: 165-byte accounts have no extensions (no cached decimals, no top-up) - // Validate via mint and use full 4-account layout - if source.data_len() == 165 { - check_token_program_owner(mint)?; - return shared_process_approve(accounts, amount, Some(decimals)) - .map_err(convert_program_error); - } - - // Parse max_top_up from bytes 9-10 if present (0 = no limit) - let max_top_up = match instruction_data.len() { - 9 => 0u16, // Legacy: no max_top_up - 11 => u16::from_le_bytes( - instruction_data[9..11] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - let delegate = accounts - .get(APPROVE_CHECKED_ACCOUNT_DELEGATE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let owner = accounts - .get(APPROVE_CHECKED_ACCOUNT_OWNER) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - - // Borrow source account to check for cached decimals and handle top-up - let cached_decimals = { - let mut account_data = source - .try_borrow_mut_data() - .map_err(convert_program_error)?; - let (ctoken, _) = CToken::zero_copy_at_mut_checked(&mut account_data)?; - - // Get compressible extension for cached decimals and top-up - let (cached, transfer_amount) = - if let Some(compressible) = ctoken.get_compressible_extension() { - let cached = compressible.decimals(); - - let mut transfer_amount = 0u64; - let mut lamports_budget = if max_top_up == 0 { - u64::MAX - } else { - (max_top_up as u64).saturating_add(1) - }; - - process_compression_top_up( - &compressible.info, - source, - &mut 0, - &mut transfer_amount, - &mut lamports_budget, - )?; - - if transfer_amount > 0 && lamports_budget == 0 { - return Err(CTokenError::MaxTopUpExceeded.into()); - } - (cached, transfer_amount) - } else { - (None, 0) - }; - - // Drop borrow before CPI - drop(account_data); - - if transfer_amount > 0 { - transfer_lamports_via_cpi(transfer_amount, owner, source) - .map_err(convert_program_error)?; - } - - cached - }; - - // Call pinocchio approve based on cached decimals presence - if let Some(cached_decimals) = cached_decimals { - // Validate cached decimals match instruction decimals - if cached_decimals != decimals { - msg!( - "CToken approve_checked: cached decimals {} != instruction decimals {}", - cached_decimals, - decimals - ); - return Err(ProgramError::InvalidInstructionData); - } - // Create 3-account slice [source, delegate, owner] - skip mint - let approve_accounts = [*source, *delegate, *owner]; - shared_process_approve(&approve_accounts, amount, None).map_err(convert_program_error) - } else { - // No cached decimals - validate via mint account - check_token_program_owner(mint)?; - // Use full 4-account layout [source, mint, delegate, owner] - shared_process_approve(accounts, amount, Some(decimals)).map_err(convert_program_error) - } -} diff --git a/programs/compressed-token/program/src/ctoken_burn.rs b/programs/compressed-token/program/src/ctoken_burn.rs deleted file mode 100644 index 0b6a25b113..0000000000 --- a/programs/compressed-token/program/src/ctoken_burn.rs +++ /dev/null @@ -1,110 +0,0 @@ -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, burn_checked::process_burn_checked}; - -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) -} - -/// Process ctoken burn_checked instruction -/// -/// Instruction data format: -/// - 9 bytes: amount (8) + decimals (1) - legacy, no max_top_up enforcement -/// - 11 bytes: amount (8) + decimals (1) + max_top_up (2, u16, 0 = no limit) -/// -/// Account layout (same as burn): -/// 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_checked( - accounts: &[AccountInfo], - instruction_data: &[u8], -) -> Result<(), ProgramError> { - if accounts.len() < 3 { - msg!( - "CToken burn_checked: expected at least 3 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 9 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse max_top_up from bytes 9-10 if present - let max_top_up = match instruction_data.len() { - 9 => 0u16, // Legacy: no max_top_up - 11 => u16::from_le_bytes( - instruction_data[9..11] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - // Call pinocchio burn_checked - validates decimals against CMint, handles balance/supply updates - process_burn_checked(accounts, &instruction_data[..9]) - .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 deleted file mode 100644 index 215525987e..0000000000 --- a/programs/compressed-token/program/src/ctoken_mint_to.rs +++ /dev/null @@ -1,112 +0,0 @@ -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, mint_to_checked::process_mint_to_checked, -}; - -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) -} - -/// Process ctoken mint_to_checked instruction -/// -/// Instruction data format: -/// - 9 bytes: amount (8) + decimals (1) - legacy, no max_top_up enforcement -/// - 11 bytes: amount (8) + decimals (1) + max_top_up (2, u16, 0 = no limit) -/// -/// Account layout (same as mint_to): -/// 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_checked( - accounts: &[AccountInfo], - instruction_data: &[u8], -) -> Result<(), ProgramError> { - if accounts.len() < 3 { - msg!( - "CToken mint_to_checked: expected at least 3 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 9 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse max_top_up from bytes 9-10 if present - let max_top_up = match instruction_data.len() { - 9 => 0u16, // Legacy: no max_top_up - 11 => u16::from_le_bytes( - instruction_data[9..11] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - // Call pinocchio mint_to_checked - validates decimals against CMint, handles supply/balance updates - process_mint_to_checked(accounts, &instruction_data[..9]) - .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/extensions/processor.rs b/programs/compressed-token/program/src/extensions/processor.rs index 2083ef34a6..097c26e0d1 100644 --- a/programs/compressed-token/program/src/extensions/processor.rs +++ b/programs/compressed-token/program/src/extensions/processor.rs @@ -8,7 +8,6 @@ use light_program_profiler::profile; use crate::extensions::token_metadata::create_output_token_metadata; /// Set extensions state in output compressed account. -/// Compute extensions hash chain. #[inline(always)] #[profile] pub fn extensions_state_in_output_compressed_account( diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index b78e633f85..0e1dd0e5d2 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -5,41 +5,30 @@ use light_ctoken_interface::CTOKEN_PROGRAM_ID; use light_sdk::{cpi::CpiSigner, derive_light_cpi_signer}; use pinocchio::{account_info::AccountInfo, msg}; -pub mod claim; -pub mod close_token_account; +pub mod compressed_token; +pub mod compressible; pub mod convert_account_infos; -pub mod create_associated_token_account; -pub mod create_token_account; -pub mod ctoken_approve_revoke; -pub mod ctoken_burn; -pub mod ctoken_freeze_thaw; -pub mod ctoken_mint_to; +pub mod ctoken; pub mod extensions; -pub mod mint_action; pub mod shared; -pub mod transfer; -pub mod transfer2; -pub mod withdraw_funding_pool; // Reexport the wrapped anchor program. pub use ::anchor_compressed_token::*; -use claim::process_claim; -use close_token_account::processor::process_close_token_account; -use create_associated_token_account::{ - process_create_associated_token_account, process_create_associated_token_account_idempotent, +use compressible::{process_claim, process_withdraw_funding_pool}; +use ctoken::{ + process_close_token_account, process_create_associated_token_account, + process_create_associated_token_account_idempotent, process_create_token_account, + process_ctoken_approve, process_ctoken_burn, process_ctoken_burn_checked, + process_ctoken_freeze_account, process_ctoken_mint_to, process_ctoken_mint_to_checked, + process_ctoken_revoke, process_ctoken_thaw_account, process_ctoken_transfer, + process_ctoken_transfer_checked, }; -use create_token_account::process_create_token_account; -use ctoken_approve_revoke::{ - process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_revoke, -}; -use ctoken_burn::{process_ctoken_burn, process_ctoken_burn_checked}; -use ctoken_freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; -use ctoken_mint_to::{process_ctoken_mint_to, process_ctoken_mint_to_checked}; -use transfer::{process_ctoken_transfer, process_ctoken_transfer_checked}; -use withdraw_funding_pool::process_withdraw_funding_pool; use crate::{ - convert_account_infos::convert_account_infos, mint_action::processor::process_mint_action, + compressed_token::{ + mint_action::processor::process_mint_action, transfer2::processor::process_transfer2, + }, + convert_account_infos::convert_account_infos, }; pub const LIGHT_CPI_SIGNER: CpiSigner = @@ -47,8 +36,11 @@ pub const LIGHT_CPI_SIGNER: CpiSigner = pub const MAX_ACCOUNTS: usize = 30; pub(crate) const MAX_PACKED_ACCOUNTS: usize = 40; +/// Maximum number of compression operations per instruction. +/// Used for compression_to_input lookup array sizing. +pub(crate) const MAX_COMPRESSIONS: usize = 32; -// Custom ctoken instructions start at 100 to skip spl-token program instrutions. +// Instruction discriminators use SPL Token values (3-18) for compatibility plus custom values (100+). // When adding new instructions check anchor discriminators for collisions! #[repr(u8)] pub enum InstructionType { @@ -70,8 +62,6 @@ pub enum InstructionType { CTokenThawAccount = 11, /// CToken TransferChecked - transfer with decimals validation (SPL compatible) CTokenTransferChecked = 12, - /// CToken ApproveChecked - approve with decimals validation (SPL compatible) - CTokenApproveChecked = 13, /// CToken MintToChecked - mint with decimals validation CTokenMintToChecked = 14, /// CToken BurnChecked - burn with decimals validation @@ -91,11 +81,12 @@ pub enum InstructionType { /// 2. MintTo /// 3. UpdateMintAuthority /// 4. UpdateFreezeAuthority - /// 5. CreateSplMint - /// 6. MintToCToken - /// 7. UpdateMetadataField - /// 8. UpdateMetadataAuthority - /// 9. RemoveMetadataKey + /// 5. MintToCToken + /// 6. UpdateMetadataField + /// 7. UpdateMetadataAuthority + /// 8. RemoveMetadataKey + /// 9. DecompressMint + /// 10. CompressAndCloseCMint MintAction = 103, /// Claim rent for past completed epochs from compressible token account Claim = 104, @@ -117,7 +108,6 @@ impl From for InstructionType { 10 => InstructionType::CTokenFreezeAccount, 11 => InstructionType::CTokenThawAccount, 12 => InstructionType::CTokenTransferChecked, - 13 => InstructionType::CTokenApproveChecked, 14 => InstructionType::CTokenMintToChecked, 15 => InstructionType::CTokenBurnChecked, 18 => InstructionType::CreateTokenAccount, @@ -135,8 +125,6 @@ impl From for InstructionType { #[cfg(not(feature = "cpi"))] use pinocchio::program_entrypoint; -use crate::transfer2::processor::process_transfer2; - #[cfg(not(feature = "cpi"))] program_entrypoint!(process_instruction); @@ -174,10 +162,6 @@ pub fn process_instruction( msg!("CTokenBurn"); process_ctoken_burn(accounts, &instruction_data[1..])?; } - InstructionType::CTokenApproveChecked => { - msg!("CTokenApproveChecked"); - process_ctoken_approve_checked(accounts, &instruction_data[1..])?; - } InstructionType::CTokenMintToChecked => { msg!("CTokenMintToChecked"); process_ctoken_mint_to_checked(accounts, &instruction_data[1..])?; diff --git a/programs/compressed-token/program/src/mint_action/mint_output.rs b/programs/compressed-token/program/src/mint_action/mint_output.rs deleted file mode 100644 index d8cb7656d2..0000000000 --- a/programs/compressed-token/program/src/mint_action/mint_output.rs +++ /dev/null @@ -1,195 +0,0 @@ -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, -}; -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::{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, - validated_accounts: &MintActionAccounts, - output_compressed_accounts: &'a mut [ZOutputCompressedAccountWithPackedContextMut<'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); - - process_actions( - parsed_instruction_data, - validated_accounts, - &mut token_accounts.iter_mut(), - hash_cache, - queue_indices, - &validated_accounts.packed_accounts, - &mut compressed_mint, - )?; - // When decompressed (CMint is source of truth), use zero values - let cmint_is_source_of_truth = accounts_config.cmint_is_source_of_truth(); - // Serialize state into CMint solana account - // SKIP if CompressAndCloseCMint action is present (CMint is being closed) - // SKIP if DecompressMint action is present (CMint is being closed) - if cmint_is_source_of_truth { - let cmint_account = validated_accounts - .get_cmint() - .ok_or(ErrorCode::CMintNotFound)?; - if !accounts_config.has_compress_and_close_cmint_action { - 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)?; - - // Skip top-up calculation if decompress mint action is present. - if !accounts_config.has_decompress_mint_action { - // Handle top-up for compressed mint (compression info is now embedded directly) - // Get current slot for top-up calculation - let current_slot = Clock::get() - .map_err(|_| ProgramError::UnsupportedSysvar)? - .slot; - // Calculate top-up amount using embedded compression info - let top_up = compressed_mint - .compression - .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)?; - } - } - - 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); - } - } - - 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: {} != {}", - 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()); - ( - COMPRESSED_MINT_DISCRIMINATOR, - Sha256BE::hash(compressed_account_data.data)?, - ) - }; - - // Set mint output compressed account fields except the data. - mint_account.set( - crate::LIGHT_CPI_SIGNER.program_id.into(), - 0, - Some(parsed_instruction_data.compressed_address), - queue_indices.output_queue_index, - discriminator, - data_hash, - )?; - - Ok(()) -} - -#[inline(always)] -fn split_mint_and_token_accounts<'a>( - output_compressed_accounts: &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], -) -> ( - &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, - &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], -) { - if output_compressed_accounts.len() == 1 { - (&mut output_compressed_accounts[0], &mut []) - } else { - let (mint_account, token_accounts) = output_compressed_accounts.split_at_mut(1); - (&mut mint_account[0], token_accounts) - } -} diff --git a/programs/compressed-token/program/src/shared/accounts.rs b/programs/compressed-token/program/src/shared/accounts.rs index 14e2e2a8eb..4e4cb9334c 100644 --- a/programs/compressed-token/program/src/shared/accounts.rs +++ b/programs/compressed-token/program/src/shared/accounts.rs @@ -33,17 +33,17 @@ pub struct LightSystemAccounts<'info> { pub cpi_authority_pda: &'info AccountInfo, /// Registered program PDA (index 2) - non-mutable pub registered_program_pda: &'info AccountInfo, - /// Account compression authority (index 4) - non-mutable + /// Account compression authority (index 3) - non-mutable pub account_compression_authority: &'info AccountInfo, - /// Account compression program (index 5) - non-mutable + /// Account compression program (index 4) - non-mutable pub account_compression_program: &'info AccountInfo, - /// System program (index 9) - non-mutable + /// System program (index 5) - non-mutable pub system_program: &'info AccountInfo, - /// Sol pool PDA (index 7) - optional, mutable if present + /// Sol pool PDA (index 6) - optional, mutable if present pub sol_pool_pda: Option<&'info AccountInfo>, - /// SOL decompression recipient (index 8) - optional, mutable, for SOL decompression + /// SOL decompression recipient (index 7) - optional, mutable, for SOL decompression pub sol_decompression_recipient: Option<&'info AccountInfo>, - /// CPI context account (index 10) - optional, non-mutable + /// CPI context account (index 8) - optional, mutable pub cpi_context: Option<&'info AccountInfo>, } 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 43af640e6e..291a630908 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -1,12 +1,13 @@ use anchor_lang::solana_program::program_error::ProgramError; -use light_ctoken_interface::{ - state::{CToken, CompressedMint}, - CTokenError, +#[cfg(target_os = "solana")] +use light_ctoken_interface::state::{ + cmint_top_up_lamports_from_account_info, top_up_lamports_from_account_info_unchecked, }; +use light_ctoken_interface::CTokenError; use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, - sysvars::{clock::Clock, rent::Rent, Sysvar}, + sysvars::{clock::Clock, Sysvar}, }; use super::{ @@ -15,7 +16,7 @@ use super::{ }; /// 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. +/// CMint always has compression info. CToken requires Compressible extension or errors. /// /// # Arguments /// * `cmint` - The CMint account (may or may not have Compressible extension) @@ -24,10 +25,11 @@ use super::{ /// * `max_top_up` - Maximum lamports for top-ups combined (0 = no limit) #[inline(always)] #[profile] +#[allow(unused)] pub fn calculate_and_execute_compressible_top_ups<'a>( cmint: &'a AccountInfo, ctoken: &'a AccountInfo, - payer: &'a AccountInfo, + payer: Option<&'a AccountInfo>, max_top_up: u16, ) -> Result<(), ProgramError> { let mut transfers = [ @@ -42,64 +44,26 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( ]; let mut current_slot = 0; - let mut rent: Option = None; + // 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_checked(&cmint_data) - .map_err(|_| CTokenError::CMintDeserializationFailed)?; - // Access compression info directly from meta (all cmints now have compression embedded) - if current_slot == 0 { - current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - rent = Some(Rent::get().map_err(|_| CTokenError::SysvarAccessError)?); - } - let rent_exemption = rent.as_ref().unwrap().minimum_balance(cmint.data_len()); - transfers[0].amount = mint - .base - .compression - .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); + // Calculate CMint top-up using optimized function (owner check inside) + #[cfg(target_os = "solana")] + if let Some(amount) = cmint_top_up_lamports_from_account_info(cmint, &mut current_slot) { + transfers[0].amount = amount; + lamports_budget = lamports_budget.saturating_sub(amount); } - // Calculate CToken top-up (only if not 165 bytes - 165 means no extensions) - if ctoken.data_len() != 165 { - let account_data = ctoken.try_borrow_data().map_err(convert_program_error)?; - let (token, _) = CToken::zero_copy_at_checked(&account_data)?; - // Check for Compressible extension - let compressible = token - .get_compressible_extension() - .ok_or::(CTokenError::MissingCompressibleExtension.into())?; - if current_slot == 0 { - current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - rent = Some(Rent::get().map_err(|_| CTokenError::SysvarAccessError)?); - } - let rent_exemption = rent.as_ref().unwrap().minimum_balance(ctoken.data_len()); - transfers[1].amount = compressible - .info - .calculate_top_up_lamports( - ctoken.data_len() as u64, - current_slot, - ctoken.lamports(), - rent_exemption, - ) - .map_err(|_| CTokenError::InvalidAccountData)?; - lamports_budget = lamports_budget.saturating_sub(transfers[1].amount); + // Calculate CToken top-up using optimized function + // Returns None if no Compressible extension (165 bytes or missing extension) + #[cfg(target_os = "solana")] + if let Some(amount) = top_up_lamports_from_account_info_unchecked(ctoken, &mut current_slot) { + transfers[1].amount = amount; + lamports_budget = lamports_budget.saturating_sub(amount); } - // Exit early if no compressible accounts + // Exit early if no compressible accounts (current_slot remains 0 if no top-ups calculated) if current_slot == 0 { return Ok(()); } @@ -112,7 +76,40 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( if max_top_up != 0 && lamports_budget == 0 { return Err(CTokenError::MaxTopUpExceeded.into()); } - + let payer = payer.ok_or(CTokenError::MissingPayer)?; multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; Ok(()) } + +/// Process compression top-up using embedded compression info. +/// Uses stored rent_exemption_paid from CompressionInfo instead of querying Rent sysvar. +#[inline(always)] +pub fn process_compression_top_up( + compression: &light_compressible::compression_info::ZCompressionInfoMut<'_>, + account_info: &AccountInfo, + current_slot: &mut u64, + transfer_amount: &mut u64, + lamports_budget: &mut u64, +) -> Result<(), ProgramError> { + if *transfer_amount != 0 { + return Ok(()); + } + + if *current_slot == 0 { + *current_slot = Clock::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .slot; + } + + *transfer_amount = compression + .calculate_top_up_lamports( + account_info.data_len() as u64, + *current_slot, + account_info.lamports(), + ) + .map_err(|_| CTokenError::InvalidAccountData)?; + + *lamports_budget = lamports_budget.saturating_sub(*transfer_amount); + + Ok(()) +} diff --git a/programs/compressed-token/program/src/shared/config_account.rs b/programs/compressed-token/program/src/shared/config_account.rs new file mode 100644 index 0000000000..d29154d982 --- /dev/null +++ b/programs/compressed-token/program/src/shared/config_account.rs @@ -0,0 +1,44 @@ +use anchor_lang::{prelude::ProgramError, pubkey}; +use light_account_checks::{ + checks::{check_discriminator, check_owner}, + AccountIterator, +}; +use light_compressible::config::CompressibleConfig; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use spl_pod::{bytemuck, solana_msg::msg}; + +#[profile] +#[inline(always)] +pub fn parse_config_account( + config_account: &AccountInfo, +) -> Result<&CompressibleConfig, ProgramError> { + // Validate config account owner + check_owner( + &pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX").to_bytes(), + config_account, + )?; + // Parse config data + let data = unsafe { config_account.borrow_data_unchecked() }; + check_discriminator::(data)?; + let config = bytemuck::pod_from_bytes::(&data[8..]).map_err(|e| { + msg!("Failed to deserialize CompressibleConfig: {:?}", e); + ProgramError::InvalidAccountData + })?; + + Ok(config) +} + +#[profile] +#[inline(always)] +pub fn next_config_account<'info>( + iter: &mut AccountIterator<'info, AccountInfo>, +) -> Result<&'info CompressibleConfig, ProgramError> { + let config_account = iter.next_non_mut("compressible config")?; + let config = parse_config_account(config_account)?; + + // Validate config is active (only active allowed for account creation) + config.validate_active().map_err(ProgramError::from)?; + + Ok(config) +} diff --git a/programs/compressed-token/program/src/shared/convert_program_error.rs b/programs/compressed-token/program/src/shared/convert_program_error.rs index e04888d0c8..a440c81496 100644 --- a/programs/compressed-token/program/src/shared/convert_program_error.rs +++ b/programs/compressed-token/program/src/shared/convert_program_error.rs @@ -1,5 +1,55 @@ +use anchor_compressed_token::ErrorCode; +use pinocchio_token_program::error::TokenError; + +/// Convert generic pinocchio errors to anchor ProgramError with +6000 offset. +/// Use this for system program operations, data access, and non-token operations. pub fn convert_program_error( pinocchio_program_error: pinocchio::program_error::ProgramError, ) -> anchor_lang::prelude::ProgramError { anchor_lang::prelude::ProgramError::Custom(u64::from(pinocchio_program_error) as u32 + 6000) } + +/// Convert TokenError directly to anchor ProgramError. +/// Use for functions returning TokenError (e.g., unpack_amount_and_decimals). +pub fn convert_token_error(e: TokenError) -> anchor_lang::prelude::ProgramError { + convert_spl_token_error_code(e as u32) +} + +/// Convert pinocchio token processor errors to our custom ErrorCode. +/// Maps SPL Token error codes (0-18) to our enum variants for consistent error reporting. +/// +/// IMPORTANT: Only use this for pinocchio_token_program processor calls. +/// For system program and other operations, use `convert_program_error` instead. +pub fn convert_pinocchio_token_error( + pinocchio_error: pinocchio::program_error::ProgramError, +) -> anchor_lang::prelude::ProgramError { + convert_spl_token_error_code(u64::from(pinocchio_error) as u32) +} + +/// Internal: Map SPL Token error code (0-18) to ErrorCode. +fn convert_spl_token_error_code(code: u32) -> anchor_lang::prelude::ProgramError { + let error_code = match code { + 0 => ErrorCode::NotRentExempt, + 1 => ErrorCode::InsufficientFunds, + 2 => ErrorCode::InvalidMint, + 3 => ErrorCode::MintMismatch, + 4 => ErrorCode::OwnerMismatch, + 5 => ErrorCode::FixedSupply, + 6 => ErrorCode::AlreadyInUse, + 7 => ErrorCode::InvalidNumberOfProvidedSigners, + 8 => ErrorCode::InvalidNumberOfRequiredSigners, + 9 => ErrorCode::UninitializedState, + 10 => ErrorCode::NativeNotSupported, + 11 => ErrorCode::NonNativeHasBalance, + 12 => ErrorCode::InvalidInstruction, + 13 => ErrorCode::InvalidState, + 14 => ErrorCode::Overflow, + 15 => ErrorCode::AuthorityTypeNotSupported, + 16 => ErrorCode::MintHasNoFreezeAuthority, + 17 => ErrorCode::AccountFrozen, + 18 => ErrorCode::MintDecimalsMismatch, + // Pass through unknown/higher codes with standard +6900 offset + _ => return anchor_lang::prelude::ProgramError::Custom(code + 6900), + }; + error_code.into() +} diff --git a/programs/compressed-token/program/src/shared/cpi.rs b/programs/compressed-token/program/src/shared/cpi.rs index e7e407a463..d1efec5576 100644 --- a/programs/compressed-token/program/src/shared/cpi.rs +++ b/programs/compressed-token/program/src/shared/cpi.rs @@ -130,7 +130,7 @@ pub fn execute_cpi_invoke( Ok(()) } -/// Eqivalent to pinocchio::cpi::slice_invoke_signed except: +/// Equivalent to pinocchio::cpi::slice_invoke_signed except: /// 1. account_infos: &[&AccountInfo] -> &[AccountInfo] /// 2. Error prints #[inline] diff --git a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs index 78b079a6ac..2f10f2f5e0 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -5,17 +5,20 @@ use light_ctoken_interface::{ instructions::extensions::CompressibleExtensionInstructionData, state::{ ctoken::CompressedTokenConfig, AccountState, CToken, CompressibleExtensionConfig, - CompressionInfoConfig, ExtensionStructConfig, + CompressionInfoConfig, ExtensionStructConfig, ACCOUNT_TYPE_MINT, }, CTokenError, CTOKEN_PROGRAM_ID, }; use light_program_profiler::profile; use light_zero_copy::traits::ZeroCopyNew; #[cfg(target_os = "solana")] -use pinocchio::sysvars::{clock::Clock, Sysvar}; -use pinocchio::{account_info::AccountInfo, msg, pubkey::Pubkey}; +use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; +use pinocchio::{account_info::AccountInfo, instruction::Seed, msg, pubkey::Pubkey}; -use crate::extensions::MintExtensionFlags; +use crate::{ + extensions::MintExtensionFlags, + shared::{convert_program_error, create_pda_account, transfer_lamports_via_cpi}, +}; const SPL_TOKEN_ID: [u8; 32] = spl_token::ID.to_bytes(); const SPL_TOKEN_2022_ID: [u8; 32] = spl_token_2022::ID.to_bytes(); @@ -26,8 +29,6 @@ const SPL_MINT_LEN: usize = 82; /// Token-2022 pads mints to BASE_ACCOUNT_LENGTH (165 bytes) before AccountType /// Layout: 82 bytes mint data + 83 bytes padding + 1 byte AccountType const T22_ACCOUNT_TYPE_OFFSET: usize = 165; -/// AccountType::Mint discriminator value -const ACCOUNT_TYPE_MINT: u8 = 1; /// Configuration for compressible accounts pub struct CompressibleInitData<'a> { @@ -39,12 +40,12 @@ pub struct CompressibleInitData<'a> { pub custom_rent_payer: Option, /// Whether this account is an ATA (determined by instruction path, not ix data) pub is_ata: bool, + /// Rent exemption lamports paid at account creation (from Rent sysvar) + pub rent_exemption_paid: u32, } /// Configuration for initializing a CToken account pub struct CTokenInitConfig<'a> { - /// The mint pubkey (32 bytes) - pub mint: &'a [u8; 32], /// The owner pubkey (32 bytes) pub owner: &'a [u8; 32], /// Compressible configuration (None = not compressible) @@ -55,6 +56,98 @@ pub struct CTokenInitConfig<'a> { pub mint_account: &'a AccountInfo, } +#[profile] +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn create_compressible_account<'info>( + compressible_config: &'info CompressibleExtensionInstructionData, + mint_extensions: &MintExtensionFlags, + config_account: &'info CompressibleConfig, + rent_payer: &'info AccountInfo, + target_account: &'info AccountInfo, + fee_payer: &'info AccountInfo, + account_seeds: Option<&[Seed]>, + is_ata: bool, +) -> Result, ProgramError> { + // Validate rent_payment != 1 (epoch boundary edge case) + if compressible_config.rent_payment == 1 { + msg!("Prefunding for exactly 1 epoch is not allowed. If the account is created near an epoch boundary, it could become immediately compressible. Use 0 or 2+ epochs."); + return Err(anchor_compressed_token::ErrorCode::OneEpochPrefundingNotAllowed.into()); + } + + // Calculate account size (includes Compressible extension) + let account_size = mint_extensions.calculate_account_size(true)?; + + // Get rent exemption from Rent sysvar (only place we query it - store for later use) + #[cfg(target_os = "solana")] + let rent_exemption_paid: u32 = Rent::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .minimum_balance(account_size as usize) + .try_into() + .map_err(|_| ProgramError::ArithmeticOverflow)?; + #[cfg(not(target_os = "solana"))] + let rent_exemption_paid = 0; + + // Calculate rent with compression cost + let rent = config_account + .rent_config + .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); + let account_size = account_size as usize; + + let custom_rent_payer = *rent_payer.key() != config_account.rent_sponsor.to_bytes(); + + // Custom rent payer must be a signer (prevents executable accounts as rent_sponsor) + if custom_rent_payer && !rent_payer.is_signer() { + msg!("Custom rent payer must be a signer"); + return Err(ProgramError::MissingRequiredSignature); + } + + // Build rent sponsor seeds for PDA signing + let version_bytes = config_account.version.to_le_bytes(); + let bump_seed = [config_account.rent_sponsor_bump]; + let rent_sponsor_seeds = [ + Seed::from(b"rent_sponsor".as_ref()), + Seed::from(version_bytes.as_ref()), + Seed::from(bump_seed.as_ref()), + ]; + + let fee_payer_seeds = if custom_rent_payer { + None + } else { + Some(rent_sponsor_seeds.as_slice()) + }; + + let additional_lamports = if custom_rent_payer { Some(rent) } else { None }; + + // Create the account + create_pda_account( + rent_payer, + target_account, + account_size, + fee_payer_seeds, + account_seeds, + additional_lamports, + )?; + + // When using protocol rent sponsor, fee_payer pays the compression incentive + if !custom_rent_payer { + transfer_lamports_via_cpi(rent, fee_payer, target_account) + .map_err(convert_program_error)?; + } + + Ok(CompressibleInitData { + ix_data: compressible_config, + config_account, + custom_rent_payer: if custom_rent_payer { + Some(*rent_payer.key()) + } else { + None + }, + is_ata, + rent_exemption_paid, + }) +} + /// Initialize a token account using zero-copy with embedded CompressionInfo #[profile] pub fn initialize_ctoken_account( @@ -62,7 +155,6 @@ pub fn initialize_ctoken_account( config: CTokenInitConfig<'_>, ) -> Result<(), ProgramError> { let CTokenInitConfig { - mint, owner, compressible, mint_extensions, @@ -71,20 +163,7 @@ pub fn initialize_ctoken_account( // Build extensions Vec from boolean flags // +1 for potential Compressible extension - let mut extensions = Vec::with_capacity(mint_extensions.num_extensions() + 1); - if mint_extensions.has_pausable { - extensions.push(ExtensionStructConfig::PausableAccount(())); - } - if mint_extensions.has_permanent_delegate { - extensions.push(ExtensionStructConfig::PermanentDelegateAccount(())); - } - if mint_extensions.has_transfer_fee { - extensions.push(ExtensionStructConfig::TransferFeeAccount(())); - } - if mint_extensions.has_transfer_hook { - extensions.push(ExtensionStructConfig::TransferHookAccount(())); - } - + let mut extensions = Vec::with_capacity(mint_extensions.num_token_account_extensions() + 1); // Add Compressible extension if compression is enabled if compressible.is_some() { extensions.push(ExtensionStructConfig::Compressible( @@ -92,11 +171,26 @@ pub fn initialize_ctoken_account( info: CompressionInfoConfig { rent_config: () }, }, )); - } + if mint_extensions.has_pausable { + extensions.push(ExtensionStructConfig::PausableAccount(())); + } + if mint_extensions.has_permanent_delegate { + extensions.push(ExtensionStructConfig::PermanentDelegateAccount(())); + } + if mint_extensions.has_transfer_fee { + extensions.push(ExtensionStructConfig::TransferFeeAccount(())); + } + if mint_extensions.has_transfer_hook { + extensions.push(ExtensionStructConfig::TransferHookAccount(())); + } + } else if mint_extensions.has_restricted_extensions() { + // Mints with restricted extensions must have the compressible extension. + return Err(anchor_compressed_token::ErrorCode::MissingCompressibleConfig.into()); + } // Build the config for new_zero_copy let zc_config = CompressedTokenConfig { - mint: light_compressed_account::Pubkey::from(*mint), + mint: light_compressed_account::Pubkey::from(*mint_account.key()), owner: light_compressed_account::Pubkey::from(*owner), state: if mint_extensions.default_state_frozen { AccountState::Frozen as u8 @@ -114,7 +208,7 @@ pub fn initialize_ctoken_account( let mut token_account_data = AccountInfoTrait::try_borrow_mut_data(token_account_info)?; // Use new_zero_copy to initialize the token account - // This sets mint, owner, state, compression_only, account_type, and extensions + // This sets mint, owner, state, account_type, and extensions let (mut ctoken, _) = CToken::new_zero_copy(&mut token_account_data, zc_config).map_err(|e| { msg!("Failed to initialize CToken: {:?}", e); @@ -143,6 +237,7 @@ fn configure_compression_info( config_account, custom_rent_payer, is_ata, + rent_exemption_paid, } = compressible; // Get the Compressible extension (must exist since we added it) @@ -173,6 +268,9 @@ fn configure_compression_info( config_account.rent_config.max_funded_epochs; compressible_ext.info.rent_config.max_top_up = config_account.rent_config.max_top_up.into(); + // Set rent exemption paid at account creation (store once, never query Rent sysvar again) + compressible_ext.info.rent_exemption_paid = rent_exemption_paid.into(); + // Set the compression_authority, rent_sponsor and lamports_per_write compressible_ext.info.compression_authority = config_account.compression_authority.to_bytes(); if let Some(custom_rent_payer) = custom_rent_payer { @@ -219,31 +317,37 @@ fn configure_compression_info( if !mint_data.is_empty() { let owner = mint_account.owner(); - // Validate mint account based on owner program - let is_valid_mint = if *owner == SPL_TOKEN_ID { - // SPL Token: mint must be exactly 82 bytes - mint_data.len() == SPL_MINT_LEN - } else if *owner == SPL_TOKEN_2022_ID || *owner == CTOKEN_PROGRAM_ID { - // Token-2022/CToken: Either exactly 82 bytes (no extensions) or - // check AccountType marker at offset 165 (with extensions) - // Layout with extensions: 82 bytes mint + 83 bytes padding + AccountType - mint_data.len() == SPL_MINT_LEN - || (mint_data.len() > T22_ACCOUNT_TYPE_OFFSET - && mint_data[T22_ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT) - } else { - msg!("Invalid mint owner"); - return Err(ProgramError::IncorrectProgramId); - }; - - if !is_valid_mint { + if !is_valid_mint(owner, &mint_data)? { msg!("Invalid mint account: not a valid mint"); return Err(ProgramError::InvalidAccountData); } // Mint layout: decimals at byte 44 for all token programs // (mint_authority option: 36, supply: 8) = 44 - compressible_ext.set_decimals(Some(mint_data[44])); + compressible_ext.set_decimals(mint_data.get(44).copied()); } Ok(()) } + +#[inline(always)] +pub fn is_valid_mint(owner: &Pubkey, mint_data: &[u8]) -> Result { + if *owner == SPL_TOKEN_ID { + // SPL Token: mint must be exactly 82 bytes + Ok(mint_data.len() == SPL_MINT_LEN) + } else if *owner == SPL_TOKEN_2022_ID { + // Token-2022: Either exactly 82 bytes (no extensions) or + // check AccountType marker at offset 165 (with extensions) + // Layout with extensions: 82 bytes mint + 83 bytes padding + AccountType + Ok(mint_data.len() == SPL_MINT_LEN + || (mint_data.len() > T22_ACCOUNT_TYPE_OFFSET + && mint_data[T22_ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT)) + } else if *owner == CTOKEN_PROGRAM_ID { + // CToken: Always has extensions, must be >165 bytes with AccountType=Mint + Ok(mint_data.len() > T22_ACCOUNT_TYPE_OFFSET + && mint_data[T22_ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT) + } else { + msg!("Invalid mint owner"); + Err(ProgramError::IncorrectProgramId) + } +} diff --git a/programs/compressed-token/program/src/shared/mint_to_token_pool.rs b/programs/compressed-token/program/src/shared/mint_to_token_pool.rs index 82a7b666bf..08cb00f873 100644 --- a/programs/compressed-token/program/src/shared/mint_to_token_pool.rs +++ b/programs/compressed-token/program/src/shared/mint_to_token_pool.rs @@ -7,10 +7,10 @@ use pinocchio::{ program::invoke_signed, }; -use crate::LIGHT_CPI_SIGNER; +use crate::{shared::convert_program_error, LIGHT_CPI_SIGNER}; /// Mint tokens to the token pool using SPL token mint_to instruction. -/// This function is shared between create_spl_mint and mint_to_compressed processors +/// This function is used by mint_to_compressed processors /// to ensure consistent token pool management. #[profile] pub fn mint_to_token_pool( @@ -55,5 +55,5 @@ pub fn mint_to_token_pool( &[mint_account, token_pool_account, cpi_authority_pda], &[signer], ) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000)) + .map_err(convert_program_error) } diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index 99368379a9..3213704178 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod compressible_top_up; +pub mod config_account; mod convert_program_error; pub mod cpi; pub mod cpi_bytes_size; @@ -12,8 +13,12 @@ pub mod token_output; pub mod transfer_lamports; pub mod validate_ata_derivation; -pub use convert_program_error::convert_program_error; +pub use config_account::{next_config_account, parse_config_account}; +pub use convert_program_error::{ + convert_pinocchio_token_error, convert_program_error, convert_token_error, +}; pub use create_pda_account::{create_pda_account, verify_pda}; +pub use initialize_ctoken_account::create_compressible_account; pub use light_account_checks::AccountIterator; pub use mint_to_token_pool::mint_to_token_pool; pub use transfer_lamports::*; diff --git a/programs/compressed-token/program/src/shared/owner_validation.rs b/programs/compressed-token/program/src/shared/owner_validation.rs index e983743187..2903f8a9f1 100644 --- a/programs/compressed-token/program/src/shared/owner_validation.rs +++ b/programs/compressed-token/program/src/shared/owner_validation.rs @@ -103,11 +103,11 @@ pub fn check_ctoken_owner( if let Some(checks) = mint_checks { if let Some(permanent_delegate) = &checks.permanent_delegate { if pubkey_eq(authority_key, permanent_delegate) { - return Ok(()); // Permanent delegate can compress any account of this mint + return Ok(()); // Permanent delegate can (de)compress any account of this mint } } } - // Authority is neither owner, account delegate, nor permanent delegate + // Authority is neither owner nor permanent delegate Err(ErrorCode::OwnerMismatch.into()) } diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index 4cba7ec723..3b2607d485 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -12,12 +12,13 @@ use light_ctoken_interface::{ state::{ CompressedOnlyExtension, CompressedTokenAccountState, ExtensionStruct, TokenDataVersion, }, + CTokenError, }; use pinocchio::account_info::AccountInfo; use crate::{ + compressed_token::transfer2::check_extensions::MintExtensionCache, shared::owner_validation::verify_owner_or_delegate_signer, - transfer2::check_extensions::MintExtensionCache, }; /// Creates an input compressed account using zero-copy patterns and index-based account lookup. @@ -78,26 +79,13 @@ pub fn set_input_compressed_account<'a>( // For ATA decompress (is_ata=true), verify the wallet owner from owner_index instead // of the compressed account owner (which is the ATA pubkey that can't sign). + // Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump. let signer_account = if let Some(exts) = tlv_data { - exts.iter() - .find_map(|ext| { - if let ZExtensionInstructionData::CompressedOnly(data) = ext { - if data.is_ata != 0 { - // Get wallet owner from owner_index - packed_accounts.get(data.owner_index as usize) - } else { - None - } - } else { - None - } - }) - .unwrap_or(owner_account) + resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)? } else { owner_account }; - // TODO: allow freeze authority to decompress if has CompressOnlyExtension verify_owner_or_delegate_signer( signer_account, delegate_account, @@ -115,30 +103,10 @@ pub fn set_input_compressed_account<'a>( CompressedTokenAccountState::Initialized as u8 }; // Convert instruction TLV data to state TLV - let tlv: Option> = match tlv_data { - Some(exts) => { - let mut result = Vec::with_capacity(exts.len()); - for ext in exts.iter() { - match ext { - ZExtensionInstructionData::CompressedOnly(data) => { - result.push(ExtensionStruct::CompressedOnly( - CompressedOnlyExtension { - delegated_amount: data.delegated_amount.into(), - withheld_transfer_fee: data - .withheld_transfer_fee - .into(), - is_ata: if data.is_ata() { 1 } else { 0 }, - }, - )); - } - _ => { - return Err(ErrorCode::UnsupportedTlvExtensionType.into()); - } - } - } - Some(result) - } - None => None, + let tlv: Option> = if let Some(exts) = tlv_data { + Some(convert_tlv_to_extension_structs(exts)?) + } else { + None }; let token_data = TokenData { mint: mint_account.key().into(), @@ -190,6 +158,85 @@ pub fn set_input_compressed_account<'a>( Ok(()) } +/// Convert instruction TLV data to state TLV extension structs for hashing. +#[cold] +fn convert_tlv_to_extension_structs( + exts: &[ZExtensionInstructionData], +) -> Result, ProgramError> { + let mut result = Vec::with_capacity(exts.len()); + for ext in exts.iter() { + match ext { + ZExtensionInstructionData::CompressedOnly(data) => { + result.push(ExtensionStruct::CompressedOnly(CompressedOnlyExtension { + delegated_amount: data.delegated_amount.into(), + withheld_transfer_fee: data.withheld_transfer_fee.into(), + is_ata: if data.is_ata() { 1 } else { 0 }, + })); + } + _ => { + return Err(ErrorCode::UnsupportedTlvExtensionType.into()); + } + } + } + Ok(result) +} + +/// Resolve the signer account for ATA decompress operations. +/// +/// For non-ATA tokens: returns owner_account (the compressed token owner) +/// For ATA tokens (is_ata=true): validates ATA derivation and returns wallet_owner +/// +/// Returns explicit error if ATA derivation fails or mismatches. +#[cold] +fn resolve_ata_signer<'a>( + exts: &[ZExtensionInstructionData], + packed_accounts: &'a [AccountInfo], + mint_account: &AccountInfo, + owner_account: &'a AccountInfo, +) -> Result<&'a AccountInfo, ProgramError> { + for ext in exts.iter() { + if let ZExtensionInstructionData::CompressedOnly(data) = ext { + if data.is_ata() { + // Get wallet owner from owner_index + let wallet_owner = + packed_accounts + .get(data.owner_index as usize) + .ok_or_else(|| { + print_on_error_pubkey( + data.owner_index, + "wallet_owner", + Location::caller(), + ); + ProgramError::Custom(AccountError::NotEnoughAccountKeys.into()) + })?; + + // Derive ATA and verify owner_account matches + let bump_seed = [data.bump]; + let ata_seeds: [&[u8]; 4] = [ + wallet_owner.key().as_ref(), + crate::LIGHT_CPI_SIGNER.program_id.as_ref(), + mint_account.key().as_ref(), + bump_seed.as_ref(), + ]; + let derived_ata = pinocchio::pubkey::create_program_address( + &ata_seeds, + &crate::LIGHT_CPI_SIGNER.program_id, + ) + .map_err(|_| CTokenError::InvalidAtaDerivation)?; + + // owner_account.key() IS the ATA - verify it matches derived + if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) { + return Err(CTokenError::InvalidAtaDerivation.into()); + } + + return Ok(wallet_owner); + } + } + } + + Ok(owner_account) +} + #[cold] fn print_on_error_pubkey(index: u8, account_name: &str, location: &Location) { anchor_lang::prelude::msg!( diff --git a/programs/compressed-token/program/src/shared/transfer_lamports.rs b/programs/compressed-token/program/src/shared/transfer_lamports.rs index f80cd0dbab..f660754da2 100644 --- a/programs/compressed-token/program/src/shared/transfer_lamports.rs +++ b/programs/compressed-token/program/src/shared/transfer_lamports.rs @@ -38,7 +38,7 @@ pub fn transfer_lamports( /// Transfer lamports using CPI to system program /// This is needed when transferring from accounts not owned by our program -#[inline(always)] +#[cold] #[profile] pub fn transfer_lamports_via_cpi( amount: u64, diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs deleted file mode 100644 index 62575eed0b..0000000000 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs +++ /dev/null @@ -1,348 +0,0 @@ -use anchor_compressed_token::ErrorCode; -use anchor_lang::prelude::ProgramError; -use bitvec::prelude::*; -use light_account_checks::{checks::check_signer, packed_accounts::ProgramPackedAccounts}; -use light_ctoken_interface::{ - instructions::{ - extensions::ZExtensionInstructionData, - transfer2::{ZCompression, ZCompressionMode, ZMultiTokenTransferOutputData}, - }, - state::{ZCTokenMut, ZExtensionStructMut}, - CTokenError, -}; -use light_program_profiler::profile; -use pinocchio::{ - account_info::AccountInfo, - pubkey::{pubkey_eq, Pubkey}, -}; -use spl_pod::solana_msg::msg; - -use super::inputs::CompressAndCloseInputs; -use crate::{ - close_token_account::{ - accounts::CloseTokenAccountAccounts, processor::validate_token_account_for_close_transfer2, - }, - transfer2::accounts::Transfer2Accounts, -}; - -/// Process compress and close operation for a ctoken account. -#[profile] -pub fn process_compress_and_close( - authority: Option<&AccountInfo>, - compress_and_close_inputs: Option, - amount: u64, - token_account_info: &AccountInfo, - ctoken: &mut ZCTokenMut, - packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, -) -> Result<(), ProgramError> { - let authority = authority.ok_or(ErrorCode::CompressAndCloseAuthorityMissing)?; - check_signer(authority).map_err(|e| { - anchor_lang::solana_program::msg!("Authority signer check failed: {:?}", e); - ProgramError::from(e) - })?; - - let close_inputs = - compress_and_close_inputs.ok_or(ErrorCode::CompressAndCloseDestinationMissing)?; - - // Validate token account - only compressible accounts with compression_authority are allowed - let compress_to_pubkey = validate_token_account_for_close_transfer2( - &CloseTokenAccountAccounts { - token_account: token_account_info, - destination: close_inputs.destination, - authority, - rent_sponsor: Some(close_inputs.rent_sponsor), - }, - ctoken, - )?; - - // Validate compressed output matches the account being closed - let compressed_account = close_inputs - .compressed_token_account - .ok_or(ErrorCode::CompressAndCloseOutputMissing)?; - validate_compressed_token_account( - packed_accounts, - amount, - compressed_account, - ctoken, - compress_to_pubkey, - token_account_info.key(), - close_inputs.tlv, - )?; - - ctoken.base.amount.set(0); - // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) - // This allows the close_token_account validation to pass for frozen accounts - ctoken.base.set_initialized(); - Ok(()) -} - -/// Validate compressed token account for compress and close operation -fn validate_compressed_token_account( - packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, - compression_amount: u64, - compressed_token_account: &ZMultiTokenTransferOutputData<'_>, - ctoken: &ZCTokenMut, - compress_to_pubkey: bool, - token_account_pubkey: &Pubkey, - out_tlv: Option<&[ZExtensionInstructionData<'_>]>, -) -> Result<(), ProgramError> { - // Owners should match if not compressing to pubkey - if compress_to_pubkey { - // Owner should match token account pubkey if compressing to pubkey - if *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - != *token_account_pubkey - { - msg!( - "compress_to_pubkey: packed_accounts owner {:?} should match token_account_pubkey: {:?}", - solana_pubkey::Pubkey::new_from_array( - *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - ), - solana_pubkey::Pubkey::new_from_array(*token_account_pubkey) - ); - return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); - } - } else if ctoken.owner.to_bytes() - != *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - { - msg!( - "*ctoken.owner {:?} packed_accounts owner: {:?}", - solana_pubkey::Pubkey::new_from_array(ctoken.owner.to_bytes()), - solana_pubkey::Pubkey::new_from_array( - *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - ) - ); - return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); - } - - // Compression amount must match the output amount - if compression_amount != compressed_token_account.amount.get() { - msg!( - "compression_amount {} != compressed token account amount {}", - compression_amount, - compressed_token_account.amount.get() - ); - return Err(ErrorCode::CompressAndCloseAmountMismatch.into()); - } - // Token balance must match the compressed output amount - if ctoken.amount.get() != compressed_token_account.amount.get() { - msg!( - "output ctoken.amount {} != compressed token account amount {}", - ctoken.amount.get(), - compressed_token_account.amount.get() - ); - return Err(ErrorCode::CompressAndCloseBalanceMismatch.into()); - } - - // Mint must match - let output_mint = packed_accounts - .get_u8(compressed_token_account.mint, "CompressAndClose: mint")? - .key(); - if *output_mint != ctoken.mint.to_bytes() { - msg!( - "mint mismatch: ctoken {:?} != output {:?}", - solana_pubkey::Pubkey::new_from_array(ctoken.mint.to_bytes()), - solana_pubkey::Pubkey::new_from_array(*output_mint) - ); - return Err(ErrorCode::CompressAndCloseInvalidMint.into()); - } - - // Version should be ShaFlat - if compressed_token_account.version != 3 { - return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); - } - let compression = ctoken - .get_compressible_extension() - .ok_or::(CTokenError::MissingCompressibleExtension.into())?; - // Version should also match what's specified in the embedded compression info - let expected_version = compression.info.account_version; - let compression_only = compression.compression_only(); - let is_ata = compression.is_ata != 0; - - if compressed_token_account.version != expected_version { - return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); - } - let compression_only_extension = out_tlv.as_ref().and_then(|ext| { - ext.iter() - .find(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) - }); - - // CompressedOnly extension is required for: - // - compression_only accounts (cannot decompress to SPL) - // - ATA accounts (need is_ata flag for proper decompress authorization) - if (compression_only || is_ata) && compression_only_extension.is_none() { - return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); - } - - if let Some(ZExtensionInstructionData::CompressedOnly(compression_only_extension)) = - compression_only_extension - { - // Note: is_ata validation happens during decompress, not compress_and_close. - // During compress_and_close we just store the is_ata flag from the Compressible extension. - // The decompress instruction validates the ATA derivation using the stored is_ata and bump. - - // Delegated amounts must match - if u64::from(compression_only_extension.delegated_amount) != ctoken.delegated_amount.get() { - msg!( - "delegated_amount mismatch: ctoken {} != extension {}", - ctoken.delegated_amount.get(), - u64::from(compression_only_extension.delegated_amount) - ); - return Err(ErrorCode::CompressAndCloseDelegatedAmountMismatch.into()); - } - // Delegate must be preserved for exact state restoration during decompress - if ctoken.delegate().is_some() || compression_only_extension.delegated_amount != 0 { - let delegate = ctoken - .delegate() - .ok_or(ErrorCode::CompressAndCloseInvalidDelegate)?; - if !compressed_token_account.has_delegate() { - msg!("ctoken has delegate but compressed token output does not"); - return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); - } - let token_data_delegate = packed_accounts.get_u8( - compressed_token_account.delegate, - "compressed_token_account delegate", - )?; - if !pubkey_eq(token_data_delegate.key(), &delegate.to_bytes()) { - msg!( - "delegate mismatch: ctoken {:?} != output {:?}", - solana_pubkey::Pubkey::new_from_array(delegate.to_bytes()), - solana_pubkey::Pubkey::new_from_array(*token_data_delegate.key()) - ); - return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); - } - } - // if ctoken has fee extension withheld amount must match - let ctoken_withheld_fee = ctoken.extensions.as_ref().and_then(|exts| { - exts.iter().find_map(|ext| { - if let ZExtensionStructMut::TransferFeeAccount(fee_ext) = ext { - Some(fee_ext.withheld_amount) - } else { - None - } - }) - }); - - if let Some(withheld_fee) = ctoken_withheld_fee { - if compression_only_extension.withheld_transfer_fee != withheld_fee { - msg!( - "withheld_transfer_fee mismatch: ctoken {} != extension {}", - withheld_fee, - u64::from(compression_only_extension.withheld_transfer_fee) - ); - return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); - } - } else if u64::from(compression_only_extension.withheld_transfer_fee) != 0 { - msg!( - "withheld_transfer_fee must be 0 when ctoken has no fee extension, got {}", - u64::from(compression_only_extension.withheld_transfer_fee) - ); - return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); - } - - // Frozen state must match between CToken and extension data - // AccountState::Frozen = 2 in CToken - // ZeroCopy converts bool to u8: 0 = false, non-zero = true - let ctoken_is_frozen = ctoken.state == 2; - let extension_is_frozen = compression_only_extension.is_frozen != 0; - if extension_is_frozen != ctoken_is_frozen { - msg!( - "is_frozen mismatch: ctoken {} != extension {}", - ctoken_is_frozen, - compression_only_extension.is_frozen - ); - return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); - } - } else { - // Frozen accounts require CompressedOnly extension to preserve frozen state - // AccountState::Frozen = 2 in CToken - let ctoken_is_frozen = ctoken.state == 2; - if ctoken_is_frozen { - msg!("Frozen account requires CompressedOnly extension with is_frozen=true"); - return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); - } - - // Source token account must not have a delegate - // Compressed tokens don't support delegation, so we reject accounts with delegates - if ctoken.delegate().is_some() { - msg!("Source token account has delegate, cannot compress and close"); - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } - - // Delegate should be None - if compressed_token_account.has_delegate() { - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } - if compressed_token_account.delegate != 0 { - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } - } - - Ok(()) -} - -/// Close ctoken accounts after compress and close operations -pub fn close_for_compress_and_close( - compressions: &[ZCompression<'_>], - _validated_accounts: &Transfer2Accounts, -) -> Result<(), ProgramError> { - // Track used compressed account indices for CompressAndClose to prevent duplicate outputs - let mut used_compressed_account_indices = [0u8; 32]; // 256 bits - let used_bits = used_compressed_account_indices.view_bits_mut::(); - - for compression in compressions - .iter() - .filter(|c| c.mode == ZCompressionMode::CompressAndClose) - { - // Check for duplicate compressed account indices in CompressAndClose operations - let compressed_idx = compression.get_compressed_token_account_index()?; - if let Some(mut bit) = used_bits.get_mut(compressed_idx as usize) { - if *bit { - msg!( - "Duplicate compressed account index {} in CompressAndClose operations", - compressed_idx - ); - return Err(ErrorCode::CompressAndCloseDuplicateOutput.into()); - } - *bit = true; - } else { - msg!("Compressed account index {} out of bounds", compressed_idx); - return Err(ProgramError::InvalidInstructionData); - } - - #[cfg(target_os = "solana")] - { - let validated_accounts = _validated_accounts; - let token_account_info = validated_accounts.packed_accounts.get_u8( - compression.source_or_recipient, - "CompressAndClose: source_or_recipient", - )?; - let destination = validated_accounts.packed_accounts.get_u8( - compression.get_destination_index()?, - "CompressAndClose: destination", - )?; - let rent_sponsor = validated_accounts.packed_accounts.get_u8( - compression.get_rent_sponsor_index()?, - "CompressAndClose: rent_sponsor", - )?; - let authority = validated_accounts - .packed_accounts - .get_u8(compression.authority, "CompressAndClose: authority")?; - use crate::close_token_account::processor::close_token_account; - close_token_account(&CloseTokenAccountAccounts { - token_account: token_account_info, - destination, - authority, - rent_sponsor: Some(rent_sponsor), - })?; - } - } - Ok(()) -} diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs deleted file mode 100644 index 19c2e25b8c..0000000000 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs +++ /dev/null @@ -1,208 +0,0 @@ -use anchor_lang::prelude::ProgramError; -use light_compressed_account::Pubkey; -use light_ctoken_interface::{ - instructions::extensions::{ - compressed_only::ZCompressedOnlyExtensionInstructionData, ZExtensionInstructionData, - }, - state::{ZCTokenMut, ZExtensionStructMut}, - CTokenError, -}; -use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; -use spl_pod::solana_msg::msg; - -use super::inputs::DecompressCompressOnlyInputs; - -/// Validates that the destination CToken matches the source account for ATA decompress. -/// For ATA decompress (is_ata=true), verifies the destination is the correct ATA. -/// For non-ATA decompress, just validates owner matches. -/// -/// # Arguments -/// * `ctoken` - Destination CToken account -/// * `destination_account` - Destination account info -/// * `input_owner` - Compressed account owner (ATA pubkey for is_ata) -/// * `wallet_owner` - Wallet owner who signs (from owner_index, only for is_ata) -/// * `ext_data` - CompressedOnly extension data -#[inline(always)] -fn validate_decompression_destination( - ctoken: &ZCTokenMut, - destination_account: &AccountInfo, - input_owner: &Pubkey, - wallet_owner: Option<&AccountInfo>, - ext_data: &ZCompressedOnlyExtensionInstructionData, -) -> Result<(), ProgramError> { - // Owner must match (for non-ATA) or ATA must be correctly derived (for ATA) - if ext_data.is_ata != 0 { - // For ATA decompress, we need the wallet_owner - let wallet_owner = wallet_owner.ok_or_else(|| { - msg!("ATA decompress requires wallet_owner from owner_index"); - CTokenError::DecompressDestinationMismatch - })?; - - // Wallet owner must be a signer - if !wallet_owner.is_signer() { - msg!("Wallet owner must be signer for ATA decompress"); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // For ATA decompress, verify the destination is the correct ATA - // by deriving the ATA address from wallet_owner and comparing - let wallet_owner_bytes = wallet_owner.key(); - let mint_pubkey = ctoken.base.mint.to_bytes(); - let bump = ext_data.bump; - - // ATA seeds: [wallet_owner, program_id, mint, bump] - let bump_seed = [bump]; - let ata_seeds: [&[u8]; 4] = [ - wallet_owner_bytes.as_ref(), - crate::LIGHT_CPI_SIGNER.program_id.as_ref(), - mint_pubkey.as_ref(), - bump_seed.as_ref(), - ]; - - // Derive ATA address and verify it matches the destination - let derived_ata = pinocchio::pubkey::create_program_address( - &ata_seeds, - &crate::LIGHT_CPI_SIGNER.program_id, - ) - .map_err(|_| { - msg!("Failed to derive ATA address for decompress"); - ProgramError::InvalidSeeds - })?; - - // Verify derived ATA matches destination account pubkey - if !pubkey_eq(&derived_ata, destination_account.key()) { - msg!( - "Decompress ATA mismatch: derived {:?} != destination {:?}", - solana_pubkey::Pubkey::new_from_array(derived_ata), - solana_pubkey::Pubkey::new_from_array(*destination_account.key()) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // Verify the compressed account's owner (input_owner) matches the derived ATA - // This proves the compressed account belongs to this ATA - let input_owner_bytes = input_owner.to_bytes(); - if !pubkey_eq(&input_owner_bytes, &derived_ata) { - msg!( - "Decompress ATA: compressed owner {:?} != derived ATA {:?}", - solana_pubkey::Pubkey::new_from_array(input_owner_bytes), - solana_pubkey::Pubkey::new_from_array(derived_ata) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // Also verify destination CToken owner matches wallet_owner - // (destination should be wallet's ATA, owned by wallet) - if !pubkey_eq(wallet_owner_bytes, &ctoken.base.owner.to_bytes()) { - msg!( - "Decompress ATA: wallet owner {:?} != destination owner {:?}", - solana_pubkey::Pubkey::new_from_array(*wallet_owner_bytes), - solana_pubkey::Pubkey::new_from_array(ctoken.base.owner.to_bytes()) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - } else { - // For non-ATA decompress, owner must match - if ctoken.base.owner.to_bytes() != input_owner.to_bytes() { - msg!("Decompress destination owner mismatch"); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - } - - Ok(()) -} - -/// Apply extension state from the input compressed account during decompress. -/// This transfers delegate, delegated_amount, and withheld_transfer_fee from -/// the compressed account's CompressedOnly extension to the CToken account. -/// -/// For ATA decompress with is_ata=true, validates the destination matches the -/// derived ATA address. Existing delegate/amount on destination is preserved -/// and added to rather than overwritten. -#[inline(always)] -pub fn apply_decompress_extension_state( - ctoken: &mut ZCTokenMut, - destination_account: &AccountInfo, - decompress_inputs: Option, -) -> Result<(), ProgramError> { - // If no decompress inputs, nothing to transfer - let Some(inputs) = decompress_inputs else { - return Ok(()); - }; - - // Extract CompressedOnly extension data from input TLV - let compressed_only_data = inputs.tlv.iter().find_map(|ext| { - if let ZExtensionInstructionData::CompressedOnly(data) = ext { - Some(data) - } else { - None - } - }); - - // If no CompressedOnly extension, nothing to transfer - let Some(ext_data) = compressed_only_data else { - return Ok(()); - }; - - // Validate destination matches expected (ATA derivation or owner match) - validate_decompression_destination( - ctoken, - destination_account, - &Pubkey::from(*inputs.owner.key()), - inputs.wallet_owner, - ext_data, - )?; - - let delegated_amount: u64 = ext_data.delegated_amount.into(); - let withheld_transfer_fee: u64 = ext_data.withheld_transfer_fee.into(); - - // Handle delegate and delegated_amount - // If destination already has delegate, skip delegate AND delegated_amount restoration (preserve existing) - if delegated_amount > 0 || inputs.delegate.is_some() { - let input_delegate_pubkey = inputs.delegate.map(|acc| Pubkey::from(*acc.key())); - - // Only set delegate and delegated_amount if destination doesn't already have one - if ctoken.delegate().is_none() { - if let Some(input_del) = input_delegate_pubkey { - ctoken.base.set_delegate(Some(input_del))?; - } else if delegated_amount > 0 { - // Has delegated_amount but no delegate pubkey - invalid state - msg!("Decompress: delegated_amount > 0 but no delegate pubkey provided"); - return Err(CTokenError::DecompressDelegatedAmountWithoutDelegate.into()); - } - - // Add delegated_amount (only when we're setting the delegate) - if delegated_amount > 0 { - let current = ctoken.base.delegated_amount.get(); - ctoken.base.delegated_amount.set(current + delegated_amount); - } - } - } - - // Handle withheld_transfer_fee (always add, not overwrite) - // Defensive: ensures compress/decompress always works for ctoken accounts. - // It should not be possible to set withheld_transfer_fee to non-zero. - if withheld_transfer_fee > 0 { - let mut fee_applied = false; - if let Some(extensions) = ctoken.extensions.as_deref_mut() { - for extension in extensions.iter_mut() { - if let ZExtensionStructMut::TransferFeeAccount(ref mut fee_ext) = extension { - fee_ext.add_withheld_amount(withheld_transfer_fee)?; - fee_applied = true; - break; - } - } - } - if !fee_applied { - msg!("Decompress: withheld_transfer_fee > 0 but no TransferFeeAccount extension found"); - return Err(CTokenError::DecompressWithheldFeeWithoutExtension.into()); - } - } - - // Handle is_frozen - restore frozen state from compressed token - if ext_data.is_frozen != 0 { - ctoken.base.set_frozen(); - } - - Ok(()) -} diff --git a/programs/compressed-token/program/tests/check_authority.rs b/programs/compressed-token/program/tests/check_authority.rs index 31e850eeb6..08e9f3624a 100644 --- a/programs/compressed-token/program/tests/check_authority.rs +++ b/programs/compressed-token/program/tests/check_authority.rs @@ -1,6 +1,6 @@ use anchor_compressed_token::ErrorCode; use light_account_checks::account_info::test_account_info::pinocchio::get_account_info; -use light_compressed_token::mint_action::check_authority; +use light_compressed_token::compressed_token::mint_action::check_authority; use pinocchio::pubkey::Pubkey; // Anchor custom error codes start at offset 6000 diff --git a/programs/compressed-token/program/tests/check_extensions.rs b/programs/compressed-token/program/tests/check_extensions.rs new file mode 100644 index 0000000000..70a17314e2 --- /dev/null +++ b/programs/compressed-token/program/tests/check_extensions.rs @@ -0,0 +1,744 @@ +//! Specific unit tests for build_mint_extension_cache and check_mint_extensions. +//! +//! Tests are organized into categories: +//! - Category 1: Failure tests for MintHasRestrictedExtensions +//! - Category 2: Failure tests for CompressAndClose +//! - Category 3: Success tests for bypass scenarios +//! - Category 4: Success tests for non-restricted mints +//! - Category 5: Direct check_mint_extensions tests + +use anchor_compressed_token::ErrorCode; +use anchor_lang::{prelude::ProgramError, solana_program::pubkey::Pubkey as SolanaPubkey}; +use light_account_checks::{ + account_info::test_account_info::pinocchio::get_account_info, + packed_accounts::ProgramPackedAccounts, +}; +use light_compressed_token::{ + compressed_token::transfer2::check_extensions::build_mint_extension_cache, + extensions::check_mint_extensions, +}; +use light_ctoken_interface::instructions::{ + extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, + transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiInputTokenDataWithContext, MultiTokenTransferOutputData, + }, +}; +use light_zero_copy::traits::ZeroCopyAt; +use pinocchio::pubkey::Pubkey; +use spl_pod::{optional_keys::OptionalNonZeroPubkey, primitives::PodBool}; +use spl_token_2022::{ + extension::{ + metadata_pointer::MetadataPointer, pausable::PausableConfig, + permanent_delegate::PermanentDelegate, transfer_fee::TransferFeeConfig, + transfer_hook::TransferHook, BaseStateWithExtensionsMut, ExtensionType, + PodStateWithExtensionsMut, + }, + pod::PodMint, +}; + +const ANCHOR_ERROR_OFFSET: u32 = 6000; +const SPL_TOKEN_2022_ID: [u8; 32] = spl_token_2022::ID.to_bytes(); +const SPL_TOKEN_ID: [u8; 32] = spl_token::ID.to_bytes(); + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Configuration for creating mock T22 mint with extensions. +#[derive(Default, Clone)] +struct MintConfig { + pub has_pausable: bool, + pub is_paused: bool, + pub has_transfer_fee: bool, + pub has_non_zero_fee: bool, + pub has_transfer_hook: bool, + pub has_non_nil_hook: bool, + pub has_permanent_delegate: bool, + pub has_metadata_pointer: bool, +} + +/// Create mock T22 mint data with specified extensions. +fn create_mock_t22_mint(config: &MintConfig) -> Vec { + use spl_token_2022::pod::PodCOption; + + let mut extensions = vec![]; + if config.has_pausable { + extensions.push(ExtensionType::Pausable); + } + if config.has_transfer_fee { + extensions.push(ExtensionType::TransferFeeConfig); + } + if config.has_transfer_hook { + extensions.push(ExtensionType::TransferHook); + } + if config.has_permanent_delegate { + extensions.push(ExtensionType::PermanentDelegate); + } + if config.has_metadata_pointer { + extensions.push(ExtensionType::MetadataPointer); + } + + let space = ExtensionType::try_calculate_account_len::(&extensions).unwrap(); + let mut data = vec![0u8; space]; + + let mut mint_state = + PodStateWithExtensionsMut::::unpack_uninitialized(&mut data).unwrap(); + + // Initialize base mint + mint_state.base.mint_authority = PodCOption::some(SolanaPubkey::new_unique()); + mint_state.base.decimals = 9; + mint_state.base.is_initialized = true.into(); + mint_state.base.freeze_authority = PodCOption::none(); + mint_state.base.supply = 1_000_000u64.into(); + mint_state.init_account_type().unwrap(); + + // Initialize extensions + if config.has_pausable { + let ext = mint_state.init_extension::(true).unwrap(); + ext.authority = OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + ext.paused = PodBool::from(config.is_paused); + } + + if config.has_transfer_fee { + let ext = mint_state + .init_extension::(true) + .unwrap(); + ext.transfer_fee_config_authority = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + ext.withdraw_withheld_authority = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + if config.has_non_zero_fee { + ext.older_transfer_fee.transfer_fee_basis_points = 100u16.into(); + ext.older_transfer_fee.maximum_fee = 1000u64.into(); + ext.newer_transfer_fee.transfer_fee_basis_points = 100u16.into(); + ext.newer_transfer_fee.maximum_fee = 1000u64.into(); + } + } + + if config.has_transfer_hook { + let ext = mint_state.init_extension::(true).unwrap(); + if config.has_non_nil_hook { + ext.program_id = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + } + } + + if config.has_permanent_delegate { + let ext = mint_state + .init_extension::(true) + .unwrap(); + ext.delegate = OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + } + + if config.has_metadata_pointer { + let ext = mint_state.init_extension::(true).unwrap(); + ext.metadata_address = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + } + + data +} + +/// Create mock SPL Token (non-T22) mint data. +fn create_mock_spl_token_mint() -> Vec { + // SPL Token mint is 82 bytes + let mut data = vec![0u8; 82]; + // Set is_initialized = true (offset 45, 1 byte) + data[45] = 1; + // Set decimals (offset 44) + data[44] = 9; + data +} + +/// Test configuration for instruction data. +#[derive(Default)] +struct TestConfig { + pub has_inputs: bool, + pub has_outputs: bool, + pub has_compressions: bool, + pub compression_mode: Option, + pub has_compressed_only_in_output: bool, + pub output_amount: u64, +} + +/// Create serialized instruction data for testing. +fn create_test_inputs(config: &TestConfig) -> Vec { + let in_token_data = if config.has_inputs { + vec![MultiInputTokenDataWithContext { + mint: 0, + amount: 100, + ..Default::default() + }] + } else { + vec![] + }; + + let out_token_data = if config.has_outputs { + vec![MultiTokenTransferOutputData { + mint: 0, + amount: config.output_amount, + ..Default::default() + }] + } else { + vec![] + }; + + let compressions = if config.has_compressions { + Some(vec![Compression { + mode: config.compression_mode.unwrap_or(CompressionMode::Compress), + amount: 100, + mint: 0, + source_or_recipient: 1, + authority: 2, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 0, + }]) + } else { + None + }; + + let out_tlv = if config.has_outputs && config.has_compressed_only_in_output { + Some(vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }, + )]]) + } else if config.has_outputs { + Some(vec![vec![]]) // Empty TLV for each output + } else { + None + }; + + let instruction_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 0, + max_top_up: 0, + cpi_context: None, + compressions, + proof: None, + in_token_data, + out_token_data, + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv, + }; + + borsh::to_vec(&instruction_data).unwrap() +} + +/// Run build_mint_extension_cache with test data. +fn run_build_cache_test( + serialized_inputs: &[u8], + mint_data: &[u8], + owner: [u8; 32], +) -> Result<(), ProgramError> { + let (inputs, _) = + CompressedTokenInstructionDataTransfer2::zero_copy_at(serialized_inputs).unwrap(); + + let mint_account = get_account_info( + Pubkey::from(owner), + owner, + false, + false, + false, + mint_data.to_vec(), + ); + + let accounts = [mint_account]; + let packed_accounts = ProgramPackedAccounts { + accounts: &accounts, + }; + + build_mint_extension_cache(&inputs, &packed_accounts).map(|_| ()) +} + +/// Helper to assert specific error code. +fn assert_error(result: Result<(), ProgramError>, expected: ErrorCode) { + let expected_code = ANCHOR_ERROR_OFFSET + expected as u32; + assert!( + matches!(result, Err(ProgramError::Custom(code)) if code == expected_code), + "Expected error {:?} (code {}), got {:?}", + expected, + expected_code, + result + ); +} + +// ============================================================================ +// Category 1: Failure Cases - MintHasRestrictedExtensions +// ============================================================================ + +#[test] +fn test_input_with_pausable_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_input_with_permanent_delegate_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_permanent_delegate: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_input_with_transfer_fee_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_fee: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_input_with_transfer_hook_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_hook: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_compress_with_pausable_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Compress), + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_decompress_with_pausable_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Decompress), + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_zero_amount_output_with_restricted_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_outputs: true, + output_amount: 0, // Zero amount output + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +// ============================================================================ +// Category 2: Failure Cases - CompressAndClose +// ============================================================================ + +#[test] +fn test_compress_and_close_missing_compressed_only_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, // Restricted extension + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::CompressAndClose), + has_outputs: true, + has_compressed_only_in_output: false, // Missing CompressedOnly + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error( + result, + ErrorCode::CompressAndCloseMissingCompressedOnlyExtension, + ); +} + +#[test] +fn test_compress_and_close_empty_tlv_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_permanent_delegate: true, // Different restricted extension + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::CompressAndClose), + has_outputs: true, + has_compressed_only_in_output: false, // Empty TLV + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error( + result, + ErrorCode::CompressAndCloseMissingCompressedOnlyExtension, + ); +} + +// ============================================================================ +// Category 3: Success Cases - Bypass Scenarios +// ============================================================================ + +#[test] +fn test_input_with_restricted_no_outputs_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, // Restricted, but no outputs = bypass + is_paused: true, // Even paused is OK with bypass + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: false, // No outputs = bypass + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!( + result.is_ok(), + "Should succeed with bypass, got {:?}", + result + ); +} + +#[test] +fn test_compress_and_close_with_compressed_only_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + is_paused: true, // Even paused is OK for CompressAndClose + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::CompressAndClose), + has_outputs: true, + has_compressed_only_in_output: true, // Has required extension + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!( + result.is_ok(), + "Should succeed with CompressedOnly, got {:?}", + result + ); +} + +#[test] +fn test_decompress_no_outputs_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_fee: true, + has_non_zero_fee: true, // Would fail if checked + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Decompress), + has_outputs: false, // No outputs = bypass + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!( + result.is_ok(), + "Should succeed with bypass, got {:?}", + result + ); +} + +#[test] +fn test_compress_no_outputs_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_hook: true, + has_non_nil_hook: true, // Would fail if checked + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Compress), + has_outputs: false, // No outputs = bypass + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!( + result.is_ok(), + "Should succeed with bypass, got {:?}", + result + ); +} + +// ============================================================================ +// Category 4: Success Cases - Non-Restricted Mints +// ============================================================================ + +#[test] +fn test_spl_token_mint_succeeds() { + let mint_data = create_mock_spl_token_mint(); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + // SPL Token mint is owned by spl_token::ID, not spl_token_2022::ID + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_ID); + assert!(result.is_ok(), "SPL Token should succeed, got {:?}", result); +} + +#[test] +fn test_t22_mint_no_extensions_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig::default()); // No extensions + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!( + result.is_ok(), + "T22 without extensions should succeed, got {:?}", + result + ); +} + +#[test] +fn test_t22_mint_with_metadata_only_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_metadata_pointer: true, // Not a restricted extension + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!( + result.is_ok(), + "MetadataPointer should succeed, got {:?}", + result + ); +} + +// ============================================================================ +// Category 5: Direct check_mint_extensions Tests +// ============================================================================ + +#[test] +fn test_check_mint_extensions_paused_mint() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + is_paused: true, + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // Call check_mint_extensions directly with deny_restricted=false + // This bypasses the restricted check and reaches the paused check + let result = check_mint_extensions(&mint_account, false); + assert_error(result.map(|_| ()), ErrorCode::MintPaused); +} + +#[test] +fn test_check_mint_extensions_non_zero_fee() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_fee: true, + has_non_zero_fee: true, + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + let result = check_mint_extensions(&mint_account, false); + assert_error( + result.map(|_| ()), + ErrorCode::NonZeroTransferFeeNotSupported, + ); +} + +#[test] +fn test_check_mint_extensions_non_nil_hook() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_hook: true, + has_non_nil_hook: true, + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + let result = check_mint_extensions(&mint_account, false); + assert_error(result.map(|_| ()), ErrorCode::TransferHookNotSupported); +} + +#[test] +fn test_check_mint_extensions_deny_restricted_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, // Restricted extension + is_paused: false, // Not paused, but still restricted + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // deny_restricted=true should fail even if mint state is valid + let result = check_mint_extensions(&mint_account, true); + assert_error(result.map(|_| ()), ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_check_mint_extensions_deny_restricted_non_restricted_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_metadata_pointer: true, // Not a restricted extension + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // deny_restricted=true should succeed with non-restricted mint + let result = check_mint_extensions(&mint_account, true); + assert!( + result.is_ok(), + "Non-restricted mint should succeed, got {:?}", + result + ); +} + +#[test] +fn test_check_mint_extensions_valid_mint_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + is_paused: false, // Not paused + has_transfer_fee: true, + has_non_zero_fee: false, // Zero fee + has_transfer_hook: true, + has_non_nil_hook: false, // Nil hook + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // deny_restricted=false with all valid states should succeed + let result = check_mint_extensions(&mint_account, false); + assert!( + result.is_ok(), + "Valid mint should succeed, got {:?}", + result + ); +} diff --git a/programs/compressed-token/program/tests/compress_and_close.rs b/programs/compressed-token/program/tests/compress_and_close.rs index 8f7464c508..80e2202973 100644 --- a/programs/compressed-token/program/tests/compress_and_close.rs +++ b/programs/compressed-token/program/tests/compress_and_close.rs @@ -4,7 +4,7 @@ use light_account_checks::{ account_info::test_account_info::pinocchio::get_account_info, packed_accounts::ProgramPackedAccounts, }; -use light_compressed_token::transfer2::{ +use light_compressed_token::compressed_token::transfer2::{ accounts::Transfer2Accounts, compression::ctoken::close_for_compress_and_close, }; use light_ctoken_interface::{ diff --git a/programs/compressed-token/program/tests/mint.rs b/programs/compressed-token/program/tests/mint.rs index 7ed1a09028..6b5f865ee6 100644 --- a/programs/compressed-token/program/tests/mint.rs +++ b/programs/compressed-token/program/tests/mint.rs @@ -4,11 +4,11 @@ use light_compressed_account::{ Pubkey, }; use light_compressed_token::{ - constants::COMPRESSED_MINT_DISCRIMINATOR, - mint_action::{ + compressed_token::mint_action::{ accounts::AccountsConfig, mint_input::create_input_compressed_mint_account, zero_copy_config::get_zero_copy_configs, }, + constants::COMPRESSED_MINT_DISCRIMINATOR, }; use light_ctoken_interface::{ instructions::{ @@ -119,6 +119,7 @@ fn test_rnd_create_compressed_mint_account() { version, mint: mint_pda, cmint_decompressed, + compressed_address: compressed_account_address, }, mint_authority: Some(mint_authority), freeze_authority, @@ -128,14 +129,10 @@ fn test_rnd_create_compressed_mint_account() { // Step 3: Create MintActionCompressedInstructionData let mint_action_data = MintActionCompressedInstructionData { create_mint: None, // We're testing with existing mint - leaf_index, prove_by_index, root_index, - compressed_address: compressed_account_address, mint: Some(mint_instruction_data.clone()), - token_pool_bump: 0, - token_pool_index: 0, actions: vec![], // No actions for basic test proof: None, cpi_context: None, @@ -176,9 +173,10 @@ fn test_rnd_create_compressed_mint_account() { create_input_compressed_mint_account( input_account, - &parsed_instruction_data, + root_index.into(), merkle_context, &accounts_config, + &cmint, ) .unwrap(); @@ -380,8 +378,9 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { version: 3u8, mint: Pubkey::new_from_array([3; 32]), cmint_decompressed: false, + compressed_address: [5; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, extensions: Some(vec![ExtensionStruct::TokenMetadata(token_metadata)]), }; @@ -409,6 +408,8 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { compression_authority: zc.compression_authority, rent_sponsor: zc.rent_sponsor, last_claimed_slot: u64::from(zc.last_claimed_slot), + rent_exemption_paid: u32::from(zc.rent_exemption_paid), + _reserved: u32::from(zc._reserved), rent_config: light_compressible::rent::RentConfig { base_rent: u16::from(zc.rent_config.base_rent), compression_cost: u16::from(zc.rent_config.compression_cost), @@ -432,6 +433,7 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { version: zc_mint.base.metadata.version, mint: zc_mint.base.metadata.mint, cmint_decompressed: zc_mint.base.metadata.cmint_decompressed != 0, + compressed_address: zc_mint.base.metadata.compressed_address, }, reserved: *zc_mint.base.reserved, account_type: zc_mint.base.account_type, diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index 90d9736fb7..f9ea682006 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -4,12 +4,12 @@ /// that the derived configuration matches expected values based on instruction content. use borsh::BorshSerialize; use light_compressed_account::{instruction_data::compressed_proof::CompressedProof, Pubkey}; -use light_compressed_token::mint_action::accounts::AccountsConfig; +use light_compressed_token::compressed_token::mint_action::accounts::AccountsConfig; use light_ctoken_interface::{ instructions::{ extensions::{token_metadata::TokenMetadataInstructionData, ExtensionInstructionData}, mint_action::{ - Action, CompressedMintInstructionData, CpiContext, CreateMint, CreateSplMintAction, + Action, CompressedMintInstructionData, CpiContext, CreateMint, MintActionCompressedInstructionData, MintToCTokenAction, MintToCompressedAction, Recipient, RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, @@ -42,6 +42,7 @@ fn random_compressed_mint_metadata(rng: &mut StdRng) -> CompressedMintMetadata { version: rng.gen_range(1..=3) as u8, cmint_decompressed: rng.gen_bool(0.5), mint: random_pubkey(rng), + compressed_address: rng.gen::<[u8; 32]>(), } } @@ -83,12 +84,6 @@ fn random_update_authority_action(rng: &mut StdRng) -> UpdateAuthority { } } -fn random_create_spl_mint_action(rng: &mut StdRng) -> CreateSplMintAction { - CreateSplMintAction { - mint_bump: rng.gen::(), - } -} - fn random_update_metadata_field_action(rng: &mut StdRng) -> UpdateMetadataFieldAction { UpdateMetadataFieldAction { extension_index: rng.gen_range(0..=2) as u8, @@ -114,15 +109,14 @@ fn random_remove_metadata_key_action(rng: &mut StdRng) -> RemoveMetadataKeyActio } fn random_action(rng: &mut StdRng) -> Action { - match rng.gen_range(0..8) { + match rng.gen_range(0..7) { 0 => Action::MintToCompressed(random_mint_to_action(rng)), 1 => Action::UpdateMintAuthority(random_update_authority_action(rng)), 2 => Action::UpdateFreezeAuthority(random_update_authority_action(rng)), - 3 => Action::CreateSplMint(random_create_spl_mint_action(rng)), - 4 => Action::MintToCToken(random_mint_to_decompressed_action(rng)), - 5 => Action::UpdateMetadataField(random_update_metadata_field_action(rng)), - 6 => Action::UpdateMetadataAuthority(random_update_metadata_authority_action(rng)), - 7 => Action::RemoveMetadataKey(random_remove_metadata_key_action(rng)), + 3 => Action::MintToCToken(random_mint_to_decompressed_action(rng)), + 4 => Action::UpdateMetadataField(random_update_metadata_field_action(rng)), + 5 => Action::UpdateMetadataAuthority(random_update_metadata_authority_action(rng)), + 6 => Action::RemoveMetadataKey(random_remove_metadata_key_action(rng)), _ => unreachable!(), } } @@ -182,9 +176,6 @@ fn generate_random_instruction_data( leaf_index: rng.gen::(), prove_by_index: rng.gen_bool(0.5), root_index: rng.gen::(), - compressed_address: rng.gen::<[u8; 32]>(), - token_pool_bump: rng.gen::(), - token_pool_index: rng.gen::(), max_top_up: rng.gen::(), actions, proof: if rng.gen_bool(0.6) { @@ -224,24 +215,17 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun .map(|ctx| ctx.first_set_context || ctx.set_context) .unwrap_or(false); - // 3. has_mint_to_actions - // Only MintToCompressed counts - MintToCToken mints to existing decompressed accounts - let has_mint_to_actions = data + // 3. require_token_output_queue (only MintToCompressed creates new compressed outputs) + let require_token_output_queue = data .actions .iter() .any(|action| matches!(action, Action::MintToCompressed(_))); - // 4. create_spl_mint (for with_mint_signer only) - let create_spl_mint = data - .actions - .iter() - .any(|action| matches!(action, Action::CreateSplMint(_))); - - // 5. cmint_decompressed - only based on metadata flag (matches AccountsConfig::new) + // 4. 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; + // 5. with_mint_signer + let with_mint_signer = data.create_mint.is_some(); // 7. create_mint let create_mint = data.create_mint.is_some(); @@ -264,7 +248,7 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun has_compress_and_close_cmint_action, write_to_cpi_context, cmint_decompressed, - has_mint_to_actions, + require_token_output_queue, with_mint_signer, create_mint, } @@ -346,14 +330,8 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi .iter() .any(|action| matches!(action, Action::MintToCToken(_))); - // Check for CreateSplMint actions - let create_spl_mint = instruction_data - .actions - .iter() - .any(|action| matches!(action, Action::CreateSplMint(_))); - // Check for MintToCompressed actions - let has_mint_to_actions = instruction_data + let require_token_output_queue = instruction_data .actions .iter() .any(|action| matches!(action, Action::MintToCompressed(_))); @@ -368,9 +346,8 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi // 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) + // 2. cmint_decompressed && require_token_output_queue (mint decompressed + MintToCompressed not allowed) + has_mint_to_ctoken || (cmint_decompressed && require_token_output_queue) } else { false } diff --git a/programs/compressed-token/program/tests/mint_validation.rs b/programs/compressed-token/program/tests/mint_validation.rs new file mode 100644 index 0000000000..28bb2c5f62 --- /dev/null +++ b/programs/compressed-token/program/tests/mint_validation.rs @@ -0,0 +1,358 @@ +use anchor_lang::prelude::ProgramError; +use light_compressed_token::shared::initialize_ctoken_account::is_valid_mint; +use pinocchio::pubkey::Pubkey; + +const SPL_TOKEN_ID: Pubkey = spl_token::ID.to_bytes(); +const SPL_TOKEN_2022_ID: Pubkey = spl_token_2022::ID.to_bytes(); +const CTOKEN_PROGRAM_ID: Pubkey = light_ctoken_interface::CTOKEN_PROGRAM_ID; +const SYSTEM_PROGRAM_ID: Pubkey = [0u8; 32]; +const RANDOM_PROGRAM_ID: Pubkey = [42u8; 32]; + +const ACCOUNT_TYPE_UNINITIALIZED: u8 = 0; +const ACCOUNT_TYPE_MINT: u8 = 1; +const ACCOUNT_TYPE_ACCOUNT: u8 = 2; +const ACCOUNT_TYPE_UNKNOWN: u8 = 3; + +/// Owner types for testing +#[derive(Debug, Clone, Copy)] +enum Owner { + SplToken, + Token2022, + CToken, + SystemProgram, + RandomProgram, +} + +impl Owner { + fn pubkey(&self) -> &Pubkey { + match self { + Owner::SplToken => &SPL_TOKEN_ID, + Owner::Token2022 => &SPL_TOKEN_2022_ID, + Owner::CToken => &CTOKEN_PROGRAM_ID, + Owner::SystemProgram => &SYSTEM_PROGRAM_ID, + Owner::RandomProgram => &RANDOM_PROGRAM_ID, + } + } +} + +/// Data configurations for testing +#[derive(Debug, Clone)] +enum MintData { + Empty, + TooSmall(usize), // < 82 bytes + ExactSplSize, // 82 bytes (valid for all) + BetweenSizes(usize), // 83-165 bytes + WithAccountType(u8), // 166+ bytes with specific AccountType +} + +impl MintData { + fn to_bytes(&self) -> Vec { + match self { + MintData::Empty => vec![], + MintData::TooSmall(size) => vec![0u8; *size], + MintData::ExactSplSize => vec![0u8; 82], + MintData::BetweenSizes(size) => vec![0u8; *size], + MintData::WithAccountType(account_type) => { + let mut data = vec![0u8; 170]; + data[165] = *account_type; + data + } + } + } +} + +/// Expected result for a test case +#[derive(Debug, Clone, Copy, PartialEq)] +enum Expected { + Valid, // Ok(true) + Invalid, // Ok(false) + IncorrectProgramId, // Err(IncorrectProgramId) +} + +/// Test case definition +struct TestCase { + owner: Owner, + data: MintData, + expected: Expected, + description: &'static str, +} + +fn run_test_case(tc: &TestCase) { + let data = tc.data.to_bytes(); + let result = is_valid_mint(tc.owner.pubkey(), &data); + + match tc.expected { + Expected::Valid => { + assert!( + result.as_ref().map(|v| *v).unwrap_or(false), + "FAILED: {} - expected Ok(true), got {:?}", + tc.description, + result + ); + } + Expected::Invalid => { + assert!( + result.as_ref().map(|v| !*v).unwrap_or(false), + "FAILED: {} - expected Ok(false), got {:?}", + tc.description, + result + ); + } + Expected::IncorrectProgramId => { + assert!( + result.as_ref().err() == Some(&ProgramError::IncorrectProgramId), + "FAILED: {} - expected Err(IncorrectProgramId), got {:?}", + tc.description, + result + ); + } + } +} + +/// Systematically test all owner x data combinations +#[test] +fn test_is_valid_mint_all_combinations() { + let test_cases = vec![ + // ========================================================================= + // INVALID OWNERS - should always return Err(IncorrectProgramId) + // ========================================================================= + TestCase { + owner: Owner::SystemProgram, + data: MintData::ExactSplSize, + expected: Expected::IncorrectProgramId, + description: "System program owner with 82 bytes", + }, + TestCase { + owner: Owner::RandomProgram, + data: MintData::ExactSplSize, + expected: Expected::IncorrectProgramId, + description: "Random program owner with 82 bytes", + }, + TestCase { + owner: Owner::SystemProgram, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::IncorrectProgramId, + description: "System program owner with AccountType=Mint", + }, + // ========================================================================= + // SPL TOKEN - only accepts exactly 82 bytes + // ========================================================================= + TestCase { + owner: Owner::SplToken, + data: MintData::Empty, + expected: Expected::Invalid, + description: "SPL: empty data", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::TooSmall(40), + expected: Expected::Invalid, + description: "SPL: 40 bytes (< 82)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::TooSmall(81), + expected: Expected::Invalid, + description: "SPL: 81 bytes (off by one)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::ExactSplSize, + expected: Expected::Valid, + description: "SPL: exactly 82 bytes (valid mint)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::BetweenSizes(83), + expected: Expected::Invalid, + description: "SPL: 83 bytes (off by one, too large)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::BetweenSizes(165), + expected: Expected::Invalid, + description: "SPL: 165 bytes (token account size)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::Invalid, + description: "SPL: 170 bytes with AccountType=Mint (SPL doesnt support extensions)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_ACCOUNT), + expected: Expected::Invalid, + description: "SPL: 170 bytes with AccountType=Account", + }, + // ========================================================================= + // TOKEN-2022 - accepts 82 bytes OR 166+ with AccountType=Mint + // ========================================================================= + TestCase { + owner: Owner::Token2022, + data: MintData::Empty, + expected: Expected::Invalid, + description: "T22: empty data", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::TooSmall(40), + expected: Expected::Invalid, + description: "T22: 40 bytes (< 82)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::TooSmall(81), + expected: Expected::Invalid, + description: "T22: 81 bytes (off by one)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::ExactSplSize, + expected: Expected::Valid, + description: "T22: exactly 82 bytes (valid mint without extensions)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::BetweenSizes(83), + expected: Expected::Invalid, + description: "T22: 83 bytes (invalid - between sizes)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::BetweenSizes(165), + expected: Expected::Invalid, + description: "T22: 165 bytes (edge case - no AccountType marker)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNINITIALIZED), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=0 (uninitialized)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::Valid, + description: "T22: 170 bytes with AccountType=Mint (valid)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_ACCOUNT), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=Account (token account)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNKNOWN), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=3 (unknown)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(255), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=255 (invalid)", + }, + // ========================================================================= + // CTOKEN - must always be >165 bytes with AccountType=Mint + // ========================================================================= + TestCase { + owner: Owner::CToken, + data: MintData::Empty, + expected: Expected::Invalid, + description: "CToken: empty data", + }, + TestCase { + owner: Owner::CToken, + data: MintData::TooSmall(40), + expected: Expected::Invalid, + description: "CToken: 40 bytes (< 82)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::TooSmall(81), + expected: Expected::Invalid, + description: "CToken: 81 bytes (off by one)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::ExactSplSize, + expected: Expected::Invalid, + description: "CToken: 82 bytes (invalid - CToken always has extensions)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::BetweenSizes(83), + expected: Expected::Invalid, + description: "CToken: 83 bytes (invalid - between sizes)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::BetweenSizes(165), + expected: Expected::Invalid, + description: "CToken: 165 bytes (edge case - no AccountType marker)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNINITIALIZED), + expected: Expected::Invalid, + description: "CToken: 170 bytes with AccountType=0 (uninitialized)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::Valid, + description: "CToken: 170 bytes with AccountType=Mint (valid)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_ACCOUNT), + expected: Expected::Invalid, + description: "CToken: 170 bytes with AccountType=Account (token account)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNKNOWN), + expected: Expected::Invalid, + description: "CToken: 170 bytes with AccountType=3 (unknown)", + }, + ]; + + println!( + "\nRunning {} test cases for is_valid_mint:\n", + test_cases.len() + ); + + let mut passed = 0; + let mut failed = 0; + + for tc in &test_cases { + print!(" {:60} ... ", tc.description); + let data = tc.data.to_bytes(); + let result = is_valid_mint(tc.owner.pubkey(), &data); + + let success = match tc.expected { + Expected::Valid => result.as_ref().map(|v| *v).unwrap_or(false), + Expected::Invalid => result.as_ref().map(|v| !*v).unwrap_or(false), + Expected::IncorrectProgramId => { + result.as_ref().err() == Some(&ProgramError::IncorrectProgramId) + } + }; + + if success { + println!("ok"); + passed += 1; + } else { + println!("FAILED (got {:?})", result); + failed += 1; + } + } + + println!("\nResults: {} passed, {} failed\n", passed, failed); + + // Now run assertions to fail the test if any failed + for tc in &test_cases { + run_test_case(tc); + } +} diff --git a/programs/compressed-token/program/tests/multi_sum_check.rs b/programs/compressed-token/program/tests/multi_sum_check.rs index d33091d221..96d4ded630 100644 --- a/programs/compressed-token/program/tests/multi_sum_check.rs +++ b/programs/compressed-token/program/tests/multi_sum_check.rs @@ -6,7 +6,7 @@ use light_account_checks::{ account_info::test_account_info::pinocchio::get_account_info, packed_accounts::ProgramPackedAccounts, }; -use light_compressed_token::transfer2::sum_check::{ +use light_compressed_token::compressed_token::transfer2::sum_check::{ sum_check_multi_mint, validate_mint_uniqueness, }; use light_ctoken_interface::instructions::transfer2::{ diff --git a/programs/compressed-token/program/tests/queue_indices.rs b/programs/compressed-token/program/tests/queue_indices.rs index 886d65e6c8..13bfa8731d 100644 --- a/programs/compressed-token/program/tests/queue_indices.rs +++ b/programs/compressed-token/program/tests/queue_indices.rs @@ -1,6 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::AnchorSerialize; -use light_compressed_token::mint_action::queue_indices::QueueIndices; +use light_compressed_token::compressed_token::mint_action::queue_indices::QueueIndices; use light_ctoken_interface::instructions::mint_action::CpiContext; use light_zero_copy::traits::ZeroCopyAt; 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 521f1b311e..cc05d04ab0 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 @@ -52,6 +52,7 @@ pub fn create_compressed_mint_cpi( version: input.version, mint: find_cmint_address(&input.mint_signer).0.to_bytes().into(), cmint_decompressed: false, + compressed_address: mint_address, }, mint_authority: Some(input.mint_authority.to_bytes().into()), freeze_authority: input.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -59,7 +60,6 @@ pub fn create_compressed_mint_cpi( }; 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_instruction_data, @@ -134,6 +134,7 @@ pub fn create_compressed_mint_cpi_write( version: input.version, mint: find_cmint_address(&input.mint_signer).0.to_bytes().into(), cmint_decompressed: false, + compressed_address: input.mint_address, }, mint_authority: Some(input.mint_authority.to_bytes().into()), freeze_authority: input.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -141,9 +142,9 @@ pub fn create_compressed_mint_cpi_write( }; let instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint_write_to_cpi_context( - input.mint_address, input.address_merkle_tree_root_index, - compressed_mint_instruction_data,input.cpi_context + compressed_mint_instruction_data, + input.cpi_context, ); let meta_config = MintActionMetaConfigCpiWrite { 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 16d54fc7fc..79d85b983a 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 @@ -116,7 +116,7 @@ impl MintActionMetaConfig { } /// Set the mint_signer account with signing required. - /// Use for create_mint and create_spl_mint actions. + /// Use for create_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; @@ -158,7 +158,7 @@ impl MintActionMetaConfig { // mint_signer is present when creating a new mint or decompressing if let Some(mint_signer) = self.mint_signer { - // mint_signer needs to sign for create_mint/create_spl_mint, not for decompress_mint + // mint_signer needs to sign for create_mint, not for decompress_mint metas.push(AccountMeta::new_readonly( mint_signer, self.mint_signer_must_sign, diff --git a/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs b/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs index d9331947b2..8ff01ce917 100644 --- a/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs +++ b/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs @@ -121,6 +121,7 @@ impl CreateCMint { version: 3, mint: self.params.mint.to_bytes().into(), cmint_decompressed: false, + compressed_address: compression_address, }, mint_authority: Some(self.params.mint_authority.to_bytes().into()), freeze_authority: self @@ -132,7 +133,6 @@ impl CreateCMint { let mut instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - compression_address, self.params.address_merkle_tree_root_index, self.params.proof, compressed_mint_instruction_data, @@ -262,6 +262,7 @@ impl CreateCompressedMintCpiWrite { version: self.params.version, mint: self.params.mint.to_bytes().into(), cmint_decompressed: false, + compressed_address: self.params.compression_address, }, mint_authority: Some(self.params.mint_authority.to_bytes().into()), freeze_authority: self @@ -273,7 +274,6 @@ impl CreateCompressedMintCpiWrite { let instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint_write_to_cpi_context( - self.params.compression_address, self.params.address_merkle_tree_root_index, compressed_mint_instruction_data, self.params.cpi_context, diff --git a/sdk-libs/token-client/src/actions/mint_action.rs b/sdk-libs/token-client/src/actions/mint_action.rs index 5e2f59127a..e08329d0ef 100644 --- a/sdk-libs/token-client/src/actions/mint_action.rs +++ b/sdk-libs/token-client/src/actions/mint_action.rs @@ -23,7 +23,7 @@ use crate::instructions::mint_action::{ /// * `params` - Parameters for the mint action /// * `authority` - Authority keypair for the mint operations /// * `payer` - Account that pays for the transaction -/// * `mint_signer` - Optional mint signer for CreateSplMint action +/// * `mint_signer` - Optional mint signer for create_mint or DecompressMint action pub async fn mint_action( rpc: &mut R, params: MintActionParams, @@ -49,7 +49,7 @@ pub async fn mint_action( signers.push(authority); } - // Add mint signer if needed for CreateSplMint + // Add mint signer if needed for create_mint or DecompressMint if let Some(signer) = mint_signer { if !signers.iter().any(|s| s.pubkey() == signer.pubkey()) { signers.push(signer); diff --git a/sdk-libs/token-client/src/instructions/mint_action.rs b/sdk-libs/token-client/src/instructions/mint_action.rs index 14382da97b..50c5fcb15f 100644 --- a/sdk-libs/token-client/src/instructions/mint_action.rs +++ b/sdk-libs/token-client/src/instructions/mint_action.rs @@ -158,6 +158,7 @@ pub async fn create_mint_action_instruction( mint: find_cmint_address(¶ms.mint_seed).0.to_bytes().into(), // false for new mint - on-chain sets to true after DecompressMint cmint_decompressed: false, + compressed_address: params.compressed_mint_address, }, mint_authority: Some(new_mint.mint_authority.to_bytes().into()), freeze_authority: new_mint.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -223,7 +224,6 @@ pub async fn create_mint_action_instruction( // Build instruction data using builder pattern let mut instruction_data = if is_creating_mint { MintActionCompressedInstructionData::new_mint( - params.compressed_mint_address, compressed_mint_inputs.root_index, proof.ok_or_else(|| { RpcError::CustomError("Proof is required for mint creation".to_string()) diff --git a/sdk-tests/csdk-anchor-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-derived-test/src/lib.rs index 37e4aeff2c..b49ea8307f 100644 --- a/sdk-tests/csdk-anchor-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-derived-test/src/lib.rs @@ -145,7 +145,6 @@ pub mod csdk_anchor_derived_test { // Build instruction data using the correct API let proof = compression_params.proof.0.unwrap_or_default(); let instruction_data = MintActionCompressedInstructionData::new_mint( - compression_params.mint_with_context.address, 0, // root_index for new addresses proof, compression_params.mint_with_context.mint.clone().unwrap(), 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 d9c940ca78..b5e40fbf03 100644 --- a/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs @@ -670,6 +670,7 @@ pub async fn create_user_record_and_game_session( version: 3, mint: spl_mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), 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 ac4ca4fcd3..6f75ef39eb 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -186,7 +186,6 @@ pub mod csdk_anchor_full_derived_test { // Build instruction data using the correct API let proof = compression_params.proof.0.unwrap_or_default(); let instruction_data = MintActionCompressedInstructionData::new_mint( - compression_params.mint_with_context.address, 0, // root_index for new addresses proof, compression_params.mint_with_context.mint.clone().unwrap(), 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 4c427b933e..ad7527679e 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 @@ -330,6 +330,7 @@ pub async fn create_user_record_and_game_session( version: 3, mint: spl_mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), 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 755a1d2bc0..a475baf412 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 @@ -124,7 +124,6 @@ pub fn create_user_record_and_game_session<'info>( let proof = compression_params.proof.0.unwrap_or_default(); let mut instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - compression_params.mint_with_context.address, 0, // root_index proof, compression_params.mint_with_context.mint.clone().unwrap(), 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 b949f58d95..16f17de744 100644 --- a/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs @@ -334,6 +334,7 @@ pub async fn create_user_record_and_game_session( version: 3, mint: spl_mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), 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 2691c18584..9ae8a1460e 100644 --- a/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs +++ b/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs @@ -17,7 +17,6 @@ pub fn process_mint_action<'a, 'info>( ) -> Result<()> { // Build instruction data using builder pattern let mut instruction_data = MintActionCompressedInstructionData::new_mint( - 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().unwrap(), 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 e34ec3c079..86f16caab8 100644 --- a/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs +++ b/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs @@ -19,7 +19,6 @@ pub fn process_mint_action<'a, 'info>( // ValidityProof is a wrapper around Option let compressed_proof = input.pda_creation.proof.0.unwrap(); let instruction_data = MintActionCompressedInstructionData::new_mint( - input.compressed_mint_with_context.address, input.compressed_mint_with_context.root_index, compressed_proof, input.compressed_mint_with_context.mint.clone().unwrap(), diff --git a/sdk-tests/sdk-token-test/tests/ctoken_pda.rs b/sdk-tests/sdk-token-test/tests/ctoken_pda.rs index 20e73f0c76..9f3be17fee 100644 --- a/sdk-tests/sdk-token-test/tests/ctoken_pda.rs +++ b/sdk-tests/sdk-token-test/tests/ctoken_pda.rs @@ -202,6 +202,7 @@ pub async fn create_mint( version: 3, mint: mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: freeze_authority.map(|fa| fa.into()), diff --git a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs index d2f15772b6..513baff1c1 100644 --- a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs +++ b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs @@ -275,6 +275,7 @@ pub async fn create_mint( version: 3, mint: mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: freeze_authority.map(|fa| fa.into()), 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 01be945744..a9f0db4ad4 100644 --- a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs +++ b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs @@ -241,8 +241,9 @@ async fn mint_compressed_tokens( version: 3, mint: mint_pda.into(), cmint_decompressed: false, + compressed_address: compressed_mint_account.address.unwrap(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: Default::default(), extensions: None, 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 f70f8ed67f..39eea72671 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 @@ -132,8 +132,9 @@ async fn test_compress_full_and_close() { version: 3, mint: mint_pda.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: Default::default(), extensions: None,