diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index ebeb55bb9..212ed2b8e 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -246,6 +246,7 @@ pub enum CliAuthorityType { Group, ScaledUiAmount, Pause, + PermissionedBurn, } impl TryFrom for AuthorityType { type Error = Error; @@ -278,6 +279,7 @@ impl TryFrom for AuthorityType { } CliAuthorityType::ScaledUiAmount => Ok(AuthorityType::ScaledUiAmount), CliAuthorityType::Pause => Ok(AuthorityType::Pause), + CliAuthorityType::PermissionedBurn => Ok(AuthorityType::PermissionedBurn), } } } @@ -926,6 +928,21 @@ pub fn app<'a>( "Enable the mint authority to pause mint, burn, and transfer for this mint" ) ) + .arg( + Arg::with_name("enable_permissioned_burn") + .long("enable-permissioned-burn") + .takes_value(false) + .help("Require the configured permissioned burn authority for burning tokens") + ) + .arg( + Arg::with_name("permissioned_burn") + .long("permissioned-burn") + .validator(|s| is_valid_pubkey(s)) + .value_name("AUTHORITY") + .takes_value(true) + .conflicts_with("enable_permissioned_burn") + .help("Specify a permissioned burn authority for the mint. Defaults to the mint authority.") + ) .arg(multisig_signer_arg()) .nonce_args(true) .arg(memo_arg()) diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 43a6e3d02..d09ba6341 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -52,6 +52,7 @@ use { mint_close_authority::MintCloseAuthority, pausable::PausableConfig, permanent_delegate::PermanentDelegate, + permissioned_burn::PermissionedBurnConfig, scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::{TransferFeeAmount, TransferFeeConfig}, transfer_hook::TransferHook, @@ -268,6 +269,8 @@ async fn command_create_token( enable_transfer_hook: bool, ui_multiplier: Option, pausable: bool, + enable_permissioned_burn: bool, + permissioned_burn_authority: Option, bulk_signers: Vec>, ) -> CommandResult { println_display( @@ -409,6 +412,12 @@ async fn command_create_token( extensions.push(ExtensionInitializationParams::PausableConfig { authority }); } + if enable_permissioned_burn { + extensions.push(ExtensionInitializationParams::PermissionedBurnConfig { + authority: permissioned_burn_authority.unwrap_or(authority), + }); + } + let res = token .create_mint( &authority, @@ -1124,6 +1133,16 @@ async fn command_authorize( )) } } + CliAuthorityType::PermissionedBurn => { + if let Ok(extension) = mint.get_extension::() { + Ok(Option::::from(extension.authority)) + } else { + Err(format!( + "Mint `{}` does not support permissioned burn", + account + )) + } + } }?; Ok((account, previous_authority)) @@ -1167,7 +1186,8 @@ async fn command_authorize( | CliAuthorityType::Group | CliAuthorityType::GroupMemberPointer | CliAuthorityType::ScaledUiAmount - | CliAuthorityType::Pause => Err(format!( + | CliAuthorityType::Pause + | CliAuthorityType::PermissionedBurn => Err(format!( "Authority type `{auth_str}` not supported for SPL Token accounts", )), CliAuthorityType::Owner => { @@ -3872,6 +3892,10 @@ pub async fn process_command( }); let transfer_hook_program_id = pubkey_of_signer(arg_matches, "transfer_hook", &mut wallet_manager).unwrap(); + let permissioned_burn_authority = + pubkey_of_signer(arg_matches, "permissioned_burn", &mut wallet_manager).unwrap(); + let enable_permissioned_burn = arg_matches.is_present("enable_permissioned_burn") + || permissioned_burn_authority.is_some(); let confidential_transfer_auto_approve = arg_matches .value_of("enable_confidential_transfers") @@ -3901,6 +3925,8 @@ pub async fn process_command( arg_matches.is_present("enable_transfer_hook"), ui_multiplier, arg_matches.is_present("enable_pause"), + enable_permissioned_burn, + permissioned_burn_authority, bulk_signers, ) .await diff --git a/clients/cli/tests/command.rs b/clients/cli/tests/command.rs index 1c00463fa..10b3a90aa 100644 --- a/clients/cli/tests/command.rs +++ b/clients/cli/tests/command.rs @@ -28,6 +28,7 @@ use { metadata_pointer::MetadataPointer, non_transferable::NonTransferable, pausable::PausableConfig, + permissioned_burn::PermissionedBurnConfig, scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::{TransferFeeAmount, TransferFeeConfig}, transfer_hook::TransferHook, @@ -148,6 +149,7 @@ async fn main() { async_trial!(compute_budget, test_validator, payer), async_trial!(scaled_ui_amount, test_validator, payer), async_trial!(pause, test_validator, payer), + async_trial!(permissioned_burn, test_validator, payer), // GC messes with every other test, so have it on its own test validator async_trial!(gc, gc_test_validator, gc_payer), ]; @@ -4806,3 +4808,36 @@ async fn pause(test_validator: &TestValidator, payer: &Keypair) { let extension = test_mint.get_extension::().unwrap(); assert_eq!(Option::::from(extension.authority), None,); } + +async fn permissioned_burn(test_validator: &TestValidator, payer: &Keypair) { + let config = + test_config_with_default_signer(test_validator, payer, &spl_token_2022_interface::id()); + + let token = Keypair::new(); + let burn_authority = Keypair::new(); + let token_keypair_file = NamedTempFile::new().unwrap(); + write_keypair_file(&token, &token_keypair_file).unwrap(); + let token_pubkey = token.pubkey(); + + process_test_command( + &config, + payer, + &[ + "spl-token", + CommandName::CreateToken.into(), + token_keypair_file.path().to_str().unwrap(), + "--permissioned-burn", + burn_authority.pubkey().to_string().as_str(), + ], + ) + .await + .unwrap(); + + let account = config.rpc_client.get_account(&token_pubkey).await.unwrap(); + let test_mint = StateWithExtensionsOwned::::unpack(account.data).unwrap(); + let extension = test_mint.get_extension::().unwrap(); + assert_eq!( + Option::::from(extension.authority), + Some(burn_authority.pubkey()) + ); +} diff --git a/clients/js-legacy/src/extensions/extensionType.ts b/clients/js-legacy/src/extensions/extensionType.ts index e00b3eddf..e4842a5e9 100644 --- a/clients/js-legacy/src/extensions/extensionType.ts +++ b/clients/js-legacy/src/extensions/extensionType.ts @@ -18,6 +18,7 @@ import { MINT_CLOSE_AUTHORITY_SIZE } from './mintCloseAuthority.js'; import { NON_TRANSFERABLE_SIZE, NON_TRANSFERABLE_ACCOUNT_SIZE } from './nonTransferable.js'; import { PAUSABLE_CONFIG_SIZE, PAUSABLE_ACCOUNT_SIZE } from './pausable/index.js'; import { PERMANENT_DELEGATE_SIZE } from './permanentDelegate.js'; +import { PERMISSIONED_BURN_SIZE } from './permissionedBurn/state.js'; import { SCALED_UI_AMOUNT_CONFIG_SIZE } from './scaledUiAmount/index.js'; import { TRANSFER_FEE_AMOUNT_SIZE, TRANSFER_FEE_CONFIG_SIZE } from './transferFee/index.js'; import { TRANSFER_HOOK_ACCOUNT_SIZE, TRANSFER_HOOK_SIZE } from './transferHook/index.js'; @@ -53,6 +54,7 @@ export enum ExtensionType { ScaledUiAmountConfig = 25, PausableConfig = 26, PausableAccount = 27, + PermissionedBurn = 28, } export const TYPE_SIZE = 2; @@ -123,6 +125,8 @@ export function getTypeLen(e: ExtensionType): number { return PAUSABLE_CONFIG_SIZE; case ExtensionType.PausableAccount: return PAUSABLE_ACCOUNT_SIZE; + case ExtensionType.PermissionedBurn: + return PERMISSIONED_BURN_SIZE; case ExtensionType.TokenMetadata: throw Error(`Cannot get type length for variable extension type: ${e}`); default: @@ -148,6 +152,7 @@ export function isMintExtension(e: ExtensionType): boolean { case ExtensionType.TokenGroupMember: case ExtensionType.ScaledUiAmountConfig: case ExtensionType.PausableConfig: + case ExtensionType.PermissionedBurn: return true; case ExtensionType.Uninitialized: case ExtensionType.TransferFeeAmount: @@ -192,6 +197,7 @@ export function isAccountExtension(e: ExtensionType): boolean { case ExtensionType.TokenGroupMember: case ExtensionType.ScaledUiAmountConfig: case ExtensionType.PausableConfig: + case ExtensionType.PermissionedBurn: return false; default: throw Error(`Unknown extension type: ${e}`); @@ -230,6 +236,7 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType { case ExtensionType.TokenGroupMember: case ExtensionType.ScaledUiAmountConfig: case ExtensionType.PausableAccount: + case ExtensionType.PermissionedBurn: return ExtensionType.Uninitialized; } } diff --git a/clients/js-legacy/src/extensions/index.ts b/clients/js-legacy/src/extensions/index.ts index 55a7b80ef..683248de5 100644 --- a/clients/js-legacy/src/extensions/index.ts +++ b/clients/js-legacy/src/extensions/index.ts @@ -17,3 +17,4 @@ export * from './transferFee/index.js'; export * from './permanentDelegate.js'; export * from './transferHook/index.js'; export * from './pausable/index.js'; +export * from './permissionedBurn/index.js'; diff --git a/clients/js-legacy/src/extensions/permissionedBurn/index.ts b/clients/js-legacy/src/extensions/permissionedBurn/index.ts new file mode 100644 index 000000000..8bf2a08d1 --- /dev/null +++ b/clients/js-legacy/src/extensions/permissionedBurn/index.ts @@ -0,0 +1,2 @@ +export * from './instructions.js'; +export * from './state.js'; diff --git a/clients/js-legacy/src/extensions/permissionedBurn/instructions.ts b/clients/js-legacy/src/extensions/permissionedBurn/instructions.ts new file mode 100644 index 000000000..8ab169736 --- /dev/null +++ b/clients/js-legacy/src/extensions/permissionedBurn/instructions.ts @@ -0,0 +1,179 @@ +import { struct, u8 } from '@solana/buffer-layout'; +import { publicKey, u64 } from '@solana/buffer-layout-utils'; +import type { PublicKey, Signer } from '@solana/web3.js'; +import { TransactionInstruction } from '@solana/web3.js'; +import { programSupportsExtensions, TOKEN_2022_PROGRAM_ID } from '../../constants.js'; +import { TokenUnsupportedInstructionError } from '../../errors.js'; +import { addSigners } from '../../instructions/internal.js'; +import { TokenInstruction } from '../../instructions/types.js'; + +export enum PermissionedBurnInstruction { + Initialize = 0, + Burn = 1, + BurnChecked = 2, +} + +interface InitializePermissionedBurnInstructionData { + instruction: TokenInstruction.PermissionedBurnExtension; + permissionedBurnInstruction: PermissionedBurnInstruction.Initialize; + authority: PublicKey; +} + +const initializePermissionedBurnInstructionData = struct([ + u8('instruction'), + u8('permissionedBurnInstruction'), + publicKey('authority'), +]); + +/** + * Construct a InitializePermissionedBurnConfig instruction + * + * @param mint Token mint account + * @param authority The permissioned burn mint's authority + * @param programId SPL Token program account + */ +export function createInitializePermissionedBurnInstruction( + mint: PublicKey, + authority: PublicKey, + programId = TOKEN_2022_PROGRAM_ID, +): TransactionInstruction { + if (!programSupportsExtensions(programId)) { + throw new TokenUnsupportedInstructionError(); + } + + const keys = [{ pubkey: mint, isSigner: false, isWritable: true }]; + const data = Buffer.alloc(initializePermissionedBurnInstructionData.span); + initializePermissionedBurnInstructionData.encode( + { + instruction: TokenInstruction.PermissionedBurnExtension, + permissionedBurnInstruction: PermissionedBurnInstruction.Initialize, + authority, + }, + data, + ); + + return new TransactionInstruction({ keys, programId, data }); +} + +interface PermissionedBurnInstructionData { + instruction: TokenInstruction.PermissionedBurnExtension; + permissionedBurnInstruction: PermissionedBurnInstruction.Burn; + amount: bigint; +} + +const permissionedBurnInstructionData = struct([ + u8('instruction'), + u8('permissionedBurnInstruction'), + u64('amount'), +]); + +/** + * Construct a permissioned burn instruction + * + * @param account Token account to update + * @param mint Token mint account + * @param owner The account's owner/delegate + * @param permissionedBurnAuthority Authority configured on the mint for permissioned burns + * @param amount Amount to burn + * @param multiSigners The signer account(s) + * @param programId SPL Token program account + */ +export function createPermissionedBurnInstruction( + account: PublicKey, + mint: PublicKey, + owner: PublicKey, + permissionedBurnAuthority: PublicKey, + amount: number | bigint, + multiSigners: (Signer | PublicKey)[] = [], + programId = TOKEN_2022_PROGRAM_ID, +): TransactionInstruction { + if (!programSupportsExtensions(programId)) { + throw new TokenUnsupportedInstructionError(); + } + + const keys = addSigners( + [ + { pubkey: account, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: true }, + { pubkey: permissionedBurnAuthority, isSigner: true, isWritable: false }, + ], + owner, + multiSigners, + ); + + const data = Buffer.alloc(permissionedBurnInstructionData.span); + permissionedBurnInstructionData.encode( + { + instruction: TokenInstruction.PermissionedBurnExtension, + permissionedBurnInstruction: PermissionedBurnInstruction.Burn, + amount: BigInt(amount), + }, + data, + ); + + return new TransactionInstruction({ keys, programId, data }); +} + +interface PermissionedBurnCheckedInstructionData { + instruction: TokenInstruction.PermissionedBurnExtension; + permissionedBurnInstruction: PermissionedBurnInstruction.BurnChecked; + amount: bigint; + decimals: number; +} + +const permissionedBurnCheckedInstructionData = struct([ + u8('instruction'), + u8('permissionedBurnInstruction'), + u64('amount'), + u8('decimals'), +]); + +/** + * Construct a checked permissioned burn instruction + * + * @param account Token account to update + * @param mint Token mint account + * @param owner The account's owner/delegate + * @param permissionedBurnAuthority Authority configured on the mint for permissioned burns + * @param amount Amount to burn + * @param decimals Number of the decimals of the mint + * @param multiSigners The signer account(s) + * @param programId SPL Token program account + */ +export function createPermissionedBurnCheckedInstruction( + account: PublicKey, + mint: PublicKey, + owner: PublicKey, + permissionedBurnAuthority: PublicKey, + amount: number | bigint, + decimals: number, + multiSigners: (Signer | PublicKey)[] = [], + programId = TOKEN_2022_PROGRAM_ID, +): TransactionInstruction { + if (!programSupportsExtensions(programId)) { + throw new TokenUnsupportedInstructionError(); + } + + const keys = addSigners( + [ + { pubkey: account, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: true }, + { pubkey: permissionedBurnAuthority, isSigner: true, isWritable: false }, + ], + owner, + multiSigners, + ); + + const data = Buffer.alloc(permissionedBurnCheckedInstructionData.span); + permissionedBurnCheckedInstructionData.encode( + { + instruction: TokenInstruction.PermissionedBurnExtension, + permissionedBurnInstruction: PermissionedBurnInstruction.BurnChecked, + amount: BigInt(amount), + decimals, + }, + data, + ); + + return new TransactionInstruction({ keys, programId, data }); +} diff --git a/clients/js-legacy/src/extensions/permissionedBurn/state.ts b/clients/js-legacy/src/extensions/permissionedBurn/state.ts new file mode 100644 index 000000000..0dc65e98d --- /dev/null +++ b/clients/js-legacy/src/extensions/permissionedBurn/state.ts @@ -0,0 +1,27 @@ +import { struct } from '@solana/buffer-layout'; +import { publicKey } from '@solana/buffer-layout-utils'; +import { PublicKey } from '@solana/web3.js'; +import type { Mint } from '../../state/mint.js'; +import { ExtensionType, getExtensionData } from '../extensionType.js'; + +/** Permissioned burn configuration as stored by the program */ +export interface PermissionedBurn { + authority: PublicKey | null; +} + +/** Buffer layout for de/serializing a permissioned burn config */ +export const PermissionedBurnLayout = struct<{ authority: PublicKey }>([publicKey('authority')]); + +export const PERMISSIONED_BURN_SIZE = PermissionedBurnLayout.span; + +export function getPermissionedBurn(mint: Mint): PermissionedBurn | null { + const extensionData = getExtensionData(ExtensionType.PermissionedBurn, mint.tlvData); + if (extensionData !== null) { + const { authority } = PermissionedBurnLayout.decode(extensionData); + return { + authority: authority.equals(PublicKey.default) ? null : authority, + }; + } else { + return null; + } +} diff --git a/clients/js-legacy/src/instructions/setAuthority.ts b/clients/js-legacy/src/instructions/setAuthority.ts index c37917147..d9c269887 100644 --- a/clients/js-legacy/src/instructions/setAuthority.ts +++ b/clients/js-legacy/src/instructions/setAuthority.ts @@ -32,6 +32,7 @@ export enum AuthorityType { GroupMemberPointer = 14, ScaledUiAmountConfig = 15, PausableConfig = 16, + PermissionedBurn = 17, } /** TODO: docs */ diff --git a/clients/js-legacy/src/instructions/types.ts b/clients/js-legacy/src/instructions/types.ts index 3befcfa7e..7be621dc8 100644 --- a/clients/js-legacy/src/instructions/types.ts +++ b/clients/js-legacy/src/instructions/types.ts @@ -46,4 +46,5 @@ export enum TokenInstruction { ScaledUiAmountExtension = 43, PausableExtension = 44, UnwrapLamports = 45, + PermissionedBurnExtension = 46, } diff --git a/clients/js-legacy/test/e2e-2022/permissionedBurn.test.ts b/clients/js-legacy/test/e2e-2022/permissionedBurn.test.ts new file mode 100644 index 000000000..0a0c07ca6 --- /dev/null +++ b/clients/js-legacy/test/e2e-2022/permissionedBurn.test.ts @@ -0,0 +1,119 @@ +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +use(chaiAsPromised); + +import type { Connection, PublicKey, Signer } from '@solana/web3.js'; +import { Keypair, SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js'; +import { + ExtensionType, + burn, + createAccount, + createInitializeMintInstruction, + createPermissionedBurnCheckedInstruction, + createInitializePermissionedBurnInstruction, + getMint, + getMintLen, + getPermissionedBurn, + mintTo, +} from '../../src'; +import { TEST_PROGRAM_ID, getConnection, newAccountWithLamports } from '../common'; + +const TEST_TOKEN_DECIMALS = 0; +const EXTENSIONS = [ExtensionType.PermissionedBurn]; + +describe('permissioned burn', () => { + let connection: Connection; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let permissionedAuthority: Keypair; + before(async () => { + connection = await getConnection(); + payer = await newAccountWithLamports(connection, 1_000_000_000); + mintAuthority = Keypair.generate(); + permissionedAuthority = Keypair.generate(); + }); + + beforeEach(async () => { + const mintKeypair = Keypair.generate(); + mint = mintKeypair.publicKey; + const mintLen = getMintLen(EXTENSIONS); + const lamports = await connection.getMinimumBalanceForRentExemption(mintLen); + const transaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + space: mintLen, + lamports, + programId: TEST_PROGRAM_ID, + }), + createInitializePermissionedBurnInstruction(mint, permissionedAuthority.publicKey, TEST_PROGRAM_ID), + createInitializeMintInstruction(mint, TEST_TOKEN_DECIMALS, mintAuthority.publicKey, null, TEST_PROGRAM_ID), + ); + + await sendAndConfirmTransaction(connection, transaction, [payer, mintKeypair]); + }); + + it('initializes config', async () => { + const mintInfo = await getMint(connection, mint, undefined, TEST_PROGRAM_ID); + const permissionedConfig = getPermissionedBurn(mintInfo); + expect(permissionedConfig).to.not.equal(null); + if (permissionedConfig !== null) { + expect(permissionedConfig.authority).to.eql(permissionedAuthority.publicKey); + } + }); + + it('enforces permissioned authority for burn', async () => { + const owner = Keypair.generate(); + const account = await createAccount( + connection, + payer, + mint, + owner.publicKey, + undefined, + undefined, + TEST_PROGRAM_ID, + ); + await mintTo(connection, payer, mint, account, mintAuthority, 2, [], undefined, TEST_PROGRAM_ID); + + await expect( + burn(connection, payer, account, mint, owner, 1, [], undefined, TEST_PROGRAM_ID), + ).to.be.rejectedWith(Error); + + const wrongPermissioned = Keypair.generate(); + const badBurnTx = new Transaction().add( + createPermissionedBurnCheckedInstruction( + account, + mint, + owner.publicKey, + wrongPermissioned.publicKey, + 1, + TEST_TOKEN_DECIMALS, + [], + TEST_PROGRAM_ID, + ), + ); + await expect( + sendAndConfirmTransaction(connection, badBurnTx, [payer, owner, wrongPermissioned]), + ).to.be.rejectedWith(Error); + + const burnTx = new Transaction().add( + createPermissionedBurnCheckedInstruction( + account, + mint, + owner.publicKey, + permissionedAuthority.publicKey, + 1, + TEST_TOKEN_DECIMALS, + [], + TEST_PROGRAM_ID, + ), + ); + await sendAndConfirmTransaction(connection, burnTx, [payer, owner, permissionedAuthority]); + + const accountInfo = await connection.getTokenAccountBalance(account); + expect(accountInfo.value.uiAmount).to.eql(1); + const mintInfo = await getMint(connection, mint, undefined, TEST_PROGRAM_ID); + expect(mintInfo.supply).to.eql(BigInt(1)); + }); +}); diff --git a/clients/js/src/generated/instructions/index.ts b/clients/js/src/generated/instructions/index.ts index 25583b8e6..730b5440a 100644 --- a/clients/js/src/generated/instructions/index.ts +++ b/clients/js/src/generated/instructions/index.ts @@ -57,6 +57,7 @@ export * from './initializeMultisig2'; export * from './initializeNonTransferableMint'; export * from './initializePausableConfig'; export * from './initializePermanentDelegate'; +export * from './initializePermissionedBurn'; export * from './initializeScaledUiAmountMint'; export * from './initializeTokenGroup'; export * from './initializeTokenGroupMember'; @@ -66,6 +67,8 @@ export * from './initializeTransferHook'; export * from './mintTo'; export * from './mintToChecked'; export * from './pause'; +export * from './permissionedBurn'; +export * from './permissionedBurnChecked'; export * from './reallocate'; export * from './recoverNestedAssociatedToken'; export * from './removeTokenMetadataKey'; diff --git a/clients/js/src/generated/instructions/initializePermissionedBurn.ts b/clients/js/src/generated/instructions/initializePermissionedBurn.ts new file mode 100644 index 000000000..e1de968dd --- /dev/null +++ b/clients/js/src/generated/instructions/initializePermissionedBurn.ts @@ -0,0 +1,184 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getAddressDecoder, + getAddressEncoder, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyUint8Array, + type WritableAccount, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const INITIALIZE_PERMISSIONED_BURN_DISCRIMINATOR = 46; + +export function getInitializePermissionedBurnDiscriminatorBytes() { + return getU8Encoder().encode(INITIALIZE_PERMISSIONED_BURN_DISCRIMINATOR); +} + +export const INITIALIZE_PERMISSIONED_BURN_PERMISSIONED_BURN_DISCRIMINATOR = 0; + +export function getInitializePermissionedBurnPermissionedBurnDiscriminatorBytes() { + return getU8Encoder().encode( + INITIALIZE_PERMISSIONED_BURN_PERMISSIONED_BURN_DISCRIMINATOR + ); +} + +export type InitializePermissionedBurnInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMint extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountMint extends string + ? WritableAccount + : TAccountMint, + ...TRemainingAccounts, + ] + >; + +export type InitializePermissionedBurnInstructionData = { + discriminator: number; + permissionedBurnDiscriminator: number; + /** The public key for the account that is required for token burning. */ + authority: Address; +}; + +export type InitializePermissionedBurnInstructionDataArgs = { + /** The public key for the account that is required for token burning. */ + authority: Address; +}; + +export function getInitializePermissionedBurnInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['permissionedBurnDiscriminator', getU8Encoder()], + ['authority', getAddressEncoder()], + ]), + (value) => ({ + ...value, + discriminator: INITIALIZE_PERMISSIONED_BURN_DISCRIMINATOR, + permissionedBurnDiscriminator: + INITIALIZE_PERMISSIONED_BURN_PERMISSIONED_BURN_DISCRIMINATOR, + }) + ); +} + +export function getInitializePermissionedBurnInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['permissionedBurnDiscriminator', getU8Decoder()], + ['authority', getAddressDecoder()], + ]); +} + +export function getInitializePermissionedBurnInstructionDataCodec(): FixedSizeCodec< + InitializePermissionedBurnInstructionDataArgs, + InitializePermissionedBurnInstructionData +> { + return combineCodec( + getInitializePermissionedBurnInstructionDataEncoder(), + getInitializePermissionedBurnInstructionDataDecoder() + ); +} + +export type InitializePermissionedBurnInput< + TAccountMint extends string = string, +> = { + /** The mint account to initialize. */ + mint: Address; + authority: InitializePermissionedBurnInstructionDataArgs['authority']; +}; + +export function getInitializePermissionedBurnInstruction< + TAccountMint extends string, + TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, +>( + input: InitializePermissionedBurnInput, + config?: { programAddress?: TProgramAddress } +): InitializePermissionedBurnInstruction { + // Program address. + const programAddress = config?.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + mint: { value: input.mint ?? null, isWritable: true }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedAccount + >; + + // Original args. + const args = { ...input }; + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [getAccountMeta(accounts.mint)], + data: getInitializePermissionedBurnInstructionDataEncoder().encode( + args as InitializePermissionedBurnInstructionDataArgs + ), + programAddress, + } as InitializePermissionedBurnInstruction); +} + +export type ParsedInitializePermissionedBurnInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The mint account to initialize. */ + mint: TAccountMetas[0]; + }; + data: InitializePermissionedBurnInstructionData; +}; + +export function parseInitializePermissionedBurnInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData +): ParsedInitializePermissionedBurnInstruction { + if (instruction.accounts.length < 1) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { mint: getNextAccount() }, + data: getInitializePermissionedBurnInstructionDataDecoder().decode( + instruction.data + ), + }; +} diff --git a/clients/js/src/generated/instructions/permissionedBurn.ts b/clients/js/src/generated/instructions/permissionedBurn.ts new file mode 100644 index 000000000..39a35071b --- /dev/null +++ b/clients/js/src/generated/instructions/permissionedBurn.ts @@ -0,0 +1,270 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + AccountRole, + combineCodec, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const PERMISSIONED_BURN_DISCRIMINATOR = 46; + +export function getPermissionedBurnDiscriminatorBytes() { + return getU8Encoder().encode(PERMISSIONED_BURN_DISCRIMINATOR); +} + +export const PERMISSIONED_BURN_PERMISSIONED_BURN_DISCRIMINATOR = 1; + +export function getPermissionedBurnPermissionedBurnDiscriminatorBytes() { + return getU8Encoder().encode( + PERMISSIONED_BURN_PERMISSIONED_BURN_DISCRIMINATOR + ); +} + +export type PermissionedBurnInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountAccount extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountPermissionedBurnAuthority extends + | string + | AccountMeta = string, + TAccountAuthority extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountAccount extends string + ? WritableAccount + : TAccountAccount, + TAccountMint extends string + ? WritableAccount + : TAccountMint, + TAccountPermissionedBurnAuthority extends string + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountPermissionedBurnAuthority, + TAccountAuthority extends string + ? ReadonlyAccount + : TAccountAuthority, + ...TRemainingAccounts, + ] + >; + +export type PermissionedBurnInstructionData = { + discriminator: number; + permissionedBurnDiscriminator: number; + /** The amount of tokens to burn. */ + amount: bigint; +}; + +export type PermissionedBurnInstructionDataArgs = { + /** The amount of tokens to burn. */ + amount: number | bigint; +}; + +export function getPermissionedBurnInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['permissionedBurnDiscriminator', getU8Encoder()], + ['amount', getU64Encoder()], + ]), + (value) => ({ + ...value, + discriminator: PERMISSIONED_BURN_DISCRIMINATOR, + permissionedBurnDiscriminator: + PERMISSIONED_BURN_PERMISSIONED_BURN_DISCRIMINATOR, + }) + ); +} + +export function getPermissionedBurnInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['permissionedBurnDiscriminator', getU8Decoder()], + ['amount', getU64Decoder()], + ]); +} + +export function getPermissionedBurnInstructionDataCodec(): FixedSizeCodec< + PermissionedBurnInstructionDataArgs, + PermissionedBurnInstructionData +> { + return combineCodec( + getPermissionedBurnInstructionDataEncoder(), + getPermissionedBurnInstructionDataDecoder() + ); +} + +export type PermissionedBurnInput< + TAccountAccount extends string = string, + TAccountMint extends string = string, + TAccountPermissionedBurnAuthority extends string = string, + TAccountAuthority extends string = string, +> = { + /** The source account to burn from. */ + account: Address; + /** The token mint. */ + mint: Address; + /** Authority configured on the mint that must sign any permissioned burn instruction. */ + permissionedBurnAuthority: TransactionSigner; + /** The account's owner/delegate or its multisignature account. */ + authority: Address | TransactionSigner; + amount: PermissionedBurnInstructionDataArgs['amount']; + multiSigners?: Array; +}; + +export function getPermissionedBurnInstruction< + TAccountAccount extends string, + TAccountMint extends string, + TAccountPermissionedBurnAuthority extends string, + TAccountAuthority extends string, + TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, +>( + input: PermissionedBurnInput< + TAccountAccount, + TAccountMint, + TAccountPermissionedBurnAuthority, + TAccountAuthority + >, + config?: { programAddress?: TProgramAddress } +): PermissionedBurnInstruction< + TProgramAddress, + TAccountAccount, + TAccountMint, + TAccountPermissionedBurnAuthority, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountAuthority +> { + // Program address. + const programAddress = config?.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + account: { value: input.account ?? null, isWritable: true }, + mint: { value: input.mint ?? null, isWritable: true }, + permissionedBurnAuthority: { + value: input.permissionedBurnAuthority ?? null, + isWritable: false, + }, + authority: { value: input.authority ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedAccount + >; + + // Original args. + const args = { ...input }; + + // Remaining accounts. + const remainingAccounts: AccountMeta[] = (args.multiSigners ?? []).map( + (signer) => ({ + address: signer.address, + role: AccountRole.READONLY_SIGNER, + signer, + }) + ); + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta(accounts.account), + getAccountMeta(accounts.mint), + getAccountMeta(accounts.permissionedBurnAuthority), + getAccountMeta(accounts.authority), + ...remainingAccounts, + ], + data: getPermissionedBurnInstructionDataEncoder().encode( + args as PermissionedBurnInstructionDataArgs + ), + programAddress, + } as PermissionedBurnInstruction< + TProgramAddress, + TAccountAccount, + TAccountMint, + TAccountPermissionedBurnAuthority, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountAuthority + >); +} + +export type ParsedPermissionedBurnInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The source account to burn from. */ + account: TAccountMetas[0]; + /** The token mint. */ + mint: TAccountMetas[1]; + /** Authority configured on the mint that must sign any permissioned burn instruction. */ + permissionedBurnAuthority: TAccountMetas[2]; + /** The account's owner/delegate or its multisignature account. */ + authority: TAccountMetas[3]; + }; + data: PermissionedBurnInstructionData; +}; + +export function parsePermissionedBurnInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData +): ParsedPermissionedBurnInstruction { + if (instruction.accounts.length < 4) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + account: getNextAccount(), + mint: getNextAccount(), + permissionedBurnAuthority: getNextAccount(), + authority: getNextAccount(), + }, + data: getPermissionedBurnInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/clients/js/src/generated/instructions/permissionedBurnChecked.ts b/clients/js/src/generated/instructions/permissionedBurnChecked.ts new file mode 100644 index 000000000..ef6418487 --- /dev/null +++ b/clients/js/src/generated/instructions/permissionedBurnChecked.ts @@ -0,0 +1,279 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + AccountRole, + combineCodec, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const PERMISSIONED_BURN_CHECKED_DISCRIMINATOR = 46; + +export function getPermissionedBurnCheckedDiscriminatorBytes() { + return getU8Encoder().encode(PERMISSIONED_BURN_CHECKED_DISCRIMINATOR); +} + +export const PERMISSIONED_BURN_CHECKED_PERMISSIONED_BURN_DISCRIMINATOR = 2; + +export function getPermissionedBurnCheckedPermissionedBurnDiscriminatorBytes() { + return getU8Encoder().encode( + PERMISSIONED_BURN_CHECKED_PERMISSIONED_BURN_DISCRIMINATOR + ); +} + +export type PermissionedBurnCheckedInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountAccount extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountPermissionedBurnAuthority extends + | string + | AccountMeta = string, + TAccountAuthority extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountAccount extends string + ? WritableAccount + : TAccountAccount, + TAccountMint extends string + ? WritableAccount + : TAccountMint, + TAccountPermissionedBurnAuthority extends string + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountPermissionedBurnAuthority, + TAccountAuthority extends string + ? ReadonlyAccount + : TAccountAuthority, + ...TRemainingAccounts, + ] + >; + +export type PermissionedBurnCheckedInstructionData = { + discriminator: number; + permissionedBurnDiscriminator: number; + /** The amount of tokens to burn. */ + amount: bigint; + /** Expected number of base 10 digits to the right of the decimal place. */ + decimals: number; +}; + +export type PermissionedBurnCheckedInstructionDataArgs = { + /** The amount of tokens to burn. */ + amount: number | bigint; + /** Expected number of base 10 digits to the right of the decimal place. */ + decimals: number; +}; + +export function getPermissionedBurnCheckedInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['permissionedBurnDiscriminator', getU8Encoder()], + ['amount', getU64Encoder()], + ['decimals', getU8Encoder()], + ]), + (value) => ({ + ...value, + discriminator: PERMISSIONED_BURN_CHECKED_DISCRIMINATOR, + permissionedBurnDiscriminator: + PERMISSIONED_BURN_CHECKED_PERMISSIONED_BURN_DISCRIMINATOR, + }) + ); +} + +export function getPermissionedBurnCheckedInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['permissionedBurnDiscriminator', getU8Decoder()], + ['amount', getU64Decoder()], + ['decimals', getU8Decoder()], + ]); +} + +export function getPermissionedBurnCheckedInstructionDataCodec(): FixedSizeCodec< + PermissionedBurnCheckedInstructionDataArgs, + PermissionedBurnCheckedInstructionData +> { + return combineCodec( + getPermissionedBurnCheckedInstructionDataEncoder(), + getPermissionedBurnCheckedInstructionDataDecoder() + ); +} + +export type PermissionedBurnCheckedInput< + TAccountAccount extends string = string, + TAccountMint extends string = string, + TAccountPermissionedBurnAuthority extends string = string, + TAccountAuthority extends string = string, +> = { + /** The source account to burn from. */ + account: Address; + /** The token mint. */ + mint: Address; + /** Authority configured on the mint that must sign any permissioned burn instruction. */ + permissionedBurnAuthority: TransactionSigner; + /** The account's owner/delegate or its multisignature account. */ + authority: Address | TransactionSigner; + amount: PermissionedBurnCheckedInstructionDataArgs['amount']; + decimals: PermissionedBurnCheckedInstructionDataArgs['decimals']; + multiSigners?: Array; +}; + +export function getPermissionedBurnCheckedInstruction< + TAccountAccount extends string, + TAccountMint extends string, + TAccountPermissionedBurnAuthority extends string, + TAccountAuthority extends string, + TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, +>( + input: PermissionedBurnCheckedInput< + TAccountAccount, + TAccountMint, + TAccountPermissionedBurnAuthority, + TAccountAuthority + >, + config?: { programAddress?: TProgramAddress } +): PermissionedBurnCheckedInstruction< + TProgramAddress, + TAccountAccount, + TAccountMint, + TAccountPermissionedBurnAuthority, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountAuthority +> { + // Program address. + const programAddress = config?.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + account: { value: input.account ?? null, isWritable: true }, + mint: { value: input.mint ?? null, isWritable: true }, + permissionedBurnAuthority: { + value: input.permissionedBurnAuthority ?? null, + isWritable: false, + }, + authority: { value: input.authority ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedAccount + >; + + // Original args. + const args = { ...input }; + + // Remaining accounts. + const remainingAccounts: AccountMeta[] = (args.multiSigners ?? []).map( + (signer) => ({ + address: signer.address, + role: AccountRole.READONLY_SIGNER, + signer, + }) + ); + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta(accounts.account), + getAccountMeta(accounts.mint), + getAccountMeta(accounts.permissionedBurnAuthority), + getAccountMeta(accounts.authority), + ...remainingAccounts, + ], + data: getPermissionedBurnCheckedInstructionDataEncoder().encode( + args as PermissionedBurnCheckedInstructionDataArgs + ), + programAddress, + } as PermissionedBurnCheckedInstruction< + TProgramAddress, + TAccountAccount, + TAccountMint, + TAccountPermissionedBurnAuthority, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountAuthority + >); +} + +export type ParsedPermissionedBurnCheckedInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The source account to burn from. */ + account: TAccountMetas[0]; + /** The token mint. */ + mint: TAccountMetas[1]; + /** Authority configured on the mint that must sign any permissioned burn instruction. */ + permissionedBurnAuthority: TAccountMetas[2]; + /** The account's owner/delegate or its multisignature account. */ + authority: TAccountMetas[3]; + }; + data: PermissionedBurnCheckedInstructionData; +}; + +export function parsePermissionedBurnCheckedInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData +): ParsedPermissionedBurnCheckedInstruction { + if (instruction.accounts.length < 4) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + account: getNextAccount(), + mint: getNextAccount(), + permissionedBurnAuthority: getNextAccount(), + authority: getNextAccount(), + }, + data: getPermissionedBurnCheckedInstructionDataDecoder().decode( + instruction.data + ), + }; +} diff --git a/clients/js/src/generated/programs/token2022.ts b/clients/js/src/generated/programs/token2022.ts index c2b5f518b..580592c97 100644 --- a/clients/js/src/generated/programs/token2022.ts +++ b/clients/js/src/generated/programs/token2022.ts @@ -62,6 +62,7 @@ import { type ParsedInitializeNonTransferableMintInstruction, type ParsedInitializePausableConfigInstruction, type ParsedInitializePermanentDelegateInstruction, + type ParsedInitializePermissionedBurnInstruction, type ParsedInitializeScaledUiAmountMintInstruction, type ParsedInitializeTokenGroupInstruction, type ParsedInitializeTokenGroupMemberInstruction, @@ -71,6 +72,8 @@ import { type ParsedMintToCheckedInstruction, type ParsedMintToInstruction, type ParsedPauseInstruction, + type ParsedPermissionedBurnCheckedInstruction, + type ParsedPermissionedBurnInstruction, type ParsedReallocateInstruction, type ParsedRemoveTokenMetadataKeyInstruction, type ParsedResumeInstruction, @@ -219,6 +222,9 @@ export enum Token2022Instruction { UpdateTokenGroupUpdateAuthority, InitializeTokenGroupMember, UnwrapLamports, + InitializePermissionedBurn, + PermissionedBurn, + PermissionedBurnChecked, } export function identifyToken2022Instruction( @@ -676,6 +682,24 @@ export function identifyToken2022Instruction( if (containsBytes(data, getU8Encoder().encode(45), 0)) { return Token2022Instruction.UnwrapLamports; } + if ( + containsBytes(data, getU8Encoder().encode(46), 0) && + containsBytes(data, getU8Encoder().encode(0), 1) + ) { + return Token2022Instruction.InitializePermissionedBurn; + } + if ( + containsBytes(data, getU8Encoder().encode(46), 0) && + containsBytes(data, getU8Encoder().encode(1), 1) + ) { + return Token2022Instruction.PermissionedBurn; + } + if ( + containsBytes(data, getU8Encoder().encode(46), 0) && + containsBytes(data, getU8Encoder().encode(2), 1) + ) { + return Token2022Instruction.PermissionedBurnChecked; + } throw new Error( 'The provided instruction could not be identified as a token-2022 instruction.' ); @@ -947,4 +971,13 @@ export type ParsedToken2022Instruction< } & ParsedInitializeTokenGroupMemberInstruction) | ({ instructionType: Token2022Instruction.UnwrapLamports; - } & ParsedUnwrapLamportsInstruction); + } & ParsedUnwrapLamportsInstruction) + | ({ + instructionType: Token2022Instruction.InitializePermissionedBurn; + } & ParsedInitializePermissionedBurnInstruction) + | ({ + instructionType: Token2022Instruction.PermissionedBurn; + } & ParsedPermissionedBurnInstruction) + | ({ + instructionType: Token2022Instruction.PermissionedBurnChecked; + } & ParsedPermissionedBurnCheckedInstruction); diff --git a/clients/js/src/generated/types/authorityType.ts b/clients/js/src/generated/types/authorityType.ts index 6c7c8ae8b..9c3af5f61 100644 --- a/clients/js/src/generated/types/authorityType.ts +++ b/clients/js/src/generated/types/authorityType.ts @@ -33,6 +33,7 @@ export enum AuthorityType { GroupMemberPointer, ScaledUiAmount, Pause, + PermissionedBurn, } export type AuthorityTypeArgs = AuthorityType; diff --git a/clients/js/src/generated/types/extension.ts b/clients/js/src/generated/types/extension.ts index bcad0205e..c4cad72ac 100644 --- a/clients/js/src/generated/types/extension.ts +++ b/clients/js/src/generated/types/extension.ts @@ -275,7 +275,12 @@ export type Extension = newMultiplier: number; } | { __kind: 'PausableConfig'; authority: Option
; paused: boolean } - | { __kind: 'PausableAccount' }; + | { __kind: 'PausableAccount' } + | { + __kind: 'PermissionedBurn'; + /** Authority that is required for burning */ + authority: Option
; + }; export type ExtensionArgs = | { __kind: 'Uninitialized' } @@ -492,7 +497,12 @@ export type ExtensionArgs = authority: OptionOrNullable
; paused: boolean; } - | { __kind: 'PausableAccount' }; + | { __kind: 'PausableAccount' } + | { + __kind: 'PermissionedBurn'; + /** Authority that is required for burning */ + authority: OptionOrNullable
; + }; export function getExtensionEncoder(): Encoder { return getDiscriminatedUnionEncoder( @@ -816,6 +826,21 @@ export function getExtensionEncoder(): Encoder { ), ], ['PausableAccount', getUnitEncoder()], + [ + 'PermissionedBurn', + addEncoderSizePrefix( + getStructEncoder([ + [ + 'authority', + getOptionEncoder(getAddressEncoder(), { + prefix: null, + noneValue: 'zeroes', + }), + ], + ]), + getU16Encoder() + ), + ], ], { size: getU16Encoder() } ); @@ -1143,6 +1168,21 @@ export function getExtensionDecoder(): Decoder { ), ], ['PausableAccount', getUnitDecoder()], + [ + 'PermissionedBurn', + addDecoderSizePrefix( + getStructDecoder([ + [ + 'authority', + getOptionDecoder(getAddressDecoder(), { + prefix: null, + noneValue: 'zeroes', + }), + ], + ]), + getU16Decoder() + ), + ], ], { size: getU16Decoder() } ); @@ -1390,6 +1430,14 @@ export function extension( export function extension( kind: 'PausableAccount' ): GetDiscriminatedUnionVariant; +export function extension( + kind: 'PermissionedBurn', + data: GetDiscriminatedUnionVariantContent< + ExtensionArgs, + '__kind', + 'PermissionedBurn' + > +): GetDiscriminatedUnionVariant; export function extension( kind: K, data?: Data diff --git a/clients/js/src/getInitializeInstructionsForExtensions.ts b/clients/js/src/getInitializeInstructionsForExtensions.ts index f7d13e5a3..c0e02bb3a 100644 --- a/clients/js/src/getInitializeInstructionsForExtensions.ts +++ b/clients/js/src/getInitializeInstructionsForExtensions.ts @@ -28,6 +28,7 @@ import { getInitializeScaledUiAmountMintInstruction, getInitializeConfidentialTransferFeeInstruction, getInitializePausableConfigInstruction, + getInitializePermissionedBurnInstruction, } from './generated'; /** @@ -97,6 +98,22 @@ export function getPreInitializeInstructionsForMintExtensions( authority: extension.authority, }), ]; + case 'PermissionedBurn': { + const authority = isOption(extension.authority) + ? extension.authority + : wrapNullable(extension.authority); + if (isNone(authority)) { + throw new Error( + 'PermissionedBurn extension requires a permissioned burn authority' + ); + } + return [ + getInitializePermissionedBurnInstruction({ + mint, + authority: authority.value, + }), + ]; + } case 'GroupPointer': return [ getInitializeGroupPointerInstruction({ diff --git a/clients/js/test/extensions/permissionedBurn/initializePermissionedBurn.test.ts b/clients/js/test/extensions/permissionedBurn/initializePermissionedBurn.test.ts new file mode 100644 index 000000000..4ffecda7f --- /dev/null +++ b/clients/js/test/extensions/permissionedBurn/initializePermissionedBurn.test.ts @@ -0,0 +1,59 @@ +import { Account, generateKeyPairSigner, some } from '@solana/kit'; +import test from 'ava'; +import { + Mint, + extension, + fetchMint, + getInitializePermissionedBurnInstruction, +} from '../../../src'; +import { + createDefaultSolanaClient, + generateKeyPairSignerWithSol, + getCreateMintInstructions, + sendAndConfirmInstructions, +} from '../../_setup'; + +test('it initializes a mint with permissioned burn', async (t) => { + // Given a fresh client and signers + const client = createDefaultSolanaClient(); + const [authority, mint, permissionedBurnAuthority] = await Promise.all([ + generateKeyPairSignerWithSol(client), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + // And a permissioned burn extension + const permissionedBurnExtension = extension('PermissionedBurn', { + authority: some(permissionedBurnAuthority.address), + }); + + // When we create and initialize a mint account with this extension + const [createMintInstruction, initMintInstruction] = + await getCreateMintInstructions({ + authority: authority.address, + client, + extensions: [permissionedBurnExtension], + mint, + payer: authority, + }); + + await sendAndConfirmInstructions(client, authority, [ + createMintInstruction, + getInitializePermissionedBurnInstruction({ + mint: mint.address, + authority: permissionedBurnAuthority.address, + }), + initMintInstruction, + ]); + + // Then we expect the mint account to exist with the permissioned burn config + const mintAccount = await fetchMint(client.rpc, mint.address); + t.like(mintAccount, >{ + address: mint.address, + data: { + mintAuthority: some(authority.address), + isInitialized: true, + extensions: some([permissionedBurnExtension]), + }, + }); +}); diff --git a/clients/rust-legacy/src/token.rs b/clients/rust-legacy/src/token.rs index dd0c38631..551409d72 100644 --- a/clients/rust-legacy/src/token.rs +++ b/clients/rust-legacy/src/token.rs @@ -44,9 +44,9 @@ use { self, ConfidentialTransferFeeAmount, ConfidentialTransferFeeConfig, }, cpi_guard, default_account_state, group_member_pointer, group_pointer, - interest_bearing_mint, memo_transfer, metadata_pointer, pausable, scaled_ui_amount, - transfer_fee, transfer_hook, BaseStateWithExtensions, Extension, ExtensionType, - StateWithExtensionsOwned, + interest_bearing_mint, memo_transfer, metadata_pointer, pausable, permissioned_burn, + scaled_ui_amount, transfer_fee, transfer_hook, BaseStateWithExtensions, Extension, + ExtensionType, StateWithExtensionsOwned, }, instruction, solana_zk_sdk::{ @@ -201,6 +201,9 @@ pub enum ExtensionInitializationParams { PausableConfig { authority: Pubkey, }, + PermissionedBurnConfig { + authority: Pubkey, + }, ConfidentialMintBurn { supply_elgamal_pubkey: PodElGamalPubkey, decryptable_supply: PodAeCiphertext, @@ -226,6 +229,7 @@ impl ExtensionInitializationParams { Self::GroupMemberPointer { .. } => ExtensionType::GroupMemberPointer, Self::ScaledUiAmountConfig { .. } => ExtensionType::ScaledUiAmount, Self::PausableConfig { .. } => ExtensionType::Pausable, + Self::PermissionedBurnConfig { .. } => ExtensionType::PermissionedBurn, Self::ConfidentialMintBurn { .. } => ExtensionType::ConfidentialMintBurn, } } @@ -348,6 +352,9 @@ impl ExtensionInitializationParams { Self::PausableConfig { authority } => { pausable::instruction::initialize(token_program_id, mint, &authority) } + Self::PermissionedBurnConfig { authority } => { + permissioned_burn::instruction::initialize(token_program_id, mint, &authority) + } Self::ConfidentialMintBurn { supply_elgamal_pubkey, decryptable_supply, diff --git a/clients/rust-legacy/tests/permissioned_burn.rs b/clients/rust-legacy/tests/permissioned_burn.rs new file mode 100644 index 000000000..c725fe7c7 --- /dev/null +++ b/clients/rust-legacy/tests/permissioned_burn.rs @@ -0,0 +1,189 @@ +mod program_test; +use { + program_test::{TestContext, TokenContext}, + solana_program_test::tokio, + solana_sdk::{ + instruction::InstructionError, pubkey::Pubkey, signature::Signer, signer::keypair::Keypair, + transaction::TransactionError, transport::TransportError, + }, + spl_token_2022_interface::{ + error::TokenError, + extension::{ + permissioned_burn::{ + instruction as permissioned_burn_instruction, PermissionedBurnConfig, + }, + BaseStateWithExtensions, + }, + instruction::AuthorityType, + }, + spl_token_client::token::{ExtensionInitializationParams, TokenError as TokenClientError}, +}; + +fn client_error(token_error: TokenError) -> TokenClientError { + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError(0, InstructionError::Custom(token_error as u32)), + ))) +} + +#[tokio::test] +async fn success_initialize() { + let mut context = TestContext::new().await; + let authority = Keypair::new(); + context + .init_token_with_mint(vec![ + ExtensionInitializationParams::PermissionedBurnConfig { + authority: authority.pubkey(), + }, + ]) + .await + .unwrap(); + + let TokenContext { token, .. } = context.token_context.unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + + assert_eq!( + Option::::from(extension.authority), + Some(authority.pubkey()) + ); +} + +#[tokio::test] +async fn permissioned_burn_enforced() { + let mut context = TestContext::new().await; + let authority = Keypair::new(); + let new_authority = Keypair::new(); + context + .init_token_with_mint(vec![ + ExtensionInitializationParams::PermissionedBurnConfig { + authority: authority.pubkey(), + }, + ]) + .await + .unwrap(); + + let TokenContext { + token, + mint_authority, + decimals, + .. + } = context.token_context.unwrap(); + + let account_owner = Keypair::new(); + token + .create_auxiliary_token_account(&account_owner, &account_owner.pubkey()) + .await + .unwrap(); + let account = account_owner.pubkey(); + + // Mint some supply + token + .mint_to(&account, &mint_authority.pubkey(), 2, &[&mint_authority]) + .await + .unwrap(); + + // Standard burn should be rejected when the permissioned extension is set. + let error = token + .burn(&account, &account_owner.pubkey(), 1, &[&account_owner]) + .await + .unwrap_err(); + assert_eq!(error, client_error(TokenError::InvalidInstruction)); + + // Permissioned burn with the wrong permissioned authority fails. + let wrong_permissioned = Keypair::new(); + let ix_wrong = permissioned_burn_instruction::burn_checked( + &spl_token_2022_interface::id(), + &account, + token.get_address(), + &wrong_permissioned.pubkey(), + &account_owner.pubkey(), + &[], + 1, + decimals, + ) + .unwrap(); + let error = token + .process_ixs(&[ix_wrong], &[&account_owner, &wrong_permissioned]) + .await + .unwrap_err(); + assert_eq!( + error, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError(0, InstructionError::InvalidAccountData) + ))) + ); + + // Permissioned burn with the configured authority succeeds. + let ix_ok = permissioned_burn_instruction::burn_checked( + &spl_token_2022_interface::id(), + &account, + token.get_address(), + &authority.pubkey(), + &account_owner.pubkey(), + &[], + 1, + decimals, + ) + .unwrap(); + token + .process_ixs(&[ix_ok], &[&account_owner, &authority]) + .await + .unwrap(); + + let account_after = token.get_account_info(&account).await.unwrap(); + assert_eq!(account_after.base.amount, 1); + let mint_after = token.get_mint_info().await.unwrap(); + assert_eq!(mint_after.base.supply, 1); + + // Update permissioned burn authority and ensure new authority is enforced. + token + .set_authority( + token.get_address(), + &authority.pubkey(), + Some(&new_authority.pubkey()), + AuthorityType::PermissionedBurn, + &[&authority], + ) + .await + .unwrap(); + + // Old authority should no longer work. + let ix_old = permissioned_burn_instruction::burn_checked( + &spl_token_2022_interface::id(), + &account, + token.get_address(), + &authority.pubkey(), + &account_owner.pubkey(), + &[], + 1, + decimals, + ) + .unwrap(); + let err_old = token + .process_ixs(&[ix_old], &[&account_owner, &authority]) + .await + .unwrap_err(); + assert_eq!( + err_old, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError(0, InstructionError::InvalidAccountData) + ))) + ); + + // New authority should succeed. + let ix_new = permissioned_burn_instruction::burn_checked( + &spl_token_2022_interface::id(), + &account, + token.get_address(), + &new_authority.pubkey(), + &account_owner.pubkey(), + &[], + 1, + decimals, + ) + .unwrap(); + token + .process_ixs(&[ix_new], &[&account_owner, &new_authority]) + .await + .unwrap(); +} diff --git a/interface/idl.json b/interface/idl.json index 83d303fb7..6adf23c45 100644 --- a/interface/idl.json +++ b/interface/idl.json @@ -8547,6 +8547,336 @@ "offset": 0 } ] + }, + { + "kind": "instructionNode", + "name": "initializePermissionedBurn", + "docs": [ + "Require permissioned burn for the given mint account.", + "", + "Fails if the mint has already been initialized, so must be called before `InitializeMint`." + ], + "optionalAccountStrategy": "programId", + "accounts": [ + { + "kind": "instructionAccountNode", + "name": "mint", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": [ + "The mint account to initialize." + ] + } + ], + "arguments": [ + { + "kind": "instructionArgumentNode", + "name": "discriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 46 + } + }, + { + "kind": "instructionArgumentNode", + "name": "permissionedBurnDiscriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 0 + } + }, + { + "kind": "instructionArgumentNode", + "name": "authority", + "docs": [ + "The public key for the account that is required for token burning." + ], + "type": { + "kind": "publicKeyTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + }, + { + "kind": "fieldDiscriminatorNode", + "name": "permissionedBurnDiscriminator", + "offset": 1 + } + ] + }, + { + "kind": "instructionNode", + "name": "permissionedBurn", + "docs": [ + "Burn tokens when the mint has the permissioned burn extension enabled." + ], + "optionalAccountStrategy": "programId", + "accounts": [ + { + "kind": "instructionAccountNode", + "name": "account", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": [ + "The source account to burn from." + ] + }, + { + "kind": "instructionAccountNode", + "name": "mint", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": [ + "The token mint." + ] + }, + { + "kind": "instructionAccountNode", + "name": "permissionedBurnAuthority", + "isWritable": false, + "isSigner": true, + "isOptional": false, + "docs": [ + "Authority configured on the mint that must sign any permissioned burn instruction." + ] + }, + { + "kind": "instructionAccountNode", + "name": "authority", + "isWritable": false, + "isSigner": "either", + "isOptional": false, + "docs": [ + "The account's owner/delegate or its multisignature account." + ], + "defaultValue": { + "kind": "identityValueNode" + } + } + ], + "arguments": [ + { + "kind": "instructionArgumentNode", + "name": "discriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 46 + } + }, + { + "kind": "instructionArgumentNode", + "name": "permissionedBurnDiscriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 1 + } + }, + { + "kind": "instructionArgumentNode", + "name": "amount", + "docs": [ + "The amount of tokens to burn." + ], + "type": { + "kind": "numberTypeNode", + "format": "u64", + "endian": "le" + } + } + ], + "remainingAccounts": [ + { + "kind": "instructionRemainingAccountsNode", + "isOptional": true, + "isSigner": true, + "docs": [], + "value": { + "kind": "argumentValueNode", + "name": "multiSigners" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + }, + { + "kind": "fieldDiscriminatorNode", + "name": "permissionedBurnDiscriminator", + "offset": 1 + } + ] + }, + { + "kind": "instructionNode", + "name": "permissionedBurnChecked", + "docs": [ + "Burn tokens with expected decimals when the mint has the permissioned burn extension enabled." + ], + "optionalAccountStrategy": "programId", + "accounts": [ + { + "kind": "instructionAccountNode", + "name": "account", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": [ + "The source account to burn from." + ] + }, + { + "kind": "instructionAccountNode", + "name": "mint", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": [ + "The token mint." + ] + }, + { + "kind": "instructionAccountNode", + "name": "permissionedBurnAuthority", + "isWritable": false, + "isSigner": true, + "isOptional": false, + "docs": [ + "Authority configured on the mint that must sign any permissioned burn instruction." + ] + }, + { + "kind": "instructionAccountNode", + "name": "authority", + "isWritable": false, + "isSigner": "either", + "isOptional": false, + "docs": [ + "The account's owner/delegate or its multisignature account." + ], + "defaultValue": { + "kind": "identityValueNode" + } + } + ], + "arguments": [ + { + "kind": "instructionArgumentNode", + "name": "discriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 46 + } + }, + { + "kind": "instructionArgumentNode", + "name": "permissionedBurnDiscriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 2 + } + }, + { + "kind": "instructionArgumentNode", + "name": "amount", + "docs": [ + "The amount of tokens to burn." + ], + "type": { + "kind": "numberTypeNode", + "format": "u64", + "endian": "le" + } + }, + { + "kind": "instructionArgumentNode", + "name": "decimals", + "docs": [ + "Expected number of base 10 digits to the right of the decimal place." + ], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + } + } + ], + "remainingAccounts": [ + { + "kind": "instructionRemainingAccountsNode", + "isOptional": true, + "isSigner": true, + "docs": [], + "value": { + "kind": "argumentValueNode", + "name": "multiSigners" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + }, + { + "kind": "fieldDiscriminatorNode", + "name": "permissionedBurnDiscriminator", + "offset": 1 + } + ] } ], "definedTypes": [ @@ -8651,6 +8981,10 @@ { "kind": "enumEmptyVariantTypeNode", "name": "pause" + }, + { + "kind": "enumEmptyVariantTypeNode", + "name": "permissionedBurn" } ], "size": { @@ -9965,6 +10299,36 @@ { "kind": "enumEmptyVariantTypeNode", "name": "pausableAccount" + }, + { + "kind": "enumStructVariantTypeNode", + "name": "permissionedBurn", + "struct": { + "kind": "sizePrefixTypeNode", + "type": { + "kind": "structTypeNode", + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "authority", + "docs": [ + "Authority that is required for burning" + ], + "type": { + "kind": "zeroableOptionTypeNode", + "item": { + "kind": "publicKeyTypeNode" + } + } + } + ] + }, + "prefix": { + "kind": "numberTypeNode", + "format": "u16", + "endian": "le" + } + } } ], "size": { @@ -10881,4 +11245,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/interface/src/extension/mod.rs b/interface/src/extension/mod.rs index 232556803..d84c604b8 100644 --- a/interface/src/extension/mod.rs +++ b/interface/src/extension/mod.rs @@ -23,6 +23,7 @@ use { non_transferable::{NonTransferable, NonTransferableAccount}, pausable::{PausableAccount, PausableConfig}, permanent_delegate::PermanentDelegate, + permissioned_burn::PermissionedBurnConfig, scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::{TransferFeeAmount, TransferFeeConfig}, transfer_hook::{TransferHook, TransferHookAccount}, @@ -76,6 +77,8 @@ pub mod non_transferable; pub mod pausable; /// Permanent Delegate extension pub mod permanent_delegate; +/// Permissioned burn extension +pub mod permissioned_burn; /// Scaled UI Amount extension pub mod scaled_ui_amount; /// Token-group extension @@ -1119,6 +1122,8 @@ pub enum ExtensionType { Pausable, /// Indicates that the account belongs to a pausable mint PausableAccount, + /// Tokens burning requires approval from authority. + PermissionedBurn, /// Test variable-length mint extension #[cfg(test)] @@ -1204,6 +1209,7 @@ impl ExtensionType { ExtensionType::ScaledUiAmount => pod_get_packed_len::(), ExtensionType::Pausable => pod_get_packed_len::(), ExtensionType::PausableAccount => pod_get_packed_len::(), + ExtensionType::PermissionedBurn => pod_get_packed_len::(), #[cfg(test)] ExtensionType::AccountPaddingTest => pod_get_packed_len::(), #[cfg(test)] @@ -1270,7 +1276,8 @@ impl ExtensionType { | ExtensionType::ConfidentialMintBurn | ExtensionType::TokenGroupMember | ExtensionType::ScaledUiAmount - | ExtensionType::Pausable => AccountType::Mint, + | ExtensionType::Pausable + | ExtensionType::PermissionedBurn => AccountType::Mint, ExtensionType::ImmutableOwner | ExtensionType::TransferFeeAmount | ExtensionType::ConfidentialTransferAccount diff --git a/interface/src/extension/permissioned_burn/instruction.rs b/interface/src/extension/permissioned_burn/instruction.rs new file mode 100644 index 000000000..16324e147 --- /dev/null +++ b/interface/src/extension/permissioned_burn/instruction.rs @@ -0,0 +1,193 @@ +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use { + crate::{ + check_program_account, + instruction::{encode_instruction, TokenInstruction}, + }, + bytemuck::{Pod, Zeroable}, + num_enum::{IntoPrimitive, TryFromPrimitive}, + solana_instruction::{AccountMeta, Instruction}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + spl_pod::primitives::PodU64, +}; + +/// Permissioned Burn extension instructions +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Debug, PartialEq, IntoPrimitive, TryFromPrimitive)] +#[repr(u8)] +pub enum PermissionedBurnInstruction { + /// Require permissioned burn for the given mint account + /// + /// Accounts expected by this instruction: + /// + /// 0. `[writable]` The mint account to initialize. + /// + /// Data expected by this instruction: + /// `crate::extension::permissioned_burn::instruction::InitializeInstructionData` + Initialize, + /// Burn tokens when the mint has the permissioned burn extension enabled. + /// + /// Accounts expected by this instruction: + /// + /// * Single authority + /// 0. `[writable]` The source account to burn from. + /// 1. `[writable]` The token mint. + /// 2. `[signer]` The permissioned burn authority configured on the mint, + /// if any. + /// 3. `[signer]` The source account's owner/delegate. + /// + /// * Multisignature authority + /// 0. `[writable]` The source account to burn from. + /// 1. `[writable]` The token mint. + /// 2. `[signer]` The permissioned burn authority configured on the mint, + /// if any. + /// 3. `[]` The source account's multisignature owner/delegate. + /// 4. `..4+M` `[signer]` M signer accounts for the multisig. + /// + /// Data expected by this instruction: + /// `crate::extension::permissioned_burn::instruction::BurnInstructionData` + Burn, + /// Burn tokens with expected decimals when the mint has the permissioned + /// burn extension enabled. + /// + /// Accounts expected by this instruction match `Burn`. + /// + /// Data expected by this instruction: + /// `crate::extension::permissioned_burn::instruction::BurnCheckedInstructionData` + BurnChecked, +} + +/// Data expected by `PermissionedBurnInstruction::Initialize` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct InitializeInstructionData { + /// The public key for the account that is required for token burning. + pub authority: Pubkey, +} + +/// Data expected by `PermissionedBurnInstruction::Burn` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct BurnInstructionData { + /// The amount of tokens to burn. + pub amount: PodU64, +} + +/// Data expected by `PermissionedBurnInstruction::BurnChecked` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct BurnCheckedInstructionData { + /// The amount of tokens to burn. + pub amount: PodU64, + /// Expected number of base 10 digits to the right of the decimal place. + pub decimals: u8, +} + +/// Create an `Initialize` instruction +pub fn initialize( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: &Pubkey, +) -> Result { + check_program_account(token_program_id)?; + let accounts = vec![AccountMeta::new(*mint, false)]; + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::PermissionedBurnExtension, + PermissionedBurnInstruction::Initialize, + &InitializeInstructionData { + authority: *authority, + }, + )) +} + +/// Create a `Burn` instruction using the permissioned burn extension. +pub fn burn( + token_program_id: &Pubkey, + account: &Pubkey, + mint: &Pubkey, + permissioned_burn_authority: &Pubkey, + authority: &Pubkey, + signer_pubkeys: &[&Pubkey], + amount: u64, +) -> Result { + check_program_account(token_program_id)?; + let data = BurnInstructionData { + amount: amount.into(), + }; + + let mut accounts = Vec::with_capacity(4 + signer_pubkeys.len()); + accounts.push(AccountMeta::new(*account, false)); + accounts.push(AccountMeta::new(*mint, false)); + accounts.push(AccountMeta::new_readonly( + *permissioned_burn_authority, + true, + )); + accounts.push(AccountMeta::new_readonly( + *authority, + signer_pubkeys.is_empty(), + )); + for signer_pubkey in signer_pubkeys.iter() { + accounts.push(AccountMeta::new_readonly(**signer_pubkey, true)); + } + + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::PermissionedBurnExtension, + PermissionedBurnInstruction::Burn, + &data, + )) +} + +/// Create a `BurnChecked` instruction using the permissioned burn extension. +#[allow(clippy::too_many_arguments)] +pub fn burn_checked( + token_program_id: &Pubkey, + account: &Pubkey, + mint: &Pubkey, + permissioned_burn_authority: &Pubkey, + authority: &Pubkey, + signer_pubkeys: &[&Pubkey], + amount: u64, + decimals: u8, +) -> Result { + check_program_account(token_program_id)?; + let data = BurnCheckedInstructionData { + amount: amount.into(), + decimals, + }; + + let mut accounts = Vec::with_capacity(4 + signer_pubkeys.len()); + accounts.push(AccountMeta::new(*account, false)); + accounts.push(AccountMeta::new(*mint, false)); + accounts.push(AccountMeta::new_readonly( + *permissioned_burn_authority, + true, + )); + accounts.push(AccountMeta::new_readonly( + *authority, + signer_pubkeys.is_empty(), + )); + for signer_pubkey in signer_pubkeys.iter() { + accounts.push(AccountMeta::new_readonly(**signer_pubkey, true)); + } + + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::PermissionedBurnExtension, + PermissionedBurnInstruction::BurnChecked, + &data, + )) +} diff --git a/interface/src/extension/permissioned_burn/mod.rs b/interface/src/extension/permissioned_burn/mod.rs new file mode 100644 index 000000000..636e38450 --- /dev/null +++ b/interface/src/extension/permissioned_burn/mod.rs @@ -0,0 +1,24 @@ +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use { + crate::extension::{Extension, ExtensionType}, + bytemuck::{Pod, Zeroable}, + spl_pod::optional_keys::OptionalNonZeroPubkey, +}; + +/// Instruction types for the permissioned burn extension +pub mod instruction; + +/// Indicates that the tokens from this mint require permissioned burn +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] +#[repr(C)] +pub struct PermissionedBurnConfig { + /// Authority that is required for burning + pub authority: OptionalNonZeroPubkey, +} + +impl Extension for PermissionedBurnConfig { + const TYPE: ExtensionType = ExtensionType::PermissionedBurn; +} diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 79d1f9267..4f2d4d598 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -731,6 +731,8 @@ pub enum TokenInstruction<'a> { ScaledUiAmountExtension, /// Instruction prefix for instructions to the pausable extension PausableExtension, + /// Instruction prefix for instructions to the permissioned burn extension + PermissionedBurnExtension, // 45 /// Transfer lamports from a native SOL account to a destination account. /// @@ -891,6 +893,7 @@ impl<'a> TokenInstruction<'a> { 42 => Self::ConfidentialMintBurnExtension, 43 => Self::ScaledUiAmountExtension, 44 => Self::PausableExtension, + 46 => Self::PermissionedBurnExtension, 45 => { let (amount, _rest) = Self::unpack_u64_option(rest)?; Self::UnwrapLamports { amount } @@ -1079,6 +1082,9 @@ impl<'a> TokenInstruction<'a> { buf.push(45); Self::pack_u64_option(&amount, &mut buf); } + &Self::PermissionedBurnExtension => { + buf.push(46); + } }; buf } @@ -1201,6 +1207,8 @@ pub enum AuthorityType { ScaledUiAmount, /// Authority to pause or resume minting / transferring / burning Pause, + /// Authority to perform a permissioned token burn + PermissionedBurn, } impl AuthorityType { @@ -1223,6 +1231,7 @@ impl AuthorityType { AuthorityType::GroupMemberPointer => 14, AuthorityType::ScaledUiAmount => 15, AuthorityType::Pause => 16, + AuthorityType::PermissionedBurn => 17, } } @@ -1246,6 +1255,7 @@ impl AuthorityType { 14 => Ok(AuthorityType::GroupMemberPointer), 15 => Ok(AuthorityType::ScaledUiAmount), 16 => Ok(AuthorityType::Pause), + 17 => Ok(AuthorityType::PermissionedBurn), _ => Err(TokenError::InvalidInstruction.into()), } } diff --git a/program/src/extension/mod.rs b/program/src/extension/mod.rs index 89be5eb39..aadb95847 100644 --- a/program/src/extension/mod.rs +++ b/program/src/extension/mod.rs @@ -28,6 +28,8 @@ pub mod non_transferable; pub mod pausable; /// Permanent Delegate extension pub mod permanent_delegate; +/// Permissioned burn extension +pub mod permissioned_burn; /// Utility to reallocate token accounts pub mod reallocate; /// Scaled UI Amount extension diff --git a/program/src/extension/permissioned_burn/instruction.rs b/program/src/extension/permissioned_burn/instruction.rs new file mode 100644 index 000000000..d93ad582d --- /dev/null +++ b/program/src/extension/permissioned_burn/instruction.rs @@ -0,0 +1,5 @@ +#![deprecated( + since = "9.1.0", + note = "Use spl_token_2022_interface instead and remove spl_token_2022 as a dependency" +)] +pub use spl_token_2022_interface::extension::permissioned_burn::instruction::*; diff --git a/program/src/extension/permissioned_burn/mod.rs b/program/src/extension/permissioned_burn/mod.rs new file mode 100644 index 000000000..cf8bbbfcd --- /dev/null +++ b/program/src/extension/permissioned_burn/mod.rs @@ -0,0 +1,10 @@ +/// Instruction types for the permissioned burn extension +pub mod instruction; +/// Instruction processor for the permissioned burn extension +pub mod processor; + +#[deprecated( + since = "9.1.0", + note = "Use spl_token_2022_interface instead and remove spl_token_2022 as a dependency" +)] +pub use spl_token_2022_interface::extension::permissioned_burn::PermissionedBurnConfig; diff --git a/program/src/extension/permissioned_burn/processor.rs b/program/src/extension/permissioned_burn/processor.rs new file mode 100644 index 000000000..df3cabba9 --- /dev/null +++ b/program/src/extension/permissioned_burn/processor.rs @@ -0,0 +1,78 @@ +use { + crate::{ + pod_instruction::{AmountCheckedData, AmountData}, + processor::{BurnInstructionVariant, InstructionVariant, Processor}, + }, + solana_account_info::{next_account_info, AccountInfo}, + solana_msg::msg, + solana_program_error::ProgramResult, + solana_pubkey::Pubkey, + spl_token_2022_interface::{ + check_program_account, + extension::{ + permissioned_burn::{ + instruction::{InitializeInstructionData, PermissionedBurnInstruction}, + PermissionedBurnConfig, + }, + BaseStateWithExtensionsMut, PodStateWithExtensionsMut, + }, + instruction::{decode_instruction_data, decode_instruction_type}, + pod::PodMint, + }, +}; + +fn process_initialize( + _program_id: &Pubkey, + accounts: &[AccountInfo], + authority: &Pubkey, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mint_account_info = next_account_info(account_info_iter)?; + let mut mint_data = mint_account_info.data.borrow_mut(); + let mut mint = PodStateWithExtensionsMut::::unpack_uninitialized(&mut mint_data)?; + + let extension = mint.init_extension::(true)?; + extension.authority = Some(*authority).try_into()?; + + Ok(()) +} + +pub(crate) fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + check_program_account(program_id)?; + + match decode_instruction_type(input)? { + PermissionedBurnInstruction::Initialize => { + msg!("PermissionedBurnInstruction::Initialize"); + let InitializeInstructionData { authority } = decode_instruction_data(input)?; + process_initialize(program_id, accounts, authority) + } + PermissionedBurnInstruction::Burn => { + msg!("PermissionedBurnInstruction::Burn"); + let data = decode_instruction_data::(input)?; + Processor::process_burn( + program_id, + accounts, + data.amount.into(), + BurnInstructionVariant::Permissioned, + InstructionVariant::Unchecked, + ) + } + PermissionedBurnInstruction::BurnChecked => { + msg!("PermissionedBurnInstruction::BurnChecked"); + let data = decode_instruction_data::(input)?; + Processor::process_burn( + program_id, + accounts, + data.amount.into(), + BurnInstructionVariant::Permissioned, + InstructionVariant::Checked { + decimals: data.decimals, + }, + ) + } + } +} diff --git a/program/src/pod_instruction.rs b/program/src/pod_instruction.rs index 19c033982..9055cd560 100644 --- a/program/src/pod_instruction.rs +++ b/program/src/pod_instruction.rs @@ -117,6 +117,7 @@ pub(crate) enum PodTokenInstruction { PausableExtension, // 45 UnwrapLamports, + PermissionedBurnExtension, } fn unpack_pubkey_option(input: &[u8]) -> Result, ProgramError> { diff --git a/program/src/processor.rs b/program/src/processor.rs index d4d93b8ec..183f15677 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -7,8 +7,8 @@ use { cpi_guard::{self, in_cpi}, default_account_state, group_member_pointer, group_pointer, interest_bearing_mint, memo_transfer::{self, check_previous_sibling_instruction_is_memo}, - metadata_pointer, pausable, reallocate, scaled_ui_amount, token_group, token_metadata, - transfer_fee, transfer_hook, + metadata_pointer, pausable, permissioned_burn, reallocate, scaled_ui_amount, + token_group, token_metadata, transfer_fee, transfer_hook, }, pod_instruction::{ decode_instruction_data_with_coption_pubkey, decode_instruction_data_with_coption_u64, @@ -52,6 +52,7 @@ use { non_transferable::{NonTransferable, NonTransferableAccount}, pausable::{PausableAccount, PausableConfig}, permanent_delegate::{get_permanent_delegate, PermanentDelegate}, + permissioned_burn::PermissionedBurnConfig, scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::{TransferFeeAmount, TransferFeeConfig}, transfer_hook::{TransferHook, TransferHookAccount}, @@ -82,6 +83,15 @@ pub(crate) enum InstructionVariant { Checked { decimals: u8 }, } +/// Burn instruction variant. Standard variants must not be used with the +/// permissioned burn extension. +/// +/// Permissioned variants require the extra authority to sign. +pub(crate) enum BurnInstructionVariant { + Standard, + Permissioned, +} + /// Program state handler. pub struct Processor {} impl Processor { @@ -967,6 +977,19 @@ impl Processor { )?; extension.authority = new_authority.try_into()?; } + AuthorityType::PermissionedBurn => { + let extension = mint.get_extension_mut::()?; + let maybe_authority: Option = extension.authority.into(); + let authority = maybe_authority.ok_or(TokenError::AuthorityTypeNotSupported)?; + Self::validate_owner( + program_id, + &authority, + authority_info, + authority_info_data_len, + account_info_iter.as_slice(), + )?; + extension.authority = new_authority.try_into()?; + } _ => { return Err(TokenError::AuthorityTypeNotSupported.into()); } @@ -1072,20 +1095,67 @@ impl Processor { program_id: &Pubkey, accounts: &[AccountInfo], amount: u64, + burn_variant: BurnInstructionVariant, instruction_variant: InstructionVariant, ) -> ProgramResult { let account_info_iter = &mut accounts.iter(); let source_account_info = next_account_info(account_info_iter)?; let mint_info = next_account_info(account_info_iter)?; - let authority_info = next_account_info(account_info_iter)?; + let (permissioned_burn_authority_info, authority_info) = match burn_variant { + BurnInstructionVariant::Permissioned => { + let permissioned_burn_authority_info = next_account_info(account_info_iter)?; + let authority_info = next_account_info(account_info_iter)?; + (Some(permissioned_burn_authority_info), authority_info) + } + BurnInstructionVariant::Standard => (None, next_account_info(account_info_iter)?), + }; + let authority_info_data_len = authority_info.data_len(); + let mut mint_data = mint_info.data.borrow_mut(); + let mint = PodStateWithExtensionsMut::::unpack(&mut mint_data)?; + + let permissioned_ext = mint.get_extension::(); + let maybe_permissioned_burn_authority = permissioned_ext + .as_ref() + .ok() + .and_then(|ext| Option::::from(ext.authority)); + + match burn_variant { + BurnInstructionVariant::Standard => { + // Standard burns cannot be used when the permissioned burn + // extension is present. + if maybe_permissioned_burn_authority.is_some() { + return Err(TokenError::InvalidInstruction.into()); + } + } + BurnInstructionVariant::Permissioned => { + permissioned_ext.map_err(|_| TokenError::InvalidInstruction)?; + + let expected_burn_authority = + maybe_permissioned_burn_authority.ok_or_else(|| { + msg!("Permissioned burn authority is None; use the standard burn"); + TokenError::InvalidInstruction + })?; + + // Pull the required extra signer from the accounts + let approver_ai = + permissioned_burn_authority_info.ok_or(ProgramError::NotEnoughAccountKeys)?; + + if !approver_ai.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + if *approver_ai.key != expected_burn_authority { + return Err(ProgramError::InvalidAccountData); + } + } + } + let mut source_account_data = source_account_info.data.borrow_mut(); let source_account = PodStateWithExtensionsMut::::unpack(&mut source_account_data)?; - let mut mint_data = mint_info.data.borrow_mut(); - let mint = PodStateWithExtensionsMut::::unpack(&mut mint_data)?; if source_account.base.is_frozen() { return Err(TokenError::AccountFrozen.into()); @@ -1110,6 +1180,7 @@ impl Processor { return Err(TokenError::MintPaused.into()); } } + let maybe_permanent_delegate = get_permanent_delegate(&mint); if let Ok(cpi_guard) = source_account.get_extension::() { @@ -1816,6 +1887,7 @@ impl Processor { program_id, accounts, data.amount.into(), + BurnInstructionVariant::Standard, InstructionVariant::Unchecked, ) } @@ -1874,6 +1946,7 @@ impl Processor { program_id, accounts, data.amount.into(), + BurnInstructionVariant::Standard, InstructionVariant::Checked { decimals: data.decimals, }, @@ -2014,6 +2087,14 @@ impl Processor { msg!("Instruction: PausableExtension"); pausable::processor::process_instruction(program_id, accounts, &input[1..]) } + PodTokenInstruction::PermissionedBurnExtension => { + msg!("Instruction: PermissionedBurnExtension"); + permissioned_burn::processor::process_instruction( + program_id, + accounts, + &input[1..], + ) + } PodTokenInstruction::UnwrapLamports => { msg!("Instruction: UnwrapLamports"); let (_, amount) = decode_instruction_data_with_coption_u64::<()>(input)?; @@ -2125,7 +2206,12 @@ mod tests { solana_program_option::COption, solana_sdk_ids::sysvar::rent, spl_token_2022_interface::{ - extension::transfer_fee::instruction::initialize_transfer_fee_config, instruction::*, + extension::{ + permissioned_burn, transfer_fee::instruction::initialize_transfer_fee_config, + ExtensionType, + }, + instruction::*, + pod::PodMint, state::Multisig, }, std::sync::{Arc, RwLock}, @@ -6838,6 +6924,109 @@ mod tests { .unwrap(); } + #[test] + fn test_permissioned_burn_none_authority_errors() { + let program_id = crate::id(); + let mint_key = Pubkey::new_unique(); + let owner_key = Pubkey::new_unique(); + let burn_authority_key = Pubkey::new_unique(); + let account_key = Pubkey::new_unique(); + + let mint_size = + ExtensionType::try_calculate_account_len::(&[ExtensionType::PermissionedBurn]) + .unwrap(); + + let mut mint_account = SolanaAccount::new( + Rent::default().minimum_balance(mint_size), + mint_size, + &program_id, + ); + let mut account_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let mut owner_account = SolanaAccount::default(); + let mut burn_authority_account = SolanaAccount::default(); + let mut rent_sysvar = rent_sysvar(); + + do_process_instruction( + permissioned_burn::instruction::initialize(&program_id, &mint_key, &burn_authority_key) + .unwrap(), + vec![&mut mint_account], + ) + .unwrap(); + do_process_instruction( + initialize_mint(&program_id, &mint_key, &owner_key, None, 2).unwrap(), + vec![&mut mint_account, &mut rent_sysvar], + ) + .unwrap(); + + // Create account and mint some tokens. + do_process_instruction( + initialize_account(&program_id, &account_key, &mint_key, &owner_key).unwrap(), + vec![ + &mut account_account, + &mut mint_account, + &mut owner_account, + &mut rent_sysvar, + ], + ) + .unwrap(); + do_process_instruction( + mint_to(&program_id, &mint_key, &account_key, &owner_key, &[], 10).unwrap(), + vec![&mut mint_account, &mut account_account, &mut owner_account], + ) + .unwrap(); + + // Clear the permissioned burn authority. + do_process_instruction( + set_authority( + &program_id, + &mint_key, + None, + AuthorityType::PermissionedBurn, + &burn_authority_key, + &[], + ) + .unwrap(), + vec![&mut mint_account, &mut burn_authority_account], + ) + .unwrap(); + + // Attempt a permissioned burn should fail when authority is None. + assert_eq!( + Err(TokenError::InvalidInstruction.into()), + do_process_instruction( + permissioned_burn::instruction::burn( + &program_id, + &account_key, + &mint_key, + &burn_authority_key, + &owner_key, + &[], + 1 + ) + .unwrap(), + vec![ + &mut account_account, + &mut mint_account, + &mut burn_authority_account, + &mut owner_account + ], + ) + ); + + // Standard burn should still succeed after authority is cleared. + assert_eq!( + Ok(()), + do_process_instruction( + burn(&program_id, &account_key, &mint_key, &owner_key, &[], 1).unwrap(), + vec![&mut account_account, &mut mint_account, &mut owner_account], + ) + ); + } + #[test] fn test_validate_owner() { let program_id = crate::id(); diff --git a/scripts/solana.dic b/scripts/solana.dic index df7bf086b..06948e019 100644 --- a/scripts/solana.dic +++ b/scripts/solana.dic @@ -69,3 +69,4 @@ cryptographic cryptographically prover encryptions +permissioned