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
42 changes: 27 additions & 15 deletions modules/sdk-coin-sol/src/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ export class Sol extends BaseCoin {
}

const transaction = new Transaction(coinConfig);
// Allow unsupported tokens (not in static map) to use their mint address as tokenName during parsing
transaction.setUseTokenAddressTokenName(true);
const rawTx = txPrebuild.txBase64 || txPrebuild.txHex;
const consolidateId = txPrebuild.consolidateId;

Expand Down Expand Up @@ -571,7 +573,7 @@ export class Sol extends BaseCoin {
// Close-ATA txs do not populate explainedTx.outputs; recipients carry ATA addresses for intent only.
if (txParams.recipients !== undefined && !isTokenEnablementTx && !isCloseAssociatedTokenAccountTx) {
const filteredRecipients = txParams.recipients?.map((recipient) =>
_.pick(recipient, ['address', 'amount', 'tokenName'])
_.pick(recipient, ['address', 'amount', 'tokenName', 'tokenAddress', 'programId'])
);
const filteredOutputs = explainedTx.outputs.map((output) => _.pick(output, ['address', 'amount', 'tokenName']));

Expand Down Expand Up @@ -609,15 +611,19 @@ export class Sol extends BaseCoin {
// If getAssociatedTokenAccountAddress throws an error, then we are unable to derive the ATA for that address.
// Return false and throw an error if that is the case.
try {
const tokenMintAddress = getSolTokenFromTokenName(recipientFromUser.tokenName);
return getAssociatedTokenAccountAddress(
tokenMintAddress!.tokenAddress,
recipientFromUser.address,
true,
tokenMintAddress!.programId
).then((ata: string) => {
return ata === recipientFromTx.address;
});
const tokenFromMap = getSolTokenFromTokenName(recipientFromUser.tokenName);
const mintAddress = tokenFromMap?.tokenAddress ?? recipientFromUser.tokenAddress;
const programId = tokenFromMap?.programId ?? recipientFromUser.programId;

if (!mintAddress) {
return false;
}

return getAssociatedTokenAccountAddress(mintAddress, recipientFromUser.address, true, programId).then(
(ata: string) => {
return ata === recipientFromTx.address;
}
);
} catch {
// Unable to derive ATA
return false;
Expand All @@ -641,16 +647,22 @@ export class Sol extends BaseCoin {
if (output.tokenName) {
// Check cache first before deriving ATA address
if (!ataAddressCache[output.tokenName]) {
const tokenMintAddress = getSolTokenFromTokenName(output.tokenName);
if (tokenMintAddress?.tokenAddress && tokenMintAddress?.programId) {
const tokenFromMap = getSolTokenFromTokenName(output.tokenName);
if (tokenFromMap?.tokenAddress && tokenFromMap?.programId) {
ataAddressCache[output.tokenName] = await getAssociatedTokenAccountAddress(
tokenMintAddress.tokenAddress,
tokenFromMap.tokenAddress,
walletRootAddress as string,
true,
tokenMintAddress.programId
tokenFromMap.programId
);
} else {
throw new Error(`Unable to get token information for ${output.tokenName}`);
// For unsupported tokens, explainTransaction sets tokenName to the mint address itself.
// Attempt ATA derivation using tokenName as the mint address; programId defaults to TOKEN_PROGRAM_ID.
ataAddressCache[output.tokenName] = await getAssociatedTokenAccountAddress(
output.tokenName,
walletRootAddress as string,
true
);
}
}

Expand Down
196 changes: 195 additions & 1 deletion modules/sdk-coin-sol/test/unit/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import nock from 'nock';
import * as should from 'should';
import * as sinon from 'sinon';

import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token';

import { BitGoAPI, encrypt } from '@bitgo/sdk-api';
import {
Expand Down Expand Up @@ -677,6 +677,200 @@ describe('SOL:', function () {
.should.be.rejectedWith('Tx outputs does not match with expected txParams recipients');
});

it('should succeed to verify token transaction for unsupported token when tokenAddress and programId are provided', async function () {
// Use an address that is NOT in the static token map as the unsupported mint
const unsupportedMintAddress = resources.stakeAccount.pub; // '3c5emUWjViFqT72LxQYec8gkU8ZtmfKKXHvGgJNUBdYx'
const recipientNativeAddress = resources.authAccount2.pub;
const amount = '1000';

// Pre-derive the ATA so the builder's instruction destination matches what verifyTransaction will compute
const ataAddress = await getAssociatedTokenAccountAddress(
unsupportedMintAddress,
recipientNativeAddress,
true,
TOKEN_PROGRAM_ID.toString()
);

const txBuilder = factory.getTokenTransferBuilder();
txBuilder.sender(wallet.pub);
txBuilder.nonce(blockHash);
txBuilder.fee({ amount: 5000 });
txBuilder.send({
address: ataAddress, // pass the ATA as the direct destination
amount,
tokenName: unsupportedMintAddress, // tokenName equals mint address (matches explainTransaction fallback)
tokenAddress: unsupportedMintAddress,
programId: TOKEN_PROGRAM_ID.toString(),
decimalPlaces: 6,
});
const tx = await txBuilder.build();

const txPrebuild = {
txBase64: tx.toBroadcastFormat(),
txInfo: { feePayer: wallet.pub, nonce: blockHash },
coin: 'tsol',
};
const txParams = {
recipients: [
{
address: recipientNativeAddress, // native address — verifyTransaction will derive the ATA
amount,
tokenName: unsupportedMintAddress, // must match output's tokenName (mint address as fallback)
tokenAddress: unsupportedMintAddress,
programId: TOKEN_PROGRAM_ID.toString(),
},
],
};

const result = await basecoin.verifyTransaction({ txParams, txPrebuild, wallet: walletObj } as any);
result.should.equal(true);
});

it('should fail to verify token transaction for unsupported token when tokenAddress and programId are not provided', async function () {
const unsupportedMintAddress = resources.stakeAccount.pub;
const recipientNativeAddress = resources.authAccount2.pub;
const amount = '1000';

const ataAddress = await getAssociatedTokenAccountAddress(
unsupportedMintAddress,
recipientNativeAddress,
true,
TOKEN_PROGRAM_ID.toString()
);

const txBuilder = factory.getTokenTransferBuilder();
txBuilder.sender(wallet.pub);
txBuilder.nonce(blockHash);
txBuilder.fee({ amount: 5000 });
txBuilder.send({
address: ataAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_PROGRAM_ID.toString(),
decimalPlaces: 6,
});
const tx = await txBuilder.build();

const txPrebuild = {
txBase64: tx.toBroadcastFormat(),
txInfo: { feePayer: wallet.pub, nonce: blockHash },
coin: 'tsol',
};
// No tokenAddress or programId — static map lookup will fail for unsupported token
const txParams = {
recipients: [
{
address: recipientNativeAddress,
amount,
tokenName: unsupportedMintAddress,
},
],
};

await basecoin
.verifyTransaction({ txParams, txPrebuild, wallet: walletObj } as any)
.should.be.rejectedWith('Tx outputs does not match with expected txParams recipients');
});

it('should succeed to verify token transaction for unsupported Token-2022 token when tokenAddress and programId are provided', async function () {
const unsupportedMintAddress = resources.stakeAccount.pub;
const recipientNativeAddress = resources.authAccount2.pub;
const amount = '1000';

const ataAddress = await getAssociatedTokenAccountAddress(
unsupportedMintAddress,
recipientNativeAddress,
true,
TOKEN_2022_PROGRAM_ID.toString()
);

const txBuilder = factory.getTokenTransferBuilder();
txBuilder.sender(wallet.pub);
txBuilder.nonce(blockHash);
txBuilder.fee({ amount: 5000 });
txBuilder.send({
address: ataAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_2022_PROGRAM_ID.toString(),
decimalPlaces: 6,
});
const tx = await txBuilder.build();

const txPrebuild = {
txBase64: tx.toBroadcastFormat(),
txInfo: { feePayer: wallet.pub, nonce: blockHash },
coin: 'tsol',
};
const txParams = {
recipients: [
{
address: recipientNativeAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_2022_PROGRAM_ID.toString(),
},
],
};

const result = await basecoin.verifyTransaction({ txParams, txPrebuild, wallet: walletObj } as any);
result.should.equal(true);
});

it('should fail to verify token transaction for unsupported Token-2022 token when wrong programId is provided', async function () {
const unsupportedMintAddress = resources.stakeAccount.pub;
const recipientNativeAddress = resources.authAccount2.pub;
const amount = '1000';

// Build with Token-2022 program ID (ATA derived using TOKEN_2022_PROGRAM_ID)
const ataAddress = await getAssociatedTokenAccountAddress(
unsupportedMintAddress,
recipientNativeAddress,
true,
TOKEN_2022_PROGRAM_ID.toString()
);

const txBuilder = factory.getTokenTransferBuilder();
txBuilder.sender(wallet.pub);
txBuilder.nonce(blockHash);
txBuilder.fee({ amount: 5000 });
txBuilder.send({
address: ataAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_2022_PROGRAM_ID.toString(),
decimalPlaces: 6,
});
const tx = await txBuilder.build();

const txPrebuild = {
txBase64: tx.toBroadcastFormat(),
txInfo: { feePayer: wallet.pub, nonce: blockHash },
coin: 'tsol',
};
// Verify with the wrong programId (TOKEN_PROGRAM_ID instead of TOKEN_2022_PROGRAM_ID)
// ATA derivation will produce a different address → mismatch → rejection
const txParams = {
recipients: [
{
address: recipientNativeAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_PROGRAM_ID.toString(),
},
],
};

await basecoin
.verifyTransaction({ txParams, txPrebuild, wallet: walletObj } as any)
.should.be.rejectedWith('Tx outputs does not match with expected txParams recipients');
});

it('should succeed to verify transactions when recipients has extra data', async function () {
const txParams = newTxParamsWithExtraData();
const txPrebuild = newTxPrebuild();
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface ITransactionRecipient {
address: string;
amount: string | number;
tokenName?: string;
tokenAddress?: string;
programId?: string;
memo?: string;
data?: string;
}
Expand Down
Loading