Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 113 additions & 24 deletions modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinConfig>) {
super(_coinConfig);
Expand All @@ -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;
Comment thread
abhijeet8986 marked this conversation as resolved.
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;
Comment thread
abhijeet8986 marked this conversation as resolved.
}

/**
* 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;
}

Expand All @@ -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<Transaction> {
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');
}
}
Comment thread
abhijeet8986 marked this conversation as resolved.

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();
}
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-coin-sol/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
abhijeet8986 marked this conversation as resolved.

if (instructions.filter((instruction) => getInstructionType(instruction) === 'Deactivate').length === 0) {
for (const instruction of instructions) {
const instructionType = getInstructionType(instruction);
Expand Down
88 changes: 85 additions & 3 deletions modules/sdk-coin-sol/src/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
TransactionExplanation,
TransactionParams,
TransactionRecipient,
TransactionType,
VerifyTransactionOptions,
TssVerifyAddressOptions,
verifyEddsaTssWalletAddress,
Expand All @@ -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,
Expand Down Expand Up @@ -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');
}
Comment thread
abhijeet8986 marked this conversation as resolved.
}

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 } {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Comment thread
abhijeet8986 marked this conversation as resolved.
const filteredRecipients = txParams.recipients?.map((recipient) =>
_.pick(recipient, ['address', 'amount', 'tokenName'])
);
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading