diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 0460f36831..916dd32d90 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -660,8 +660,29 @@ 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 tokenFromMap = getSolTokenFromTokenName(recipientFromUser.tokenName); - const mintAddress = tokenFromMap?.tokenAddress ?? recipientFromUser.tokenAddress; + // Resolve the mint from the recipient's token name, retrying with the + // chain-prefixed spelling ('usdt' -> 'tsol:usdt') when the bare name does + // not resolve to a Solana token. wallet-platform persists tokenData.tokenName + // without the chain prefix on some intent types. + const tokenFromMap = + getSolTokenFromTokenName(recipientFromUser.tokenName) ?? + getSolTokenFromTokenName(`${this.getChain()}:${recipientFromUser.tokenName}`); + let mintAddress = tokenFromMap?.tokenAddress ?? recipientFromUser.tokenAddress; + + // A recipient whose tokenName is a genuine token NAME (not a mint address) + // whose token is not resolvable here — a token registered outside the + // statics map, or a runtime where the statics lookup is unavailable — has + // one remaining source of truth for the mint: the explained output itself. + // Use it and prove the output is the recipient's associated token account + // for that mint. Recipients whose tokenName IS a mint address + // (unsupported-token shape) keep requiring an explicit tokenAddress: the + // tx side must not vouch for them. + if (!mintAddress && !isValidAddress(recipientFromUser.tokenName)) { + if (recipientFromTx.tokenName && isValidAddress(recipientFromTx.tokenName)) { + mintAddress = recipientFromTx.tokenName; + } + } + const programId = tokenFromMap?.programId ?? recipientFromUser.programId; if (!mintAddress) { @@ -729,24 +750,45 @@ export class Sol extends BaseCoin { throw new Error('Tx memo does not match with expected txParams recipient memo'); } if (txParams.recipients && !isTokenEnablementTx && !isCloseAssociatedTokenAccountTx) { - for (const recipients of txParams.recipients) { - // totalAmount based on each token - const assetName = recipients.tokenName || this.getChain(); - const amount = totalAmount[assetName] || new BigNumber(0); - totalAmount[assetName] = amount.plus(recipients.amount); - } + // Canonical asset key for a token name: the mint when the token is resolvable in + // the statics map (retrying the chain-prefixed spelling for unprefixed names), + // otherwise the name/address as given. + const assetKeyFor = (tokenName: string | undefined): string => { + if (!tokenName) { + return this.getChain(); + } + const token = + getSolTokenFromTokenName(tokenName) ?? getSolTokenFromTokenName(`${this.getChain()}:${tokenName}`); + return token?.tokenAddress ?? tokenName; + }; - // total output amount from explainedTx + // Total output amount from explainedTx, keyed by canonical asset. Built first so + // recipients can adopt the output-side key when their own tokenName is not + // resolvable here (the output's token identity is the ground truth for the token + // actually moved — mirrors the recipient check above). const explainedTxTotal: Record = {}; - for (const output of explainedTx.outputs) { // Apply s390x endianness fix to output amounts before summing const outputAmountStr = getAmountBasedOnEndianness(output.amount); + const assetName = assetKeyFor(output.tokenName); + explainedTxTotal[assetName] = (explainedTxTotal[assetName] || new BigNumber(0)).plus(outputAmountStr); + } - // total output amount based on each token - const assetName = output.tokenName || this.getChain(); - const amount = explainedTxTotal[assetName] || new BigNumber(0); - explainedTxTotal[assetName] = amount.plus(outputAmountStr); + for (const [index, recipients] of txParams.recipients.entries()) { + // totalAmount based on each token + const resolvedToken = recipients.tokenName + ? getSolTokenFromTokenName(recipients.tokenName) ?? + getSolTokenFromTokenName(`${this.getChain()}:${recipients.tokenName}`) + : undefined; + const outputAssetKey = explainedTx.outputs[index] + ? assetKeyFor(explainedTx.outputs[index].tokenName) + : undefined; + const assetName = + resolvedToken?.tokenAddress ?? + recipients.tokenAddress ?? + outputAssetKey ?? + (recipients.tokenName || this.getChain()); + totalAmount[assetName] = (totalAmount[assetName] || new BigNumber(0)).plus(recipients.amount); } if (!_.isEqual(explainedTxTotal, totalAmount)) { diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 5a0618140f..040cb901d6 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -928,6 +928,143 @@ describe('SOL:', function () { .should.be.rejectedWith('Tx outputs does not match with expected txParams recipients'); }); + it('should succeed to verify token transaction when recipient tokenName is not chain-prefixed', async function () { + // wallet-platform persists tokenData.tokenName without the chain prefix on some + // intent types; the statics lookup must retry the chain-prefixed spelling. + const txParams = newTxParamsTokenTransfer(); + const address = 'AF5H6vBkFnJuVqChRPgPQ4JRcQ5Gk25HBFhQQkyojmvg'; // Native SOL address + txParams.recipients = [{ address, amount: '1', tokenName: 'usdc' }]; + const txPrebuild = newTxPrebuildTokenTransfer(); + const feePayerWalletData = { + id: '5b34252f1bf349930e34020a00000000', + coin: 'tsol', + keys: [ + '5b3424f91bf349930e34017500000000', + '5b3424f91bf349930e34017600000000', + '5b3424f91bf349930e34017700000000', + ], + coinSpecific: { + rootAddress: '4DujymUFbQ8GBKtAwAZrQ6QqpvtBEivL48h4ta2oJGd2', + }, + multisigType: 'tss', + }; + const feePayerWallet = new Wallet(bitgo, basecoin, feePayerWalletData); + const validTransaction = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: feePayerWallet, + } as unknown as SolVerifyTransactionOptions); + validTransaction.should.equal(true); + }); + + it('should succeed to verify token transaction for a token name outside the statics map when the explained output carries the mint', async function () { + // The recipient declares a token by NAME that is not resolvable in the statics + // map (e.g. an AMS-registered token) and carries no tokenAddress. The explained + // output carries the actual mint as its tokenName (useTokenAddressTokenName + // fallback), which is the source of truth for the token being moved: prove the + // output is the recipient's associated token account for that mint. + 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', + }; + const txParams = { + recipients: [ + { + address: recipientNativeAddress, + amount, + tokenName: 'tsol:ams-custom', // token NAME, not a mint address; not in the statics map + }, + ], + }; + + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: walletObj, + } as unknown as SolVerifyTransactionOptions); + result.should.equal(true); + }); + + it('should fail to verify token transaction when the output token account belongs to a different owner', async function () { + // The output-mint fallback must still prove ownership: the ATA derived for the + // intent recipient has to equal the tx output, so a token account owned by + // someone else is rejected. + const unsupportedMintAddress = resources.stakeAccount.pub; + const recipientNativeAddress = resources.authAccount2.pub; + const otherOwnerAddress = wallet.pub; + const amount = '1000'; + + const otherOwnerAtaAddress = await getAssociatedTokenAccountAddress( + unsupportedMintAddress, + otherOwnerAddress, + true, + TOKEN_PROGRAM_ID.toString() + ); + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.sender(wallet.pub); + txBuilder.nonce(blockHash); + txBuilder.fee({ amount: 5000 }); + txBuilder.send({ + address: otherOwnerAtaAddress, + 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', + }; + const txParams = { + recipients: [ + { + address: recipientNativeAddress, + amount, + tokenName: 'tsol:ams-custom', + }, + ], + }; + + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + wallet: walletObj, + } as unknown as SolVerifyTransactionOptions) + .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/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index df64526fa4..471ab6d64f 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -431,6 +431,18 @@ export interface IntentRecipient { }; data?: string; tokenData?: TokenTransferRecipientParams; + /** + * On-chain token mint for SOL-family token intents. wallet-platform persists this on + * consolidateToken intent recipients (see WP coins/sol/transactions/intent/consolidate.ts + * enrichedRecipients); carrying it through lets coin-level verifyTransaction resolve the + * mint without relying on a statics name lookup. + */ + tokenAddress?: string; + /** + * SPL token program for SOL-family token intents (TOKEN_PROGRAM_ID or + * TOKEN_2022_PROGRAM_ID). Persisted by wallet-platform alongside tokenAddress. + */ + tokenProgramId?: string; } interface PopulatedIntentBase { intentType: string; diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index c167386b08..10f39d201f 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -140,11 +140,19 @@ export function resolveEffectiveTxParams( const tokenName = intentRecipient.tokenData?.tokenName || (chainName !== undefined && symbol && symbol !== chainName ? symbol : undefined); + // Carry token identity when the intent provides it so coin-level verification can + // resolve the mint without a statics name lookup: + // - tokenData.tokenContractAddress (EVM-style token intents) + // - recipient-level tokenAddress/tokenProgramId (SOL consolidateToken intents) + const tokenAddress = intentRecipient.tokenData?.tokenContractAddress ?? intentRecipient.tokenAddress; + const programId = intentRecipient.tokenProgramId; return { address: intentRecipient.address.address, amount: intentRecipient.amount.value, data: intentRecipient.data, ...(tokenName && { tokenName }), + ...(tokenAddress && { tokenAddress }), + ...(programId && { programId }), }; }); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 1c93d7ca99..dbc456f295 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -5,7 +5,7 @@ import { resolveTssVerifyTransactionOptions, } from '../../../../../src/bitgo/utils/tss/recipientUtils'; import { InvalidTransactionError } from '../../../../../src/bitgo/errors'; -import { PopulatedIntent, TxRequest } from '../../../../../src/bitgo/utils/tss/baseTypes'; +import { PopulatedIntent, TokenType, TxRequest } from '../../../../../src/bitgo/utils/tss/baseTypes'; function makeTxRequest(overrides: Partial = {}): TxRequest { return { @@ -113,6 +113,56 @@ describe('recipientUtils', function () { assert.strictEqual(result.recipients?.[0].amount, '500'); }); + it('carries recipient-level SPL token identity onto fallback recipients', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'consolidateToken', + recipients: [ + { + address: { address: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs' }, + amount: { value: '1000', symbol: 'sol:usdt' }, + tokenAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + tokenProgramId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + ], + } as PopulatedIntent, + }); + const result = resolveEffectiveTxParams(txRequest, undefined, 'sol'); + assert.deepStrictEqual(result.recipients, [ + { + address: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs', + amount: '1000', + data: undefined, + tokenName: 'sol:usdt', + tokenAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + ]); + }); + + it('carries tokenData.tokenContractAddress as tokenAddress for EVM-style token intents', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: '0xabc' }, + amount: { value: '0', symbol: 'eth' }, + tokenData: { + tokenType: TokenType.ERC20, + tokenQuantity: '500', + tokenName: 'eth:usdt', + tokenContractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }, + ], + } as PopulatedIntent, + }); + const result = resolveEffectiveTxParams(txRequest, undefined, 'eth'); + assert.strictEqual(result.recipients?.[0].tokenName, 'eth:usdt'); + assert.strictEqual(result.recipients?.[0].tokenAddress, '0xdAC17F958D2ee523a2206206994597C13D831ec7'); + }); + it('resolves txType from intent.intentType when txParams.type is absent', function () { const txRequest = makeTxRequest({ intent: { intentType: 'consolidate' } as any }); const result = resolveEffectiveTxParams(txRequest, {});