From df78c7fe6356af43e981327e1b37f207609d1e9e Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Wed, 24 Jun 2026 16:48:14 +0530 Subject: [PATCH] fix(sdk-coin-sol): support unsupported tokens in verifyTransaction TICKET: CSHLD-1111 --- modules/sdk-coin-sol/src/sol.ts | 42 ++-- modules/sdk-coin-sol/test/unit/sol.ts | 196 +++++++++++++++++- .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 2 + 3 files changed, 224 insertions(+), 16 deletions(-) diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 33ab266bd2..dafa7f7022 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -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; @@ -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'])); @@ -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; @@ -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 + ); } } diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 065680911f..1ae6bbbb72 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -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 { @@ -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(); diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 986fae8eb9..8d4cda4a27 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -81,6 +81,8 @@ export interface ITransactionRecipient { address: string; amount: string | number; tokenName?: string; + tokenAddress?: string; + programId?: string; memo?: string; data?: string; }