Skip to content

Commit c773490

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

10 files changed

Lines changed: 743 additions & 25 deletions

File tree

modules/express/src/clientRoutes.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,49 @@ export async function handleV2EnableTokens(req: ExpressApiRouteRequest<'express.
10961096
}
10971097
}
10981098

1099+
/**
1100+
* Handle close ATA request
1101+
* @param req
1102+
*/
1103+
export async function handleV2CloseAta(req: ExpressApiRouteRequest<'express.v2.wallet.closeata', 'post'>) {
1104+
const bitgo = req.bitgo;
1105+
const coin = bitgo.coin(req.decoded.coin);
1106+
1107+
if (!req.body.ataAddresses || !_.isArray(req.body.ataAddresses) || req.body.ataAddresses.length === 0) {
1108+
throw new Error('ataAddresses must be a non-empty array of addresses');
1109+
}
1110+
1111+
const wallet = await coin.wallets().get({ id: req.decoded.id });
1112+
1113+
let result: any;
1114+
try {
1115+
if (coin.supportsTss()) {
1116+
result = await wallet.sendCloseAtas(createTSSSendParams(req, wallet));
1117+
} else {
1118+
result = await wallet.sendCloseAtas(createSendParams(req));
1119+
}
1120+
} catch (err) {
1121+
err.status = 400;
1122+
throw err;
1123+
}
1124+
1125+
if (result.failure.length && result.failure.length > 0) {
1126+
let msg = '';
1127+
let status = 202;
1128+
1129+
if (result.success.length && result.success.length > 0) {
1130+
msg = `Close ATA transactions failed: ${result.failure.length} and succeeded: ${result.success.length}`;
1131+
} else {
1132+
status = 400;
1133+
msg = `All close ATA transactions failed`;
1134+
}
1135+
1136+
throw apiResponse(status, result, msg);
1137+
}
1138+
1139+
return result;
1140+
}
1141+
10991142
/**
11001143
* Handle Update Wallet
11011144
* @param req
@@ -1798,6 +1841,9 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
17981841
// token enablement
17991842
router.post('express.v2.wallet.enableTokens', [prepareBitGo(config), typedPromiseWrapper(handleV2EnableTokens)]);
18001843

1844+
// close ATA
1845+
router.post('express.v2.wallet.closeata', [prepareBitGo(config), typedPromiseWrapper(handleV2CloseAta)]);
1846+
18011847
// unspent changes
18021848
router.post('express.wallet.consolidateunspents', [
18031849
prepareBitGo(config),

modules/express/src/typedRoutes/api/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload';
4646
import { PostLightningWalletPayment } from './v2/lightningPayment';
4747
import { PostLightningWalletWithdraw } from './v2/lightningWithdraw';
4848
import { PutV2PendingApproval } from './v2/pendingApproval';
49+
import { PostCloseAta } from './v2/closeAta';
4950
import { PostConsolidateAccount } from './v2/consolidateAccount';
5051
import { PostCanonicalAddress } from './v2/canonicalAddress';
5152
import { PostWalletEnableTokens } from './v2/walletEnableTokens';
@@ -176,6 +177,12 @@ export const ExpressV2WalletConsolidateAccountApiSpec = apiSpec({
176177
},
177178
});
178179

180+
export const ExpressV2WalletCloseAtaApiSpec = apiSpec({
181+
'express.v2.wallet.closeata': {
182+
post: PostCloseAta,
183+
},
184+
});
185+
179186
export const ExpressWalletFanoutUnspentsApiSpec = apiSpec({
180187
'express.v1.wallet.fanoutunspents': {
181188
put: PutFanoutUnspents,
@@ -358,6 +365,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
358365
typeof ExpressV1PendingApprovalConstructTxApiSpec &
359366
typeof ExpressWalletConsolidateUnspentsApiSpec &
360367
typeof ExpressV2WalletConsolidateAccountApiSpec &
368+
typeof ExpressV2WalletCloseAtaApiSpec &
361369
typeof ExpressWalletFanoutUnspentsApiSpec &
362370
typeof ExpressV2WalletCreateAddressApiSpec &
363371
typeof ExpressV2WalletIsWalletAddressApiSpec &
@@ -403,6 +411,7 @@ export const ExpressApi: ExpressApi = {
403411
...ExpressWalletFanoutUnspentsApiSpec,
404412
...ExpressV2WalletCreateAddressApiSpec,
405413
...ExpressV2WalletConsolidateAccountApiSpec,
414+
...ExpressV2WalletCloseAtaApiSpec,
406415
...ExpressV2WalletIsWalletAddressApiSpec,
407416
...ExpressKeychainLocalApiSpec,
408417
...ExpressKeychainChangePasswordApiSpec,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as t from 'io-ts';
2+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
3+
import { BitgoExpressError } from '../../schemas/error';
4+
5+
/**
6+
* Path parameters for close ATA endpoint
7+
*/
8+
export const CloseAtaParams = {
9+
/** Coin identifier (e.g., 'sol', 'tsol') */
10+
coin: t.string,
11+
/** Wallet ID */
12+
id: t.string,
13+
} as const;
14+
15+
/**
16+
* Request body for closing Associated Token Accounts
17+
* Based on BuildCloseAtaOptions which extends PrebuildTransactionOptions and WalletSignTransactionOptions
18+
*/
19+
export const CloseAtaRequestBody = {
20+
/** ATA addresses to close (required) */
21+
ataAddresses: t.array(t.string),
22+
23+
/** Wallet passphrase to decrypt the user key */
24+
walletPassphrase: optional(t.string),
25+
/** Extended private key (alternative to walletPassphrase) */
26+
xprv: optional(t.string),
27+
/** One-time password for 2FA */
28+
otp: optional(t.string),
29+
30+
/** Memo to include in the transaction */
31+
memo: optional(t.string),
32+
/** Fee rate for the transaction */
33+
feeRate: optional(t.number),
34+
/** Maximum fee rate */
35+
maxFeeRate: optional(t.number),
36+
37+
/** Transaction request ID */
38+
txRequestId: optional(t.string),
39+
40+
/** Private key for signing (from WalletSignBaseOptions) */
41+
prv: optional(t.string),
42+
/** Array of public keys */
43+
pubs: optional(t.array(t.string)),
44+
} as const;
45+
46+
/**
47+
* Response for close ATA operation
48+
* Returns arrays of successful and failed close ATA transactions
49+
*/
50+
export const CloseAtaResponse = t.type({
51+
/** Array of successfully sent close ATA transactions */
52+
success: t.array(t.unknown),
53+
/** Array of errors from failed close ATA transactions */
54+
failure: t.array(t.unknown),
55+
});
56+
57+
/**
58+
* Response for partial success or failure cases (202/400)
59+
*/
60+
export const CloseAtaErrorResponse = t.intersection([CloseAtaResponse, BitgoExpressError]);
61+
62+
/**
63+
* Close Associated Token Accounts
64+
*
65+
* This endpoint closes zero-balance Solana Associated Token Accounts (ATAs), returning
66+
* the rent SOL (~0.002 SOL per ATA) to the wallet's root address.
67+
*
68+
* ATAs with non-zero token balance will be rejected — tokens must be consolidated first.
69+
* Multiple ATAs can be batched into a single transaction (max ~27 per tx).
70+
* ATAs owned by different receive addresses are grouped into separate transactions.
71+
*
72+
* The API may return partial success (status 202) if some batches succeed but others fail.
73+
*
74+
* @operationId express.v2.wallet.closeata
75+
* @tag express
76+
*/
77+
export const PostCloseAta = httpRoute({
78+
path: '/api/v2/{coin}/wallet/{id}/closeAta',
79+
method: 'POST',
80+
request: httpRequest({
81+
params: CloseAtaParams,
82+
body: CloseAtaRequestBody,
83+
}),
84+
response: {
85+
/** Successfully closed ATAs */
86+
200: CloseAtaResponse,
87+
/** Partial success - some succeeded, others failed */
88+
202: CloseAtaErrorResponse,
89+
/** All closures failed */
90+
400: CloseAtaErrorResponse,
91+
},
92+
});

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

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { TransactionBuilder } from './transactionBuilder';
88
import { validateAddress } from './utils';
99

