From 8947647e7ea973100eeaebdf2430f2fe76e28956 Mon Sep 17 00:00:00 2001 From: Dogukan Ali Gundogan <59707019+DogukanGun@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:35:43 +0100 Subject: [PATCH] Add withdraw excess with test cases (#35) * Add withdraw excess with test cases * Fix idl * Arrange test case according to best practice * Apply best practices to test and order idl.json * Fix conflict in idl.json * Update code according to review * Add idl changes * Review update * Formatting * Fix conflict * Revert idl.json * Add withdraw excess into idl.json * Fix test case * Change the owner to payer --- .../js/src/generated/instructions/index.ts | 1 + .../instructions/withdrawExcessLamports.ts | 223 ++++++++++++++++++ .../js/src/generated/programs/token2022.ts | 8 + clients/js/test/withdrawExcess.test.ts | 102 ++++++++ program/idl.json | 79 ++++++- 5 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 clients/js/src/generated/instructions/withdrawExcessLamports.ts create mode 100644 clients/js/test/withdrawExcess.test.ts diff --git a/clients/js/src/generated/instructions/index.ts b/clients/js/src/generated/instructions/index.ts index 1d91fbb..68e1913 100644 --- a/clients/js/src/generated/instructions/index.ts +++ b/clients/js/src/generated/instructions/index.ts @@ -84,6 +84,7 @@ export * from './updateTokenGroupUpdateAuthority'; export * from './updateTokenMetadataField'; export * from './updateTokenMetadataUpdateAuthority'; export * from './updateTransferHook'; +export * from './withdrawExcessLamports'; export * from './withdrawWithheldTokensFromAccounts'; export * from './withdrawWithheldTokensFromAccountsForConfidentialTransferFee'; export * from './withdrawWithheldTokensFromMint'; diff --git a/clients/js/src/generated/instructions/withdrawExcessLamports.ts b/clients/js/src/generated/instructions/withdrawExcessLamports.ts new file mode 100644 index 0000000..a9e01b4 --- /dev/null +++ b/clients/js/src/generated/instructions/withdrawExcessLamports.ts @@ -0,0 +1,223 @@ +/** + * 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, + getU8Decoder, + getU8Encoder, + transformEncoder, + type Address, + type Codec, + type Decoder, + type Encoder, + type IAccountMeta, + type IAccountSignerMeta, + type IInstruction, + type IInstructionWithAccounts, + type IInstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type TransactionSigner, + type WritableAccount, +} from '@solana/web3.js'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const WITHDRAW_EXCESS_LAMPORTS_DISCRIMINATOR = 38; + +export function getWithdrawExcessLamportsDiscriminatorBytes() { + return getU8Encoder().encode(WITHDRAW_EXCESS_LAMPORTS_DISCRIMINATOR); +} + +export type WithdrawExcessLamportsInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountSourceAccount extends string | IAccountMeta = string, + TAccountDestinationAccount extends string | IAccountMeta = string, + TAccountAuthority extends string | IAccountMeta = string, + TRemainingAccounts extends readonly IAccountMeta[] = [], +> = IInstruction & + IInstructionWithData & + IInstructionWithAccounts< + [ + TAccountSourceAccount extends string + ? WritableAccount + : TAccountSourceAccount, + TAccountDestinationAccount extends string + ? WritableAccount + : TAccountDestinationAccount, + TAccountAuthority extends string + ? ReadonlyAccount + : TAccountAuthority, + ...TRemainingAccounts, + ] + >; + +export type WithdrawExcessLamportsInstructionData = { discriminator: number }; + +export type WithdrawExcessLamportsInstructionDataArgs = {}; + +export function getWithdrawExcessLamportsInstructionDataEncoder(): Encoder { + return transformEncoder( + getStructEncoder([['discriminator', getU8Encoder()]]), + (value) => ({ + ...value, + discriminator: WITHDRAW_EXCESS_LAMPORTS_DISCRIMINATOR, + }) + ); +} + +export function getWithdrawExcessLamportsInstructionDataDecoder(): Decoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getWithdrawExcessLamportsInstructionDataCodec(): Codec< + WithdrawExcessLamportsInstructionDataArgs, + WithdrawExcessLamportsInstructionData +> { + return combineCodec( + getWithdrawExcessLamportsInstructionDataEncoder(), + getWithdrawExcessLamportsInstructionDataDecoder() + ); +} + +export type WithdrawExcessLamportsInput< + TAccountSourceAccount extends string = string, + TAccountDestinationAccount extends string = string, + TAccountAuthority extends string = string, +> = { + /** Account holding excess lamports. */ + sourceAccount: Address; + /** Destination account for withdrawn lamports. */ + destinationAccount: Address; + /** The source account's owner/delegate or its multisignature account. */ + authority: Address | TransactionSigner; + multiSigners?: Array; +}; + +export function getWithdrawExcessLamportsInstruction< + TAccountSourceAccount extends string, + TAccountDestinationAccount extends string, + TAccountAuthority extends string, + TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, +>( + input: WithdrawExcessLamportsInput< + TAccountSourceAccount, + TAccountDestinationAccount, + TAccountAuthority + >, + config?: { programAddress?: TProgramAddress } +): WithdrawExcessLamportsInstruction< + TProgramAddress, + TAccountSourceAccount, + TAccountDestinationAccount, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + IAccountSignerMeta + : TAccountAuthority +> { + // Program address. + const programAddress = config?.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + sourceAccount: { value: input.sourceAccount ?? null, isWritable: true }, + destinationAccount: { + value: input.destinationAccount ?? null, + isWritable: true, + }, + 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: IAccountMeta[] = (args.multiSigners ?? []).map( + (signer) => ({ + address: signer.address, + role: AccountRole.READONLY_SIGNER, + signer, + }) + ); + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + const instruction = { + accounts: [ + getAccountMeta(accounts.sourceAccount), + getAccountMeta(accounts.destinationAccount), + getAccountMeta(accounts.authority), + ...remainingAccounts, + ], + programAddress, + data: getWithdrawExcessLamportsInstructionDataEncoder().encode({}), + } as WithdrawExcessLamportsInstruction< + TProgramAddress, + TAccountSourceAccount, + TAccountDestinationAccount, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + IAccountSignerMeta + : TAccountAuthority + >; + + return instruction; +} + +export type ParsedWithdrawExcessLamportsInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMetas extends readonly IAccountMeta[] = readonly IAccountMeta[], +> = { + programAddress: Address; + accounts: { + /** Account holding excess lamports. */ + sourceAccount: TAccountMetas[0]; + /** Destination account for withdrawn lamports. */ + destinationAccount: TAccountMetas[1]; + /** The source account's owner/delegate or its multisignature account. */ + authority: TAccountMetas[2]; + }; + data: WithdrawExcessLamportsInstructionData; +}; + +export function parseWithdrawExcessLamportsInstruction< + TProgram extends string, + TAccountMetas extends readonly IAccountMeta[], +>( + instruction: IInstruction & + IInstructionWithAccounts & + IInstructionWithData +): ParsedWithdrawExcessLamportsInstruction { + if (instruction.accounts.length < 3) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = instruction.accounts![accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + sourceAccount: getNextAccount(), + destinationAccount: getNextAccount(), + authority: getNextAccount(), + }, + data: getWithdrawExcessLamportsInstructionDataDecoder().decode( + instruction.data + ), + }; +} diff --git a/clients/js/src/generated/programs/token2022.ts b/clients/js/src/generated/programs/token2022.ts index 66de187..1c92246 100644 --- a/clients/js/src/generated/programs/token2022.ts +++ b/clients/js/src/generated/programs/token2022.ts @@ -88,6 +88,7 @@ import { type ParsedUpdateTokenMetadataFieldInstruction, type ParsedUpdateTokenMetadataUpdateAuthorityInstruction, type ParsedUpdateTransferHookInstruction, + type ParsedWithdrawExcessLamportsInstruction, type ParsedWithdrawWithheldTokensFromAccountsForConfidentialTransferFeeInstruction, type ParsedWithdrawWithheldTokensFromAccountsInstruction, type ParsedWithdrawWithheldTokensFromMintForConfidentialTransferFeeInstruction, @@ -186,6 +187,7 @@ export enum Token2022Instruction { HarvestWithheldTokensToMintForConfidentialTransferFee, EnableHarvestToMint, DisableHarvestToMint, + WithdrawExcessLamports, InitializeMetadataPointer, UpdateMetadataPointer, InitializeGroupPointer, @@ -501,6 +503,9 @@ export function identifyToken2022Instruction( ) { return Token2022Instruction.DisableHarvestToMint; } + if (containsBytes(data, getU8Encoder().encode(38), 0)) { + return Token2022Instruction.WithdrawExcessLamports; + } if ( containsBytes(data, getU8Encoder().encode(39), 0) && containsBytes(data, getU8Encoder().encode(0), 1) @@ -810,6 +815,9 @@ export type ParsedToken2022Instruction< | ({ instructionType: Token2022Instruction.DisableHarvestToMint; } & ParsedDisableHarvestToMintInstruction) + | ({ + instructionType: Token2022Instruction.WithdrawExcessLamports; + } & ParsedWithdrawExcessLamportsInstruction) | ({ instructionType: Token2022Instruction.InitializeMetadataPointer; } & ParsedInitializeMetadataPointerInstruction) diff --git a/clients/js/test/withdrawExcess.test.ts b/clients/js/test/withdrawExcess.test.ts new file mode 100644 index 0000000..c48c41d --- /dev/null +++ b/clients/js/test/withdrawExcess.test.ts @@ -0,0 +1,102 @@ +import test from 'ava'; +import { + AccountState, + TOKEN_2022_PROGRAM_ADDRESS, + fetchToken, + findAssociatedTokenPda, + getCreateAssociatedTokenInstructionAsync, + getMintToInstruction, + getWithdrawExcessLamportsInstruction, +} from '../src'; +import { + createDefaultSolanaClient, + createMint, + createToken, + generateKeyPairSignerWithSol, + sendAndConfirmInstructions, +} from './_setup'; +import { getTransferSolInstruction } from '@solana-program/system'; +import { generateKeyPairSigner } from '@solana/web3.js'; + +test('it withdraws excess lamports from an associated token account', async (t) => { + // Given: A client, a payer, mint authority, token owner, and destination account + const client = createDefaultSolanaClient(); + const [payer, mintAuthority, owner, destination] = await Promise.all([ + generateKeyPairSignerWithSol(client, 200_000_000n), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + // And a mint and token are created + const mint = await createMint({ + client, + payer, + authority: mintAuthority, + decimals: 9, + }); + const token = await createToken({ client, payer, mint, owner }); + + // And tokens are minted to the token account + const mintToInstruction = getMintToInstruction({ + mint, + token, + mintAuthority, + amount: 100_000n, + }); + await sendAndConfirmInstructions(client, payer, [mintToInstruction]); + + // And an associated token account (ATA) is created for the owner + const createAtaInstruction = await getCreateAssociatedTokenInstructionAsync({ + payer, + mint, + owner: owner.address, + }); + await sendAndConfirmInstructions(client, payer, [createAtaInstruction]); + + const [ata] = await findAssociatedTokenPda({ + mint, + owner: owner.address, + tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + }); + + // Ensure the token account was initialized correctly + const initialTokenAccount = await fetchToken(client.rpc, ata); + t.is(initialTokenAccount.data.state, AccountState.Initialized); + + // When: SOL is mistakenly transferred to the ATA + const transferSolInstruction = await getTransferSolInstruction({ + source: payer, + destination: ata, + amount: 1_000_000n, + }); + await sendAndConfirmInstructions(client, payer, [transferSolInstruction]); + + // Capture initial balances for comparison after withdrawal + const lamportsBefore = await client.rpc + .getBalance(destination.address) + .send(); + const ataLamportsBefore = await client.rpc.getBalance(ata).send(); + + // And we initiate withdrawal of excess lamports from the ATA to the destination + const withdrawInstruction = await getWithdrawExcessLamportsInstruction({ + sourceAccount: ata, + destinationAccount: destination.address, + authority: owner, + }); + await sendAndConfirmInstructions(client, payer, [withdrawInstruction]); + + // Then: Verify that lamports were successfully withdrawn to the destination + const lamportsAfter = await client.rpc.getBalance(destination.address).send(); + const ataLamportsAfter = await client.rpc.getBalance(ata).send(); + + // Assertions to confirm successful transfer of lamports + t.true( + Number(lamportsAfter.value) > Number(lamportsBefore.value), + 'Lamports were successfully withdrawn to the destination account.' + ); + t.true( + Number(ataLamportsBefore.value) > Number(ataLamportsAfter.value), + 'Lamports were successfully withdrawn from the ATA.' + ); +}); diff --git a/program/idl.json b/program/idl.json index 9ccccc8..ccba040 100644 --- a/program/idl.json +++ b/program/idl.json @@ -6493,6 +6493,83 @@ } ] }, + { + "kind": "instructionNode", + "name": "withdrawExcessLamports", + "docs": [ + "This instruction is to be used to rescue SOLs sent to any TokenProgram", + "owned account by sending them to any other account, leaving behind only", + "lamports for rent exemption." + ], + "optionalAccountStrategy": "programId", + "accounts": [ + { + "kind": "instructionAccountNode", + "name": "sourceAccount", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": ["Account holding excess lamports."] + }, + { + "kind": "instructionAccountNode", + "name": "destinationAccount", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": ["Destination account for withdrawn lamports."] + }, + { + "kind": "instructionAccountNode", + "name": "authority", + "isWritable": false, + "isSigner": "either", + "isOptional": false, + "docs": [ + "The source 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": 38 + } + } + ], + "remainingAccounts": [ + { + "kind": "instructionRemainingAccountsNode", + "isOptional": true, + "isSigner": true, + "docs": [], + "value": { + "kind": "argumentValueNode", + "name": "multiSigners" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ] + }, { "kind": "instructionNode", "name": "initializeMetadataPointer", @@ -9944,4 +10021,4 @@ ] } ] -} +} \ No newline at end of file