From 89150c986ab06966cc1fea84242a7b1b07ccc903 Mon Sep 17 00:00:00 2001 From: Abhijeet Singh Date: Tue, 21 Apr 2026 12:24:33 +0530 Subject: [PATCH] feat(sdk-coin-sol): add ATA closure support for Solana wallets TICKET: CHALO-174 --- .../sdk-coin-sol/src/lib/closeAtaBuilder.ts | 137 +++++-- modules/sdk-coin-sol/src/lib/utils.ts | 12 + modules/sdk-coin-sol/src/sol.ts | 88 ++++- modules/sdk-coin-sol/test/unit/sol.ts | 198 ++++++++++ .../transactionBuilder/closeAtaBuilder.ts | 372 ++++++++++++++++++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 12 + 6 files changed, 792 insertions(+), 27 deletions(-) create mode 100644 modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts diff --git a/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts b/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts index bbafcd4493..2597094c57 100644 --- a/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts @@ -7,10 +7,21 @@ import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; import { validateAddress } from './utils'; +type CloseAtaApiMode = 'single' | 'bulk'; + +const MIX_API_ERROR_MESSAGE = + 'Cannot mix single-ATA API (accountAddress/destinationAddress/authorityAddress) with bulk-ATA API (addCloseAtaInstruction)'; + export class CloseAtaBuilder extends TransactionBuilder { - protected _accountAddress: string; - protected _destinationAddress: string; - protected _authorityAddress: string; + // Unified storage for all close entries (single or bulk) + protected _closeAtaEntries: { accountAddress: string; destinationAddress: string; authorityAddress: string }[] = []; + + // Which API has been used on this builder instance. Locks in on first call so we can + // reject attempts to mix the legacy single-ATA setters with the bulk addCloseAtaInstruction(). + // After initBuilder(): remains undefined for a single parsed close (either API may extend/edit); + // set to 'bulk' when the parsed tx had multiple closes so legacy setters cannot partially + // overwrite entry[0] while leaving other parsed entries in place. + private _apiMode: CloseAtaApiMode | undefined = undefined; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -21,21 +32,90 @@ export class CloseAtaBuilder extends TransactionBuilder { return TransactionType.CloseAssociatedTokenAccount; } + /** + * Sets the ATA account address to close (single-ATA API, backward compatible). + * Cannot be mixed with addCloseAtaInstruction(). + */ accountAddress(accountAddress: string): this { + this._assertSingleAtaApiUsable(); validateAddress(accountAddress, 'accountAddress'); - this._accountAddress = accountAddress; + this._apiMode = 'single'; + this._ensureSingleEntry(); + this._closeAtaEntries[0].accountAddress = accountAddress; return this; } + /** + * Sets the destination address for rent SOL (single-ATA API, backward compatible). + * Cannot be mixed with addCloseAtaInstruction(). + */ destinationAddress(destinationAddress: string): this { + this._assertSingleAtaApiUsable(); validateAddress(destinationAddress, 'destinationAddress'); - this._destinationAddress = destinationAddress; + this._apiMode = 'single'; + this._ensureSingleEntry(); + this._closeAtaEntries[0].destinationAddress = destinationAddress; return this; } + /** + * Sets the authority address / ATA owner (single-ATA API, backward compatible). + * Cannot be mixed with addCloseAtaInstruction(). + */ authorityAddress(authorityAddress: string): this { + this._assertSingleAtaApiUsable(); + validateAddress(authorityAddress, 'authorityAddress'); + this._apiMode = 'single'; + this._ensureSingleEntry(); + this._closeAtaEntries[0].authorityAddress = authorityAddress; + return this; + } + + /** + * Throws if the bulk-ATA API has already been used on this builder. + */ + private _assertSingleAtaApiUsable(): void { + if (this._apiMode === 'bulk') { + throw new BuildTransactionError(MIX_API_ERROR_MESSAGE); + } + } + + /** + * Ensures a single entry exists in _closeAtaEntries for the legacy API. + */ + private _ensureSingleEntry(): void { + if (this._closeAtaEntries.length === 0) { + this._closeAtaEntries.push({ accountAddress: '', destinationAddress: '', authorityAddress: '' }); + } + } + + /** + * Add an ATA to close in this transaction (for bulk closure). + * Cannot be mixed with the single-ATA API (accountAddress/destinationAddress/authorityAddress). + * + * @param {string} accountAddress - the ATA address to close + * @param {string} destinationAddress - where rent SOL goes (root wallet address) + * @param {string} authorityAddress - ATA owner who must sign + */ + addCloseAtaInstruction(accountAddress: string, destinationAddress: string, authorityAddress: string): this { + if (this._apiMode === 'single') { + throw new BuildTransactionError(MIX_API_ERROR_MESSAGE); + } + + validateAddress(accountAddress, 'accountAddress'); + validateAddress(destinationAddress, 'destinationAddress'); validateAddress(authorityAddress, 'authorityAddress'); - this._authorityAddress = authorityAddress; + + if (accountAddress === destinationAddress) { + throw new BuildTransactionError('Account address to close cannot be the same as the destination address'); + } + + if (this._closeAtaEntries.some((entry) => entry.accountAddress === accountAddress)) { + throw new BuildTransactionError('Duplicate ATA address: ' + accountAddress); + } + + this._apiMode = 'bulk'; + this._closeAtaEntries.push({ accountAddress, destinationAddress, authorityAddress }); return this; } @@ -45,33 +125,42 @@ export class CloseAtaBuilder extends TransactionBuilder { for (const instruction of this._instructionsData) { if (instruction.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) { const ataCloseInstruction: AtaClose = instruction; - this.accountAddress(ataCloseInstruction.params.accountAddress); - this.destinationAddress(ataCloseInstruction.params.destinationAddress); - this.authorityAddress(ataCloseInstruction.params.authorityAddress); + this._closeAtaEntries.push({ + accountAddress: ataCloseInstruction.params.accountAddress, + destinationAddress: ataCloseInstruction.params.destinationAddress, + authorityAddress: ataCloseInstruction.params.authorityAddress, + }); } } + if (this._closeAtaEntries.length > 1) { + this._apiMode = 'bulk'; + } } /** @inheritdoc */ protected async buildImplementation(): Promise { - assert(this._accountAddress, 'Account Address must be set before building the transaction'); - assert(this._destinationAddress, 'Destination Address must be set before building the transaction'); - assert(this._authorityAddress, 'Authority Address must be set before building the transaction'); + assert(this._closeAtaEntries.length > 0, 'At least one ATA must be specified before building the transaction'); - if (this._accountAddress === this._destinationAddress) { - throw new BuildTransactionError('Account address to close cannot be the same as the destination address'); - } + for (const entry of this._closeAtaEntries) { + assert(entry.accountAddress, 'Account Address must be set before building the transaction'); + assert(entry.destinationAddress, 'Destination Address must be set before building the transaction'); + assert(entry.authorityAddress, 'Authority Address must be set before building the transaction'); - const closeAssociatedTokenAccountData: AtaClose = { - type: InstructionBuilderTypes.CloseAssociatedTokenAccount, - params: { - accountAddress: this._accountAddress, - destinationAddress: this._destinationAddress, - authorityAddress: this._authorityAddress, - }, - }; + if (entry.accountAddress === entry.destinationAddress) { + throw new BuildTransactionError('Account address to close cannot be the same as the destination address'); + } + } - this._instructionsData = [closeAssociatedTokenAccountData]; + this._instructionsData = this._closeAtaEntries.map( + (entry): AtaClose => ({ + type: InstructionBuilderTypes.CloseAssociatedTokenAccount, + params: { + accountAddress: entry.accountAddress, + destinationAddress: entry.destinationAddress, + authorityAddress: entry.authorityAddress, + }, + }) + ); return await super.buildImplementation(); } diff --git a/modules/sdk-coin-sol/src/lib/utils.ts b/modules/sdk-coin-sol/src/lib/utils.ts index a5d2494d5d..53077f114f 100644 --- a/modules/sdk-coin-sol/src/lib/utils.ts +++ b/modules/sdk-coin-sol/src/lib/utils.ts @@ -333,6 +333,18 @@ export function getTransactionType(transaction: SolTransaction): TransactionType if (memoData?.includes('WalletConnectDefiCustomTx')) { return TransactionType.CustomTx; } + // Check for close ATA instructions before classifying as Send. + // A bulk close-ATA tx contains only closeAccount instructions (zero-balance ATAs). + // Note: This assumes close-ATA transactions never contain TokenTransfer instructions. + // This holds for Phase 1 where non-zero balance ATAs are rejected (user must consolidate first). + // If atomic transfer+close is added in the future, this detection needs refinement. + const hasCloseAta = instructions.some( + (instruction) => getInstructionType(instruction) === ValidInstructionTypesEnum.CloseAssociatedTokenAccount + ); + if (hasCloseAta) { + return TransactionType.CloseAssociatedTokenAccount; + } + if (instructions.filter((instruction) => getInstructionType(instruction) === 'Deactivate').length === 0) { for (const instruction of instructions) { const instructionType = getInstructionType(instruction); diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index e0e23f449f..8cb1651010 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -50,6 +50,7 @@ import { TransactionExplanation, TransactionParams, TransactionRecipient, + TransactionType, VerifyTransactionOptions, TssVerifyAddressOptions, verifyEddsaTssWalletAddress, @@ -64,7 +65,8 @@ import { TransactionBuilderFactory, explainSolTransaction, } from './lib'; -import { TransactionExplanation as SolLibTransactionExplanation } from './lib/iface'; +import { AtaClose, AtaRecoverNested, TransactionExplanation as SolLibTransactionExplanation } from './lib/iface'; +import { InstructionBuilderTypes } from './lib/constants'; import { getAssociatedTokenAccountAddress, getSolTokenFromAddress, @@ -367,6 +369,77 @@ export class Sol extends BaseCoin { } } + /** + * For transactions typed as `CloseAssociatedTokenAccount` (includes SPL close-account and + * recover-nested flows), enforce that rent / authority aligns with the wallet root when known. + * + * No-op when `walletRootAddress` is not provided (e.g. uninitialized wallets). + */ + private verifyCloseAssociatedTokenAccountFamilyRoots( + transaction: Transaction, + walletRootAddress: string | undefined + ): void { + if (!walletRootAddress) { + return; + } + + const txJson = transaction.toJson(); + + for (const instruction of txJson.instructionsData) { + if (instruction.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) { + const closeInstruction = instruction as AtaClose; + if (closeInstruction.params.destinationAddress !== walletRootAddress) { + throw new Error( + `Close ATA destination must be wallet root address. Expected ${walletRootAddress}, got ${closeInstruction.params.destinationAddress}` + ); + } + } else if (instruction.type === InstructionBuilderTypes.RecoverNestedAssociatedTokenAccount) { + const recoverNestedInstruction = instruction as AtaRecoverNested; + if (recoverNestedInstruction.params.walletAddress !== walletRootAddress) { + throw new Error( + `Recover nested wallet address must be wallet root address. Expected ${walletRootAddress}, got ${recoverNestedInstruction.params.walletAddress}` + ); + } + } + } + } + + /** + * When the caller supplies `recipients` for a close-ATA prebuild, require they match the + * on-chain ATA accounts being closed (order-independent) and carry zero amount (intent-only). + * Recover-nested-only txs have no close instructions; this is a no-op in that case. + */ + private verifyCloseAtaRecipientsMatchCloseInstructions( + transaction: Transaction, + recipients: TransactionRecipient[] + ): void { + const txJson = transaction.toJson(); + const closeAtaAccountAddresses = txJson.instructionsData + .filter((inst) => inst.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) + .map((inst) => (inst as AtaClose).params.accountAddress); + + if (closeAtaAccountAddresses.length === 0) { + return; + } + + for (const recipient of recipients) { + if (recipient.tokenName) { + throw new Error('Close ATA recipients must not specify tokenName'); + } + if (!new BigNumber(String(recipient.amount ?? '0')).isZero()) { + throw new Error('Close ATA recipients must have zero amount'); + } + } + + const sortedRecipients = [...recipients.map((r) => r.address)].sort(); + const sortedCloses = [...closeAtaAccountAddresses].sort(); + if (!_.isEqual(sortedRecipients, sortedCloses)) { + throw new Error( + 'Close ATA txParams.recipients addresses must match the ATA account addresses in the transaction instructions' + ); + } + } + private hasSolVersionedTransactionData( txParams: TransactionParams ): txParams is TransactionParams & { solVersionedTransactionData: SolVersionedTransactionData } { @@ -471,6 +544,7 @@ export class Sol extends BaseCoin { } transaction.fromRawTransaction(rawTxBase64); const explainedTx = transaction.explainTransaction(); + const isCloseAssociatedTokenAccountTx = transaction.type === TransactionType.CloseAssociatedTokenAccount; if (txParams.type === 'enabletoken' && verificationOptions?.verifyTokenEnablement) { this.verifyTxType(txParams.type, explainedTx.type); @@ -481,8 +555,16 @@ export class Sol extends BaseCoin { await this.verifyTokenAddress(tokenEnablementsPrebuild, enableTokensConfig); } + if (isCloseAssociatedTokenAccountTx) { + this.verifyCloseAssociatedTokenAccountFamilyRoots(transaction, walletRootAddress); + if (txParams.recipients !== undefined) { + this.verifyCloseAtaRecipientsMatchCloseInstructions(transaction, txParams.recipients); + } + } + // users do not input recipients for consolidation requests as they are generated by the server - if (txParams.recipients !== undefined) { + // Close-ATA txs do not populate explainedTx.outputs; recipients carry ATA addresses for intent only. + if (txParams.recipients !== undefined && !isCloseAssociatedTokenAccountTx) { const filteredRecipients = txParams.recipients?.map((recipient) => _.pick(recipient, ['address', 'amount', 'tokenName']) ); @@ -580,7 +662,7 @@ export class Sol extends BaseCoin { if (memo && memo.value !== explainedTx.memo) { throw new Error('Tx memo does not match with expected txParams recipient memo'); } - if (txParams.recipients) { + if (txParams.recipients && !isCloseAssociatedTokenAccountTx) { for (const recipients of txParams.recipients) { // totalAmount based on each token const assetName = recipients.tokenName || this.getChain(); diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index a24719803c..8c793efad2 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -280,6 +280,204 @@ describe('SOL:', function () { validTransaction.should.equal(true); }); + it('should verify bulk close-ATA tx when txParams.recipients is present (outputs not populated)', async function () { + const accountKeys = new KeyPair(resources.authAccount).getKeys(); + const nonceAccountKeys = new KeyPair(resources.nonceAccount).getKeys(); + const account2Keys = new KeyPair(resources.authAccount2).getKeys(); + const ataAddress1 = nonceAccountKeys.pub; + const ataAddress2 = account2Keys.pub; + + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(blockHash); + txBuilder.sender(accountKeys.pub); + txBuilder.addCloseAtaInstruction(ataAddress1, accountKeys.pub, accountKeys.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, accountKeys.pub, accountKeys.pub); + const built = await txBuilder.build(); + const txPrebuild = { + txBase64: built.toBroadcastFormat(), + txInfo: { + feePayer: accountKeys.pub, + nonce: blockHash, + }, + coin: 'tsol', + }; + + const validTransaction = await basecoin.verifyTransaction({ + txParams: { + recipients: [ + { address: ataAddress1, amount: '0' }, + { address: ataAddress2, amount: '0' }, + ], + }, + txPrebuild, + wallet: walletObj, + } as any); + validTransaction.should.equal(true); + }); + + it('should fail verify bulk close-ATA when txParams.recipients do not match close instructions', async function () { + const accountKeys = new KeyPair(resources.authAccount).getKeys(); + const nonceAccountKeys = new KeyPair(resources.nonceAccount).getKeys(); + const account2Keys = new KeyPair(resources.authAccount2).getKeys(); + const wrongAta = new KeyPair(resources.stakeAccount).getKeys().pub; + const ataAddress1 = nonceAccountKeys.pub; + const ataAddress2 = account2Keys.pub; + + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(blockHash); + txBuilder.sender(accountKeys.pub); + txBuilder.addCloseAtaInstruction(ataAddress1, accountKeys.pub, accountKeys.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, accountKeys.pub, accountKeys.pub); + const built = await txBuilder.build(); + const txPrebuild = { + txBase64: built.toBroadcastFormat(), + txInfo: { + feePayer: accountKeys.pub, + nonce: blockHash, + }, + coin: 'tsol', + }; + + await basecoin + .verifyTransaction({ + txParams: { + recipients: [ + { address: ataAddress1, amount: '0' }, + { address: wrongAta, amount: '0' }, + ], + }, + txPrebuild, + wallet: walletObj, + } as any) + .should.rejectedWith( + 'Close ATA txParams.recipients addresses must match the ATA account addresses in the transaction instructions' + ); + }); + + it('should fail verify bulk close-ATA when a recipient amount is non-zero', async function () { + const accountKeys = new KeyPair(resources.authAccount).getKeys(); + const nonceAccountKeys = new KeyPair(resources.nonceAccount).getKeys(); + const account2Keys = new KeyPair(resources.authAccount2).getKeys(); + const ataAddress1 = nonceAccountKeys.pub; + const ataAddress2 = account2Keys.pub; + + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(blockHash); + txBuilder.sender(accountKeys.pub); + txBuilder.addCloseAtaInstruction(ataAddress1, accountKeys.pub, accountKeys.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, accountKeys.pub, accountKeys.pub); + const built = await txBuilder.build(); + const txPrebuild = { + txBase64: built.toBroadcastFormat(), + txInfo: { + feePayer: accountKeys.pub, + nonce: blockHash, + }, + coin: 'tsol', + }; + + await basecoin + .verifyTransaction({ + txParams: { + recipients: [ + { address: ataAddress1, amount: '1' }, + { address: ataAddress2, amount: '0' }, + ], + }, + txPrebuild, + wallet: walletObj, + } as any) + .should.rejectedWith('Close ATA recipients must have zero amount'); + }); + + it('should verify recover-nested tx when walletAddress matches root', async function () { + const accountKeys = new KeyPair(resources.authAccount).getKeys(); + const nonceAccountKeys = new KeyPair(resources.nonceAccount).getKeys(); + const account2Keys = new KeyPair(resources.authAccount2).getKeys(); + const mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + + const txBuilder = factory.getRecoverNestedAtaBuilder(); + txBuilder.nonce(blockHash); + txBuilder.sender(accountKeys.pub); + txBuilder.feePayer(accountKeys.pub); + txBuilder.associatedTokenAccountRent('2039280'); + txBuilder.nestedAccountAddress(nonceAccountKeys.pub); + txBuilder.nestedMintAddress(mint); + txBuilder.destinationAccountAddress(account2Keys.pub); + txBuilder.ownerAccountAddress(account2Keys.pub); + txBuilder.ownerMintAddress(mint); + txBuilder.walletAddress(accountKeys.pub); + + const built = await txBuilder.build(); + const txPrebuild = { + txBase64: built.toBroadcastFormat(), + txInfo: { + feePayer: accountKeys.pub, + nonce: blockHash, + }, + coin: 'tsol', + }; + + const validTransaction = await basecoin.verifyTransaction({ + txParams: {}, + txPrebuild, + wallet: walletObj, + } as any); + validTransaction.should.equal(true); + }); + + it('should fail verify recover-nested tx when wallet root does not match instruction walletAddress', async function () { + const accountKeys = new KeyPair(resources.authAccount).getKeys(); + const nonceAccountKeys = new KeyPair(resources.nonceAccount).getKeys(); + const account2Keys = new KeyPair(resources.authAccount2).getKeys(); + const mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + + const txBuilder = factory.getRecoverNestedAtaBuilder(); + txBuilder.nonce(blockHash); + txBuilder.sender(accountKeys.pub); + txBuilder.feePayer(accountKeys.pub); + txBuilder.associatedTokenAccountRent('2039280'); + txBuilder.nestedAccountAddress(nonceAccountKeys.pub); + txBuilder.nestedMintAddress(mint); + txBuilder.destinationAccountAddress(account2Keys.pub); + txBuilder.ownerAccountAddress(account2Keys.pub); + txBuilder.ownerMintAddress(mint); + txBuilder.walletAddress(accountKeys.pub); + + const built = await txBuilder.build(); + const txPrebuild = { + txBase64: built.toBroadcastFormat(), + txInfo: { + feePayer: accountKeys.pub, + nonce: blockHash, + }, + coin: 'tsol', + }; + + const walletDataWrongRoot = { + id: '5b34252f1bf349930e34020a00000000', + coin: 'tsol', + keys: [ + '5b3424f91bf349930e34017500000000', + '5b3424f91bf349930e34017600000000', + '5b3424f91bf349930e34017700000000', + ], + coinSpecific: { + rootAddress: stakeAccount.pub, + }, + multisigType: 'tss', + }; + const wrongRootWallet = new Wallet(bitgo, basecoin, walletDataWrongRoot); + + await basecoin + .verifyTransaction({ + txParams: {}, + txPrebuild, + wallet: wrongRootWallet, + } as any) + .should.rejectedWith(/Recover nested wallet address must be wallet root address/); + }); + it('should fail verify transactions when have different memo', async function () { const txParams = newTxParams(); const txPrebuild = newTxPrebuild(); diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts new file mode 100644 index 0000000000..8630c82ad5 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts @@ -0,0 +1,372 @@ +import { KeyPair, Utils } from '../../../src'; +import { CloseAtaBuilder } from '../../../src/lib/closeAtaBuilder'; +import should from 'should'; +import * as testData from '../../resources/sol'; +import { TransactionType } from '@bitgo/sdk-core'; +import { getBuilderFactory } from '../getBuilderFactory'; +import { InstructionBuilderTypes } from '../../../src/lib/constants'; + +describe('Sol Close ATA Builder', () => { + const factory = getBuilderFactory('tsol'); + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + const account = new KeyPair(testData.authAccount).getKeys(); + const nonceAccount = new KeyPair(testData.nonceAccount).getKeys(); + const account2 = new KeyPair(testData.authAccount2).getKeys(); + + // ATA addresses (valid Solana addresses from test fixtures) + const ataAddress1 = nonceAccount.pub; + const ataAddress2 = account2.pub; + const destinationAddress = account.pub; + + const closeAtaBuilder = () => { + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(account.pub); + return txBuilder; + }; + + describe('Single ATA close (backward compatible)', () => { + describe('Succeed', () => { + it('build a single close ATA tx unsigned', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(ataAddress1); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + + const instructions = tx.toJson().instructionsData; + instructions.length.should.equal(1); + instructions[0].type.should.equal(InstructionBuilderTypes.CloseAssociatedTokenAccount); + instructions[0].params.accountAddress.should.equal(ataAddress1); + instructions[0].params.destinationAddress.should.equal(destinationAddress); + instructions[0].params.authorityAddress.should.equal(account.pub); + }); + + it('build a single close ATA tx signed', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(ataAddress1); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + txBuilder.sign({ key: account.prv }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + + it('build a single close ATA tx with durable nonce', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.nonce(recentBlockHash, { + walletNonceAddress: nonceAccount.pub, + authWalletAddress: account.pub, + }); + txBuilder.accountAddress(ataAddress2); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + }); + + describe('Fail', () => { + it('should fail when account address is not set', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + await txBuilder.build().should.rejectedWith('Account Address must be set before building the transaction'); + }); + + it('should fail when destination address is not set', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(ataAddress1); + txBuilder.authorityAddress(account.pub); + + await txBuilder.build().should.rejectedWith('Destination Address must be set before building the transaction'); + }); + + it('should fail when authority address is not set', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(ataAddress1); + txBuilder.destinationAddress(destinationAddress); + + await txBuilder.build().should.rejectedWith('Authority Address must be set before building the transaction'); + }); + + it('should fail when account address equals destination address', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(destinationAddress); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + await txBuilder + .build() + .should.rejectedWith('Account address to close cannot be the same as the destination address'); + }); + + it('should fail with invalid account address', () => { + const txBuilder = closeAtaBuilder(); + should(() => txBuilder.accountAddress('invalidAddress')).throwError(); + }); + + it('should fail with invalid destination address', () => { + const txBuilder = closeAtaBuilder(); + should(() => txBuilder.destinationAddress('invalidAddress')).throwError(); + }); + + it('should fail with invalid authority address', () => { + const txBuilder = closeAtaBuilder(); + should(() => txBuilder.authorityAddress('invalidAddress')).throwError(); + }); + + it('should fail when nonce is not provided', async () => { + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.sender(account.pub); + txBuilder.accountAddress(ataAddress1); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + await txBuilder.build().should.rejectedWith('Invalid transaction: missing nonce blockhash'); + }); + + it('should fail when sender is not provided', async () => { + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.accountAddress(ataAddress1); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + await txBuilder.build().should.rejectedWith('Invalid transaction: missing sender'); + }); + }); + }); + + describe('Bulk ATA close (addCloseAtaInstruction)', () => { + describe('Succeed', () => { + it('build a bulk close ATA tx with multiple ATAs unsigned', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, destinationAddress, account.pub); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + + const instructions = tx.toJson().instructionsData; + instructions.length.should.equal(2); + + instructions[0].type.should.equal(InstructionBuilderTypes.CloseAssociatedTokenAccount); + instructions[0].params.accountAddress.should.equal(ataAddress1); + instructions[0].params.destinationAddress.should.equal(destinationAddress); + instructions[0].params.authorityAddress.should.equal(account.pub); + + instructions[1].type.should.equal(InstructionBuilderTypes.CloseAssociatedTokenAccount); + instructions[1].params.accountAddress.should.equal(ataAddress2); + instructions[1].params.destinationAddress.should.equal(destinationAddress); + instructions[1].params.authorityAddress.should.equal(account.pub); + }); + + it('build a bulk close ATA tx signed', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, destinationAddress, account.pub); + txBuilder.sign({ key: account.prv }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + + const instructions = tx.toJson().instructionsData; + instructions.length.should.equal(2); + }); + + it('build a single close using addCloseAtaInstruction', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const instructions = tx.toJson().instructionsData; + instructions.length.should.equal(1); + instructions[0].params.accountAddress.should.equal(ataAddress1); + }); + + it('all close instructions have same destination (root wallet)', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, destinationAddress, account.pub); + + const tx = await txBuilder.build(); + const instructions = tx.toJson().instructionsData; + + for (const instruction of instructions) { + instruction.params.destinationAddress.should.equal(destinationAddress); + } + }); + }); + + describe('Fail', () => { + it('should fail with duplicate ATA address', () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + + should(() => txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub)).throwError( + 'Duplicate ATA address: ' + ataAddress1 + ); + }); + + it('should fail when account equals destination', () => { + const txBuilder = closeAtaBuilder(); + + should(() => txBuilder.addCloseAtaInstruction(destinationAddress, destinationAddress, account.pub)).throwError( + 'Account address to close cannot be the same as the destination address' + ); + }); + + it('should fail with invalid account address', () => { + const txBuilder = closeAtaBuilder(); + should(() => txBuilder.addCloseAtaInstruction('invalid', destinationAddress, account.pub)).throwError(); + }); + + it('should fail with invalid destination address', () => { + const txBuilder = closeAtaBuilder(); + should(() => txBuilder.addCloseAtaInstruction(ataAddress1, 'invalid', account.pub)).throwError(); + }); + + it('should fail with invalid authority address', () => { + const txBuilder = closeAtaBuilder(); + should(() => txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, 'invalid')).throwError(); + }); + }); + }); + + describe('Mixing single-ATA and bulk APIs', () => { + const mixErrorMessage = /Cannot mix single-ATA API .* with bulk-ATA API/; + + it('should throw when single-ATA setter is called after addCloseAtaInstruction (accountAddress)', () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + should(() => txBuilder.accountAddress(ataAddress2)).throw(mixErrorMessage); + }); + + it('should throw when single-ATA setter is called after addCloseAtaInstruction (destinationAddress)', () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + should(() => txBuilder.destinationAddress(account2.pub)).throw(mixErrorMessage); + }); + + it('should throw when single-ATA setter is called after addCloseAtaInstruction (authorityAddress)', () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + should(() => txBuilder.authorityAddress(account2.pub)).throw(mixErrorMessage); + }); + + it('should throw when addCloseAtaInstruction is called after single-ATA setter', () => { + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(ataAddress1); + should(() => txBuilder.addCloseAtaInstruction(ataAddress2, destinationAddress, account.pub)).throw( + mixErrorMessage + ); + }); + + it('should not corrupt the first bulk entry when bulk-then-single is rejected', () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + try { + txBuilder.accountAddress(ataAddress2); + } catch { + /* expected */ + } + // First entry must still be intact after the rejected mix attempt. + const entries = (txBuilder as unknown as { _closeAtaEntries: Array<{ accountAddress: string }> }) + ._closeAtaEntries; + entries.length.should.equal(1); + entries[0].accountAddress.should.equal(ataAddress1); + }); + }); + + describe('From raw transaction', () => { + it('should parse a single close ATA tx from raw and rebuild', async () => { + // Build a tx first + const txBuilder = closeAtaBuilder(); + txBuilder.accountAddress(ataAddress1); + txBuilder.destinationAddress(destinationAddress); + txBuilder.authorityAddress(account.pub); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + // Parse from raw + const rebuiltBuilder = factory.from(rawTx); + const rebuiltTx = await rebuiltBuilder.build(); + + rebuiltTx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const instructions = rebuiltTx.toJson().instructionsData; + instructions.length.should.equal(1); + instructions[0].type.should.equal(InstructionBuilderTypes.CloseAssociatedTokenAccount); + instructions[0].params.accountAddress.should.equal(ataAddress1); + instructions[0].params.destinationAddress.should.equal(destinationAddress); + instructions[0].params.authorityAddress.should.equal(account.pub); + }); + + it('should parse a bulk close ATA tx from raw and rebuild', async () => { + // Build a bulk tx first + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, destinationAddress, account.pub); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + // Parse from raw + const rebuiltBuilder = factory.from(rawTx); + const rebuiltTx = await rebuiltBuilder.build(); + + rebuiltTx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + + const instructions = rebuiltTx.toJson().instructionsData; + instructions.length.should.equal(2); + instructions[0].params.accountAddress.should.equal(ataAddress1); + instructions[1].params.accountAddress.should.equal(ataAddress2); + }); + + it('should reject legacy single-ATA setters on a parsed bulk close tx', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + txBuilder.addCloseAtaInstruction(ataAddress2, destinationAddress, account.pub); + const rawTx = (await txBuilder.build()).toBroadcastFormat(); + + const parsed = factory.from(rawTx) as CloseAtaBuilder; + const mixErrorMessage = /Cannot mix single-ATA API .* with bulk-ATA API/; + should(() => parsed.accountAddress(ataAddress1)).throw(mixErrorMessage); + should(() => parsed.destinationAddress(destinationAddress)).throw(mixErrorMessage); + should(() => parsed.authorityAddress(account.pub)).throw(mixErrorMessage); + }); + + it('should parse existing close ATA raw tx from test resources', async () => { + const txnBuilder = factory.from(testData.TRANSFER_UNSIGNED_TX_CLOSE_ATA); + should.exist(txnBuilder); + + const tx = await txnBuilder.build(); + tx.type.should.equal(TransactionType.CloseAssociatedTokenAccount); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 97a124e777..9fc187939c 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3701,6 +3701,18 @@ export class Wallet implements IWallet { params.preview ); break; + case 'closeAssociatedTokenAccount': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'closeAssociatedTokenAccount', + recipients: params.recipients || [], + memo: params.memo, + }, + apiVersion, + params.preview + ); + break; case 'acceleration': txRequest = await this.tssUtils!.prebuildTxWithIntent( {