From a16ab0f92b70a0be7cd13bcc48905c9383abcc99 Mon Sep 17 00:00:00 2001 From: Dadam Rishikesh Reddy Date: Sat, 3 Oct 2026 17:13:51 +0530 Subject: [PATCH] fix(sdk-coin-sol): resolve SOL token payment recipients during TSS verify-before-sign WCN-2113 re-introduced the pre-sign recipient verification in the EdDSA TSS sign flows. For SOL SPL payment intents the UI signs without txParams, so verification compares intent-fallback recipients (native address + tokenName from amount.symbol) against the explained transaction output (the recipient's associated token account). When the recipient's token name does not resolve in the statics map - unprefixed tokenData.tokenName ('usdt'), tokens registered outside the statics map, or a runtime where the statics lookup is unavailable - the mint cannot be resolved and every SOL token payment sign fails with 'Tx outputs does not match with expected txParams recipients'. - verifyTransaction: retry the token lookup with the chain-prefixed spelling ('usdt' -> 'sol:usdt'); when a recipient's tokenName is a genuine token NAME that still does not resolve and no tokenAddress is carried, use the mint from the explained output (the token actually moved) 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. - verifyTransaction total-amount check: group per-asset totals by canonical asset key (resolved mint) on both the recipient and output sides so the name-spelling fallbacks above do not break the totals comparison. - sdk-core resolveEffectiveTxParams: carry recipient-level tokenAddress/tokenProgramId (SOL consolidateToken intents) and tokenData.tokenContractAddress (EVM-style token intents) onto intent-fallback recipients. - sdk-core IntentRecipient: declare the optional tokenAddress/tokenProgramId fields wallet-platform persists on SOL consolidateToken intent recipients. Related: WCN-2113 (verification re-introduction), WCN-2952 (consolidation follow-up, PR #9878). This is the payment-intent counterpart: SOL token payment signing is broken for MPCv2 TSS wallets signing without explicit txParams. TICKET: WCN-2952 --- modules/sdk-coin-sol/src/sol.ts | 70 +++++++-- modules/sdk-coin-sol/test/unit/sol.ts | 137 ++++++++++++++++++ .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 12 ++ .../src/bitgo/utils/tss/recipientUtils.ts | 8 + .../unit/bitgo/utils/tss/recipientUtils.ts | 52 ++++++- 5 files changed, 264 insertions(+), 15 deletions(-) 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, {});