Skip to content

Commit f405493

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

5 files changed

Lines changed: 525 additions & 25 deletions

File tree

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

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,20 @@ 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+
// Remains undefined after initBuilder() so a parsed transaction can still be modified via
22+
// either API path.
23+
private _apiMode: CloseAtaApiMode | undefined = undefined;
1424

1525
constructor(_coinConfig: Readonly<CoinConfig>) {
1626
super(_coinConfig);
@@ -21,21 +31,90 @@ export class CloseAtaBuilder extends TransactionBuilder {
2131
return TransactionType.CloseAssociatedTokenAccount;
2232
}
2333

34+
/**
35+
* Sets the ATA account address to close (single-ATA API, backward compatible).
36+
* Cannot be mixed with addCloseAtaInstruction().
37+
*/
2438
accountAddress(accountAddress: string): this {
39+
this._assertSingleAtaApiUsable();
2540
validateAddress(accountAddress, 'accountAddress');
26-
this._accountAddress = accountAddress;
41+
this._apiMode = 'single';
42+
this._ensureSingleEntry();
43+
this._closeAtaEntries[0].accountAddress = accountAddress;
2744
return this;
2845
}
2946

47+
/**
48+
* Sets the destination address for rent SOL (single-ATA API, backward compatible).
49+
* Cannot be mixed with addCloseAtaInstruction().
50+
*/
3051
destinationAddress(destinationAddress: string): this {
52+
this._assertSingleAtaApiUsable();
3153
validateAddress(destinationAddress, 'destinationAddress');
32-
this._destinationAddress = destinationAddress;
54+
this._apiMode = 'single';
55+
this._ensureSingleEntry();
56+
this._closeAtaEntries[0].destinationAddress = destinationAddress;
3357
return this;
3458
}
3559

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

@@ -45,33 +124,39 @@ export class CloseAtaBuilder extends TransactionBuilder {
45124
for (const instruction of this._instructionsData) {
46125
if (instruction.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) {
47126
const ataCloseInstruction: AtaClose = instruction;
48-
this.accountAddress(ataCloseInstruction.params.accountAddress);
49-
this.destinationAddress(ataCloseInstruction.params.destinationAddress);
50-
this.authorityAddress(ataCloseInstruction.params.authorityAddress);
127+
this._closeAtaEntries.push({
128+
accountAddress: ataCloseInstruction.params.accountAddress,
129+
destinationAddress: ataCloseInstruction.params.destinationAddress,
130+
authorityAddress: ataCloseInstruction.params.authorityAddress,
131+
});
51132
}
52133
}
53134
}
54135

55136
/** @inheritdoc */
56137
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');
138+
assert(this._closeAtaEntries.length > 0, 'At least one ATA must be specified before building the transaction');
60139

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

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

74-
this._instructionsData = [closeAssociatedTokenAccountData];
150+
this._instructionsData = this._closeAtaEntries.map(
151+
(entry): AtaClose => ({
152+
type: InstructionBuilderTypes.CloseAssociatedTokenAccount,
153+
params: {
154+
accountAddress: entry.accountAddress,
155+
destinationAddress: entry.destinationAddress,
156+
authorityAddress: entry.authorityAddress,
157+
},
158+
})
159+
);
75160

76161
return await super.buildImplementation();
77162
}

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: 34 additions & 1 deletion
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 } {
@@ -481,6 +510,10 @@ export class Sol extends BaseCoin {
481510
await this.verifyTokenAddress(tokenEnablementsPrebuild, enableTokensConfig);
482511
}
483512

513+
if (transaction.type === TransactionType.CloseAssociatedTokenAccount) {
514+
this.verifyCloseAtaTransaction(transaction, walletRootAddress ?? '');
515+
}
516+
484517
// users do not input recipients for consolidation requests as they are generated by the server
485518
if (txParams.recipients !== undefined) {
486519
const filteredRecipients = txParams.recipients?.map((recipient) =>

0 commit comments

Comments
 (0)