1010
export class CloseAtaBuilder extends TransactionBuilder {
11-
protected _accountAddress: string;
12-
protected _destinationAddress: string;
13-
protected _authorityAddress: string;
11+
// Unified storage for all close entries (single or bulk)
12+
protected _closeAtaEntries: { accountAddress: string; destinationAddress: string; authorityAddress: string }[] = [];
13+
14+
// Track whether the legacy single-ATA API is being used
15+
private _usingSingleAtaApi = false;
1416

1517
constructor(_coinConfig: Readonly<CoinConfig>) {
1618
super(_coinConfig);
@@ -21,21 +23,79 @@ export class CloseAtaBuilder extends TransactionBuilder {
2123
return TransactionType.CloseAssociatedTokenAccount;
2224
}
2325

26+
/**
27+
* Sets the ATA account address to close (single-ATA API, backward compatible).
28+
* Cannot be mixed with addCloseAtaInstruction().
29+
*/
2430
accountAddress(accountAddress: string): this {
2531
validateAddress(accountAddress, 'accountAddress');
26-
this._accountAddress = accountAddress;
32+
this._usingSingleAtaApi = true;
33+
this._ensureSingleEntry();
34+
this._closeAtaEntries[0].accountAddress = accountAddress;
2735
return this;
2836
}
2937

38+
/**
39+
* Sets the destination address for rent SOL (single-ATA API, backward compatible).
40+
* Cannot be mixed with addCloseAtaInstruction().
41+
*/
3042
destinationAddress(destinationAddress: string): this {
3143
validateAddress(destinationAddress, 'destinationAddress');
32-
this._destinationAddress = destinationAddress;
44+
this._usingSingleAtaApi = true;
45+
this._ensureSingleEntry();
46+
this._closeAtaEntries[0].destinationAddress = destinationAddress;
3347
return this;
3448
}
3549

50+
/**
51+
* Sets the authority address / ATA owner (single-ATA API, backward compatible).
52+
* Cannot be mixed with addCloseAtaInstruction().
53+
*/
3654
authorityAddress(authorityAddress: string): this {
3755
validateAddress(authorityAddress, 'authorityAddress');
38-
this._authorityAddress = authorityAddress;
56+
this._usingSingleAtaApi = true;
57+
this._ensureSingleEntry();
58+
this._closeAtaEntries[0].authorityAddress = authorityAddress;
59+
return this;
60+
}
61+
62+
/**
63+
* Ensures a single entry exists in _closeAtaEntries for the legacy API.
64+
*/
65+
private _ensureSingleEntry(): void {
66+
if (this._closeAtaEntries.length === 0) {
67+
this._closeAtaEntries.push({ accountAddress: '', destinationAddress: '', authorityAddress: '' });
68+
}
69+
}
70+
71+
/**
72+
* Add an ATA to close in this transaction (for bulk closure).
73+
* Cannot be mixed with the single-ATA API (accountAddress/destinationAddress/authorityAddress).
74+
*
75+
* @param {string} accountAddress - the ATA address to close
76+
* @param {string} destinationAddress - where rent SOL goes (root wallet address)
77+
* @param {string} authorityAddress - ATA owner who must sign
78+
*/
79+
addCloseAtaInstruction(accountAddress: string, destinationAddress: string, authorityAddress: string): this {
80+
if (this._usingSingleAtaApi) {
81+
throw new BuildTransactionError(
82+
'Cannot mix addCloseAtaInstruction() with single-ATA API (accountAddress/destinationAddress/authorityAddress)'
83+
);
84+
}
85+
86+
validateAddress(accountAddress, 'accountAddress');
87+
validateAddress(destinationAddress, 'destinationAddress');
88+
validateAddress(authorityAddress, 'authorityAddress');
89+
90+
if (accountAddress === destinationAddress) {
91+
throw new BuildTransactionError('Account address to close cannot be the same as the destination address');
92+
}
93+
94+
if (this._closeAtaEntries.some((entry) => entry.accountAddress === accountAddress)) {
95+
throw new BuildTransactionError('Duplicate ATA address: ' + accountAddress);
96+
}
97+
98+
this._closeAtaEntries.push({ accountAddress, destinationAddress, authorityAddress });
3999
return this;
40100
}
41101

@@ -45,33 +105,39 @@ export class CloseAtaBuilder extends TransactionBuilder {
45105
for (const instruction of this._instructionsData) {
46106
if (instruction.type === InstructionBuilderTypes.CloseAssociatedTokenAccount) {
47107
const ataCloseInstruction: AtaClose = instruction;
48-
this.accountAddress(ataCloseInstruction.params.accountAddress);
49-
this.destinationAddress(ataCloseInstruction.params.destinationAddress);
50-
this.authorityAddress(ataCloseInstruction.params.authorityAddress);
108+
this._closeAtaEntries.push({
109+
accountAddress: ataCloseInstruction.params.accountAddress,
110+
destinationAddress: ataCloseInstruction.params.destinationAddress,
111+
authorityAddress: ataCloseInstruction.params.authorityAddress,
112+
});
51113
}
52114
}
53115
}
54116

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

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

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

74-
this._instructionsData = [closeAssociatedTokenAccountData];
131+
this._instructionsData = this._closeAtaEntries.map(
132+
(entry): AtaClose => ({
133+
type: InstructionBuilderTypes.CloseAssociatedTokenAccount,
134+
params: {
135+
accountAddress: entry.accountAddress,
136+
destinationAddress: entry.destinationAddress,
137+
authorityAddress: entry.authorityAddress,
138+
},
139+
})
140+
);
75141

76142
return await super.buildImplementation();
77143
}

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);

0 commit comments

Comments
 (0)