Skip to content

Commit 5db0125

Browse files
committed
feat(sdk-coin-sol): add ATA closure support for Solana wallets
TICKET: CHALO-174
1 parent c286633 commit 5db0125

6 files changed

Lines changed: 582 additions & 27 deletions

File tree

modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@ import { Transaction } from './transaction';
77
import { TransactionBuilder } from './transactionBuilder';
88
import { validateAddress } from './utils';
99

10+
type CloseAtaApiMode = 'single' | 'bulk';
11+
12+
const MIX_API_ERROR_MESSAGE =
13+
'Cannot mix single-ATA API (accountAddress/destinationAddress/authorityAddress) with bulk-ATA API (addCloseAtaInstruction)';
14+
1015
export class CloseAtaBuilder extends TransactionBuilder {
11-
protected _accountAddress: string;
12-
protected _destinationAddress: string;
13-
protected _authorityAddress: string;
16+
// Unified storage for all close entries (single or bulk)
17+
protected _closeAtaEntries: { accountAddress: string; destinationAddress: string; authorityAddress: string }[] = [];
18+
19+
// Which API has been used on this builder instance. Locks in on first call so we can
20+
// reject attempts to mix the legacy single-ATA setters with the bulk addCloseAtaInstruction().
21+
// After initBuilder(): remains undefined for a single parsed close (either API may extend/edit);
22+
// set to 'bulk' when the parsed tx had multiple closes so legacy setters cannot partially
23+
// overwrite entry[0] while leaving other parsed entries in place.
24+
private _apiMode: CloseAtaApiMode | undefined = undefined;
1425

1526
constructor(_coinConfig: Readonly<CoinConfig>) {
1627
super(_coinConfig);
@@ -21,21 +32,90 @@ export class CloseAtaBuilder extends TransactionBuilder {
2132
return TransactionType.CloseAssociatedTokenAccount;
2233
}
2334

35+
/**
36+
* Sets the ATA account address to close (single-ATA API, backward compatible).
37+
* Cannot be mixed with addCloseAtaInstruction().
38+
*/
2439
accountAddress(accountAddress: string): this {
40+
this._assertSingleAtaApiUsable();
2541
validateAddress(accountAddress, 'accountAddress');
26-
this._accountAddress = accountAddress;
42+
this._apiMode = 'single';
43+
this._ensureSingleEntry();
44+
this._closeAtaEntries[0].accountAddress = accountAddress;
2745
return this;
2846
}
2947

48+
/**
49+
* Sets the destination address for rent SOL (single-ATA API, backward compatible).
50+
* Cannot be mixed with addCloseAtaInstruction().
51+
*/
3052
destinationAddress(destinationAddress: string): this {
53+
this._assertSingleAtaApiUsable();
3154
validateAddress(destinationAddress, 'destinationAddress');
32-
this._destinationAddress = destinationAddress;
55+
this._apiMode = 'single';
56+
this._ensureSingleEntry();
57+
this._closeAtaEntries[0].destinationAddress = destinationAddress;
3358
return this;
3459
}
3560

61+
/**
62+
* Sets the authority address / ATA owner (single-ATA API, backward compatible).
63+
* Cannot be mixed with addCloseAtaInstruction().
64+
*/
3665
authorityAddress(authorityAddress: string): this {
66+
this._assertSingleAtaApiUsable();
67+
validateAddress(authorityAddress, 'authorityAddress');
68+
this._apiMode = 'single';
69+
this._ensureSingleEntry();
70+
this._closeAtaEntries[0].authorityAddress = authorityAddress;
71+
return this;
72+
}
73+
74+
/**
75+
* Throws if the bulk-ATA API has already been used on this builder.
76+
*/
77+
private _assertSingleAtaApiUsable(): void {
78+
if (this._apiMode === 'bulk') {
79+
throw new BuildTransactionError(MIX_API_ERROR_MESSAGE);
80+
}
81+
}
82+
83+
/**
84+
* Ensures a single entry exists in _closeAtaEntries for the legacy API.
85+
*/
86+
private _ensureSingleEntry(): void {
87+
if (this._closeAtaEntries.length === 0) {
88+
this._closeAtaEntries.push({ accountAddress: '', destinationAddress: '', authorityAddress: '' });
89+
}
90+
}
91+
92+
/**
93+
* Add an ATA to close in this transaction (for bulk closure).
94+
* Cannot be mixed with the single-ATA API (accountAddress/destinationAddress/authorityAddress).
95+
*
96+
* @param {string} accountAddress - the ATA address to close
97+
* @param {string} destinationAddress - where rent SOL goes (root wallet address)
98+
* @param {string} authorityAddress - ATA owner who must sign
99+
*/
100+
addCloseAtaInstruction(accountAddress: string, destinationAddress: string, authorityAddress: string): this {
101+
if (this._apiMode === 'single') {
102+
throw new BuildTransactionError(MIX_API_ERROR_MESSAGE);
103+
}
104+
105+
validateAddress(accountAddress, 'accountAddress');
106+
validateAddress(destinationAddress, 'destinationAddress');
37107
validateAddress(authorityAddress, 'authorityAddress');
38-
this._authorityAddress = authorityAddress;
108+
109+
if (accountAddress === destinationAddress) {
110+
throw new BuildTransactionError('Account address to close cannot be the same as the destination address');
111+
}
112+
113+
if (this._closeAtaEntries.some((entry) => entry.accountAddress === accountAddress)) {
114+
throw new BuildTransactionError('Duplicate ATA address: ' + accountAddress);
115+
}
116+
117+
this._apiMode = 'bulk';
118+
this._closeAtaEntries.push({ accountAddress, destinationAddress, authorityAddress });
39119
return this;
40120
}
41121

@@ -45,33 +125,42 @@ export class CloseAtaBuilder extends TransactionBuilder {
45125
for (const instruction of this._instructionsData) {
46126
if (instruction.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) {
47127
const ataCloseInstruction: AtaClose = instruction;
48-
this.accountAddress(ataCloseInstruction.params.accountAddress);
49-
this.destinationAddress(ataCloseInstruction.params.destinationAddress);
50-
this.authorityAddress(ataCloseInstruction.params.authorityAddress);
128+
this._closeAtaEntries.push({
129+
accountAddress: ataCloseInstruction.params.accountAddress,
130+
destinationAddress: ataCloseInstruction.params.destinationAddress,
131+
authorityAddress: ataCloseInstruction.params.authorityAddress,
132+
});
51133
}
52134
}
135+
if (this._closeAtaEntries.length > 1) {
136+
this._apiMode = 'bulk';
137+
}
53138
}
54139

55140
/** @inheritdoc */
56141
protected async buildImplementation(): Promise<Transaction> {
57-
assert(this._accountAddress, 'Account Address must be set before building the transaction');
58-
assert(this._destinationAddress, 'Destination Address must be set before building the transaction');
59-
assert(this._authorityAddress, 'Authority Address must be set before building the transaction');
142+
assert(this._closeAtaEntries.length > 0, 'At least one ATA must be specified before building the transaction');
60143

61-
if (this._accountAddress === this._destinationAddress) {
62-
throw new BuildTransactionError('Account address to close cannot be the same as the destination address');
63-
}
144+
for (const entry of this._closeAtaEntries) {
145+
assert(entry.accountAddress, 'Account Address must be set before building the transaction');
146+
assert(entry.destinationAddress, 'Destination Address must be set before building the transaction');
147+
assert(entry.authorityAddress, 'Authority Address must be set before building the transaction');
64148

65-
const closeAssociatedTokenAccountData: AtaClose = {
66-
type: InstructionBuilderTypes.CloseAssociatedTokenAccount,
67-
params: {
68-
accountAddress: this._accountAddress,
69-
destinationAddress: this._destinationAddress,
70-
authorityAddress: this._authorityAddress,
71-
},
72-
};
149+
if (entry.accountAddress === entry.destinationAddress) {
150+
throw new BuildTransactionError('Account address to close cannot be the same as the destination address');
151+
}
152+
}
73153

74-
this._instructionsData = [closeAssociatedTokenAccountData];
154+
this._instructionsData = this._closeAtaEntries.map(
155+
(entry): AtaClose => ({
156+
type: InstructionBuilderTypes.CloseAssociatedTokenAccount,
157+
params: {
158+
accountAddress: entry.accountAddress,
159+
destinationAddress: entry.destinationAddress,
160+
authorityAddress: entry.authorityAddress,
161+
},
162+
})
163+
);
75164

76165
return await super.buildImplementation();
77166
}

modules/sdk-coin-sol/src/lib/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,18 @@ export function getTransactionType(transaction: SolTransaction): TransactionType
333333
if (memoData?.includes('WalletConnectDefiCustomTx')) {
334334
return TransactionType.CustomTx;
335335
}
336+
// Check for close ATA instructions before classifying as Send.
337+
// A bulk close-ATA tx contains only closeAccount instructions (zero-balance ATAs).
338+
// Note: This assumes close-ATA transactions never contain TokenTransfer instructions.
339+
// This holds for Phase 1 where non-zero balance ATAs are rejected (user must consolidate first).
340+
// If atomic transfer+close is added in the future, this detection needs refinement.
341+
const hasCloseAta = instructions.some(
342+
(instruction) => getInstructionType(instruction) === ValidInstructionTypesEnum.CloseAssociatedTokenAccount
343+
);
344+
if (hasCloseAta) {
345+
return TransactionType.CloseAssociatedTokenAccount;
346+
}
347+
336348
if (instructions.filter((instruction) => getInstructionType(instruction) === 'Deactivate').length === 0) {
337349
for (const instruction of instructions) {
338350
const instructionType = getInstructionType(instruction);

modules/sdk-coin-sol/src/sol.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
TransactionExplanation,
5151
TransactionParams,
5252
TransactionRecipient,
53+
TransactionType,
5354
VerifyTransactionOptions,
5455
TssVerifyAddressOptions,
5556
verifyEddsaTssWalletAddress,
@@ -64,7 +65,8 @@ import {
6465
TransactionBuilderFactory,
6566
explainSolTransaction,
6667
} from './lib';
67-
import { TransactionExplanation as SolLibTransactionExplanation } from './lib/iface';
68+
import { AtaClose, TransactionExplanation as SolLibTransactionExplanation } from './lib/iface';
69+
import { InstructionBuilderTypes } from './lib/constants';
6870
import {
6971
getAssociatedTokenAccountAddress,
7072
getSolTokenFromAddress,
@@ -367,6 +369,33 @@ export class Sol extends BaseCoin {
367369
}
368370
}
369371

372+
/**
373+
* Verifies that a close-ATA transaction only sends rent SOL to the wallet's root address.
374+
* All CloseAssociatedTokenAccount instructions must have destinationAddress = walletRootAddress.
375+
*
376+
* No-op when `walletRootAddress` is not provided: the auto-detect caller in verifyTransaction()
377+
* runs this for every close-ATA transaction, but wallets without a configured rootAddress
378+
* (e.g. uninitialized or non-standard wallets) should not be blocked from verifyTransaction().
379+
*/
380+
private verifyCloseAtaTransaction(transaction: Transaction, walletRootAddress: string | undefined): void {
381+
if (!walletRootAddress) {
382+
return;
383+
}
384+
385+
const txJson = transaction.toJson();
386+
387+
for (const instruction of txJson.instructionsData) {
388+
if (instruction.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) {
389+
const closeInstruction = instruction as AtaClose;
390+
if (closeInstruction.params.destinationAddress !== walletRootAddress) {
391+
throw new Error(
392+
`Close ATA destination must be wallet root address. Expected ${walletRootAddress}, got ${closeInstruction.params.destinationAddress}`
393+
);
394+
}
395+
}
396+
}
397+
}
398+
370399
private hasSolVersionedTransactionData(
371400
txParams: TransactionParams
372401
): txParams is TransactionParams & { solVersionedTransactionData: SolVersionedTransactionData } {
@@ -471,6 +500,7 @@ export class Sol extends BaseCoin {
471500
}
472501
transaction.fromRawTransaction(rawTxBase64);
473502
const explainedTx = transaction.explainTransaction();
503+
const isCloseAssociatedTokenAccountTx = transaction.type === TransactionType.CloseAssociatedTokenAccount;
474504

475505
if (txParams.type === 'enabletoken' && verificationOptions?.verifyTokenEnablement) {
476506
this.verifyTxType(txParams.type, explainedTx.type);
@@ -481,8 +511,13 @@ export class Sol extends BaseCoin {
481511
await this.verifyTokenAddress(tokenEnablementsPrebuild, enableTokensConfig);
482512
}
483513

514+
if (isCloseAssociatedTokenAccountTx) {
515+
this.verifyCloseAtaTransaction(transaction, walletRootAddress);
516+
}
517+
484518
// users do not input recipients for consolidation requests as they are generated by the server
485-
if (txParams.recipients !== undefined) {
519+
// Close-ATA txs do not populate explainedTx.outputs; recipients carry ATA addresses for intent only.
520+
if (txParams.recipients !== undefined && !isCloseAssociatedTokenAccountTx) {
486521
const filteredRecipients = txParams.recipients?.map((recipient) =>
487522
_.pick(recipient, ['address', 'amount', 'tokenName'])
488523
);
@@ -580,7 +615,7 @@ export class Sol extends BaseCoin {
580615
if (memo && memo.value !== explainedTx.memo) {
581616
throw new Error('Tx memo does not match with expected txParams recipient memo');
582617
}
583-
if (txParams.recipients) {
618+
if (txParams.recipients && !isCloseAssociatedTokenAccountTx) {
584619
for (const recipients of txParams.recipients) {
585620
// totalAmount based on each token
586621
const assetName = recipients.tokenName || this.getChain();

modules/sdk-coin-sol/test/unit/sol.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,41 @@ describe('SOL:', function () {
280280
validTransaction.should.equal(true);
281281
});
282282

283+
it('should verify bulk close-ATA tx when txParams.recipients is present (outputs not populated)', async function () {
284+
const accountKeys = new KeyPair(resources.authAccount).getKeys();
285+
const nonceAccountKeys = new KeyPair(resources.nonceAccount).getKeys();
286+
const account2Keys = new KeyPair(resources.authAccount2).getKeys();
287+
const ataAddress1 = nonceAccountKeys.pub;
288+
const ataAddress2 = account2Keys.pub;
289+
290+
const txBuilder = factory.getCloseAtaInitializationBuilder();
291+
txBuilder.nonce(blockHash);
292+
txBuilder.sender(accountKeys.pub);
293+
txBuilder.addCloseAtaInstruction(ataAddress1, accountKeys.pub, accountKeys.pub);
294+
txBuilder.addCloseAtaInstruction(ataAddress2, accountKeys.pub, accountKeys.pub);
295+
const built = await txBuilder.build();
296+
const txPrebuild = {
297+
txBase64: built.toBroadcastFormat(),
298+
txInfo: {
299+
feePayer: accountKeys.pub,
300+
nonce: blockHash,
301+
},
302+
coin: 'tsol',
303+
};
304+
305+
const validTransaction = await basecoin.verifyTransaction({
306+
txParams: {
307+
recipients: [
308+
{ address: ataAddress1, amount: '0' },
309+
{ address: ataAddress2, amount: '0' },
310+
],
311+
},
312+
txPrebuild,
313+
wallet: walletObj,
314+
} as any);
315+
validTransaction.should.equal(true);
316+
});
317+
283318
it('should fail verify transactions when have different memo', async function () {
284319
const txParams = newTxParams();
285320
const txPrebuild = newTxPrebuild();

0 commit comments

Comments
 (0)