diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 9a102036e2..130a933d34 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -43,6 +43,7 @@ import { verifyMPCWalletAddress, TssVerifyAddressOptions, isTssVerifyAddressOptions, + NO_RECIPIENT_TX_TYPES, } from '@bitgo/sdk-core'; import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { bip32 } from '@bitgo/secp256k1'; @@ -3104,33 +3105,28 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { ); }; + if (!wallet || !txPrebuild) { + throw new Error('missing params'); + } + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error('tx cannot be both a batch and hop transaction'); + } + if ( + !params.verification?.skipTssRecipientVerification && !txParams?.recipients && !( txParams.prebuildTx?.consolidateId || txPrebuild?.consolidateId || - (txParams.type && - [ - 'acceleration', - 'fillNonce', - 'transferToken', - 'tokenApproval', - 'consolidate', - 'bridgeFunds', - 'enabletoken', - ].includes(txParams.type)) + txParams.stakingRequestId || + txParams.prebuildTx?.stakingRequestId || + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error('missing txParams'); } - if (!wallet || !txPrebuild) { - throw new Error('missing params'); - } - if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error('tx cannot be both a batch and hop transaction'); - } - if (txParams.type && ['transfer'].includes(txParams.type)) { + if (!params.verification?.skipTssRecipientVerification && txParams.type && ['transfer'].includes(txParams.type)) { if (txParams.recipients && txParams.recipients.length === 1) { const recipients = txParams.recipients; const expectedAmount = recipients[0].amount.toString(); diff --git a/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts b/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts index 6c727ffdc5..cafacdb172 100644 --- a/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts +++ b/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts @@ -287,6 +287,8 @@ export const VerificationOptions = t.partial({ verifyTokenEnablement: t.boolean, /** Verify consolidation to base address */ consolidationToBaseAddress: t.boolean, + /** Skip TSS recipient verification during signing */ + skipTssRecipientVerification: t.boolean, }); /** diff --git a/modules/sdk-coin-bsc/src/bsc.ts b/modules/sdk-coin-bsc/src/bsc.ts index af5e529a26..077175624b 100644 --- a/modules/sdk-coin-bsc/src/bsc.ts +++ b/modules/sdk-coin-bsc/src/bsc.ts @@ -1,4 +1,12 @@ -import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core'; +import { + BaseCoin, + BitGoBase, + common, + MPCAlgorithm, + MultisigType, + multisigTypes, + NO_RECIPIENT_TX_TYPES, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { AbstractEthLikeNewCoins, @@ -66,21 +74,25 @@ export class Bsc extends AbstractEthLikeNewCoins { */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; + if (!wallet || !txPrebuild) { + throw new Error(`missing params`); + } + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + if ( + !params.verification?.skipTssRecipientVerification && !txParams?.recipients && !( txParams.prebuildTx?.consolidateId || - (txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type)) + txParams.stakingRequestId || + txParams.prebuildTx?.stakingRequestId || + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error(`missing txParams`); } - if (!wallet || !txPrebuild) { - throw new Error(`missing params`); - } - if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); - } return true; } diff --git a/modules/sdk-coin-bsc/src/bscToken.ts b/modules/sdk-coin-bsc/src/bscToken.ts index ea399bc65d..11c07304c9 100644 --- a/modules/sdk-coin-bsc/src/bscToken.ts +++ b/modules/sdk-coin-bsc/src/bscToken.ts @@ -3,7 +3,7 @@ */ import { EthLikeTokenConfig, coins } from '@bitgo/statics'; -import { BitGoBase, CoinConstructor, NamedCoinConstructor, MPCAlgorithm } from '@bitgo/sdk-core'; +import { BitGoBase, CoinConstructor, NamedCoinConstructor, MPCAlgorithm, NO_RECIPIENT_TX_TYPES } from '@bitgo/sdk-core'; import { CoinNames, EthLikeToken, VerifyEthTransactionOptions } from '@bitgo/abstract-eth'; import { TransactionBuilder } from './lib'; @@ -54,21 +54,25 @@ export class BscToken extends EthLikeToken { */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; + if (!wallet || !txPrebuild) { + throw new Error(`missing params`); + } + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + if ( + !params.verification?.skipTssRecipientVerification && !txParams?.recipients && !( txParams.prebuildTx?.consolidateId || - (txParams.type && ['acceleration', 'fillNonce', 'transferToken'].includes(txParams.type)) + txParams.stakingRequestId || + txParams.prebuildTx?.stakingRequestId || + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error(`missing txParams`); } - if (!wallet || !txPrebuild) { - throw new Error(`missing params`); - } - if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); - } return true; } diff --git a/modules/sdk-coin-bsc/test/unit/bsc.ts b/modules/sdk-coin-bsc/test/unit/bsc.ts index 6c80d6047b..68ebee3eb0 100644 --- a/modules/sdk-coin-bsc/test/unit/bsc.ts +++ b/modules/sdk-coin-bsc/test/unit/bsc.ts @@ -1,4 +1,5 @@ import 'should'; +import assert from 'assert'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; @@ -39,4 +40,123 @@ describe('Native BNB', function () { tbsc.allowsAccountConsolidations().should.equal(true); }); }); + + describe('verifyTssTransaction', function () { + let bsc: Bsc; + + before(function () { + bsc = bitgo.coin('bsc') as Bsc; + }); + + const baseTxPrebuild = { txHex: '0xdeadbeef' } as any; + const baseWallet = {} as any; + + it('returns true immediately when skipTssRecipientVerification is true', async function () { + const result = await bsc.verifyTssTransaction({ + txParams: {} as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + verification: { skipTssRecipientVerification: true }, + }); + assert.strictEqual(result, true); + }); + + it('throws when no recipients and skipTssRecipientVerification is false', async function () { + await assert.rejects( + () => + bsc.verifyTssTransaction({ + txParams: {} as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + verification: { skipTssRecipientVerification: false }, + }), + /missing txParams/ + ); + }); + + it('throws when no recipients and verification is not set', async function () { + await assert.rejects( + () => + bsc.verifyTssTransaction({ + txParams: {} as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + }), + /missing txParams/ + ); + }); + + it('returns true for exempt type without skipTssRecipientVerification', async function () { + const result = await bsc.verifyTssTransaction({ + txParams: { type: 'delegate' } as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + }); + assert.strictEqual(result, true); + }); + + it('returns true when recipients are present without flag', async function () { + const result = await bsc.verifyTssTransaction({ + txParams: { recipients: [{ address: '0xabc', amount: '100' }] } as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + }); + assert.strictEqual(result, true); + }); + + it('still throws missing params when wallet is missing even with skipTssRecipientVerification', async function () { + await assert.rejects( + () => + bsc.verifyTssTransaction({ + txParams: {} as any, + txPrebuild: baseTxPrebuild, + wallet: undefined as any, + verification: { skipTssRecipientVerification: true }, + }), + /missing params/ + ); + }); + + it('still throws missing params when txPrebuild is missing even with skipTssRecipientVerification', async function () { + await assert.rejects( + () => + bsc.verifyTssTransaction({ + txParams: {} as any, + txPrebuild: undefined as any, + wallet: baseWallet, + verification: { skipTssRecipientVerification: true }, + }), + /missing params/ + ); + }); + + it('still throws hop error even with skipTssRecipientVerification', async function () { + await assert.rejects( + () => + bsc.verifyTssTransaction({ + txParams: { + hop: true, + recipients: [ + { address: '0xabc', amount: '100' }, + { address: '0xdef', amount: '200' }, + ], + } as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + verification: { skipTssRecipientVerification: true }, + }), + /tx cannot be both a batch and hop transaction/ + ); + }); + + it('skips recipient check but passes other validation with skipTssRecipientVerification', async function () { + const result = await bsc.verifyTssTransaction({ + txParams: {} as any, + txPrebuild: baseTxPrebuild, + wallet: baseWallet, + verification: { skipTssRecipientVerification: true }, + }); + assert.strictEqual(result, true); + }); + }); }); diff --git a/modules/sdk-coin-evm/src/evmCoin.ts b/modules/sdk-coin-evm/src/evmCoin.ts index 651b80116a..296b79b6f4 100644 --- a/modules/sdk-coin-evm/src/evmCoin.ts +++ b/modules/sdk-coin-evm/src/evmCoin.ts @@ -9,6 +9,7 @@ import { MPCAlgorithm, MultisigType, multisigTypes, + NO_RECIPIENT_TX_TYPES, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, CoinFeature, coins, CoinFamily } from '@bitgo/statics'; import { @@ -112,26 +113,28 @@ export class EvmCoin extends AbstractEthLikeNewCoins { private async verifyLegacyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; - // Basic validation for legacy transactions only + if (!wallet || !txPrebuild) { + throw new Error(`missing params`); + } + + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + + // Only enforce recipient presence when skipTssRecipientVerification is not set if ( + !params.verification?.skipTssRecipientVerification && !txParams?.recipients && !( txParams.prebuildTx?.consolidateId || - (txParams.type && - ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval', 'bridgeFunds'].includes(txParams.type)) + txParams.stakingRequestId || + txParams.prebuildTx?.stakingRequestId || + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error(`missing txParams`); } - if (!wallet || !txPrebuild) { - throw new Error(`missing params`); - } - - if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); - } - // If validation passes, consider it verified return true; } diff --git a/modules/sdk-coin-xdc/src/xdc.ts b/modules/sdk-coin-xdc/src/xdc.ts index 285004fc25..4ada1d6d2a 100644 --- a/modules/sdk-coin-xdc/src/xdc.ts +++ b/modules/sdk-coin-xdc/src/xdc.ts @@ -1,4 +1,12 @@ -import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core'; +import { + BaseCoin, + BitGoBase, + common, + MPCAlgorithm, + MultisigType, + multisigTypes, + NO_RECIPIENT_TX_TYPES, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { AbstractEthLikeNewCoins, @@ -58,22 +66,25 @@ export class Xdc extends AbstractEthLikeNewCoins { */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; + if (!wallet || !txPrebuild) { + throw new Error(`missing params`); + } + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + if ( + !params.verification?.skipTssRecipientVerification && !txParams?.recipients && !( txParams.prebuildTx?.consolidateId || - (txParams.type && - ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval', 'consolidate'].includes(txParams.type)) + txParams.stakingRequestId || + txParams.prebuildTx?.stakingRequestId || + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error(`missing txParams`); } - if (!wallet || !txPrebuild) { - throw new Error(`missing params`); - } - if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); - } return true; } diff --git a/modules/sdk-coin-xdc/src/xdcToken.ts b/modules/sdk-coin-xdc/src/xdcToken.ts index 3224250cce..09bdfaebbd 100644 --- a/modules/sdk-coin-xdc/src/xdcToken.ts +++ b/modules/sdk-coin-xdc/src/xdcToken.ts @@ -2,7 +2,14 @@ * @prettier */ import { EthLikeTokenConfig, coins } from '@bitgo/statics'; -import { BitGoBase, CoinConstructor, NamedCoinConstructor, common, MPCAlgorithm } from '@bitgo/sdk-core'; +import { + BitGoBase, + CoinConstructor, + NamedCoinConstructor, + common, + MPCAlgorithm, + NO_RECIPIENT_TX_TYPES, +} from '@bitgo/sdk-core'; import { CoinNames, EthLikeToken, @@ -69,22 +76,25 @@ export class XdcToken extends EthLikeToken { */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; + if (!wallet || !txPrebuild) { + throw new Error(`missing params`); + } + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + if ( + !params.verification?.skipTssRecipientVerification && !txParams?.recipients && !( txParams.prebuildTx?.consolidateId || - (txParams.type && - ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval', 'consolidate'].includes(txParams.type)) + txParams.stakingRequestId || + txParams.prebuildTx?.stakingRequestId || + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error(`missing txParams`); } - if (!wallet || !txPrebuild) { - throw new Error(`missing params`); - } - if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); - } return true; } diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 510d0d8abd..591c6dbeee 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -216,6 +216,7 @@ export interface TransactionParams { type?: string; memo?: Memo; enableTokens?: TokenEnablement[]; + stakingRequestId?: string; } export interface AddressVerificationData { @@ -237,6 +238,9 @@ export interface VerificationOptions { verifyTokenEnablement?: boolean; // Verify transaction is consolidating to wallet's base address consolidationToBaseAddress?: boolean; + // Skip TSS recipient verification during signing (safety-net opt-out for callers + // whose transaction type legitimately carries no explicit recipients). + skipTssRecipientVerification?: boolean; } export interface VerifyTransactionOptions { diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 306dd20549..33acf47ae8 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -1,6 +1,12 @@ import { Key, SerializedKeyPair } from 'openpgp'; import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../../api'; -import { type ITransactionRecipient, KeychainsTriplet, ParsedTransaction, TransactionParams } from '../../baseCoin'; +import { + type ITransactionRecipient, + KeychainsTriplet, + ParsedTransaction, + TransactionParams, + VerificationOptions, +} from '../../baseCoin'; import { ApiKeyShare, Keychain, WebauthnKeyEncryptionInfo } from '../../keychain'; import { ApiVersion, Memo, WalletType } from '../../wallet'; import { EDDSA, GShare, Signature, SignShare } from '../../../account-lib/mpc/tss'; @@ -410,6 +416,7 @@ export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase } export interface PopulatedIntent extends PopulatedIntentBase, DefiIntentFields { + stakingRequestId?: string; recipients?: IntentRecipient[]; nonce?: string; token?: string; @@ -678,6 +685,7 @@ export type TssSignTxExplicitRecipientParams = { apiVersion?: ApiVersion; recipientSource: typeof TssTxRecipientSource.Explicit; txParams: TransactionParamsWithMandatoryRecipients; + verification?: VerificationOptions; }; export type TssSignTxResolvedRecipientParams = { @@ -686,6 +694,7 @@ export type TssSignTxResolvedRecipientParams = { apiVersion?: ApiVersion; recipientSource?: typeof TssTxRecipientSource.Resolved; txParams?: TransactionParams; + verification?: VerificationOptions; }; /** diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts index 3a3f7a5099..e0514eb72e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts @@ -50,6 +50,7 @@ import { } from '../../../tss/types'; import { BaseEcdsaUtils } from './base'; import { EncryptionVersion, IRequestTracer } from '../../../../api'; +import { resolveEffectiveTxParams } from '../recipientUtils'; const encryptNShare = ECDSAMethods.encryptNShare; @@ -806,19 +807,23 @@ export class EcdsaUtils extends BaseEcdsaUtils { // Verification cannot be performed directly on the signableHex alone. However, we can parse the serializedTxHex // to regenerate the signableHex and compare it against the provided value for verification. // In contrast, for other coin families, verification is typically done using just the signableHex. + const verification = + 'verification' in params ? (params as TssSignTxRequestParamsWithPrv).verification : undefined; if (this.baseCoin.getConfig().family === 'icp') { await this.baseCoin.verifyTransaction({ txPrebuild: { txHex: unsignedTx.serializedTxHex, txInfo: unsignedTx.signableHex }, - txParams: params.txParams || { recipients: [] }, + txParams: resolveEffectiveTxParams(txRequest, params.txParams), wallet: this.wallet, walletType: this.wallet.multisigType(), + verification, }); } else { await this.baseCoin.verifyTransaction({ txPrebuild: { txHex: unsignedTx.signableHex }, - txParams: params.txParams || { recipients: [] }, + txParams: resolveEffectiveTxParams(txRequest, params.txParams), wallet: this.wallet, walletType: this.wallet.multisigType(), + verification, }); } signablePayload = Buffer.from(unsignedTx.signableHex, 'hex'); diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 337d853157..8486cc0fcb 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -55,6 +55,7 @@ import { EcdsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './ecdsaMPCv2K import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; import { InvalidTransactionError } from '../../../errors'; import { BitGoBase } from '../../../bitgoBase'; +import { resolveEffectiveTxParams } from '../recipientUtils'; export class EcdsaMPCv2Utils extends BaseEcdsaUtils { private static readonly DKLS23_SIGNING_USER_GPG_KEY = 'DKLS23_SIGNING_USER_GPG_KEY'; @@ -833,19 +834,23 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { // For all other coins, signableHex IS the unsigned transaction (e.g. RLP bytes). const isIcp = this.baseCoin.getConfig().family === 'icp'; const isAvalancheAtomic = unsignedTx.serializedTxHex && unsignedTx.serializedTxHex.startsWith('0000'); + const verification = + 'verification' in params ? (params as TssSignTxRequestParamsWithPrv).verification : undefined; if (isIcp || isAvalancheAtomic) { await this.baseCoin.verifyTransaction({ txPrebuild: { txHex: unsignedTx.serializedTxHex, txInfo: unsignedTx.signableHex }, - txParams: params.txParams || { recipients: [] }, + txParams: resolveEffectiveTxParams(txRequest, params.txParams), wallet: this.wallet, walletType: this.wallet.multisigType(), + verification, }); } else { await this.baseCoin.verifyTransaction({ txPrebuild: { txHex: unsignedTx.signableHex }, - txParams: params.txParams || { recipients: [] }, + txParams: resolveEffectiveTxParams(txRequest, params.txParams), wallet: this.wallet, walletType: this.wallet.multisigType(), + verification, }); } txOrMessageToSign = unsignedTx.signableHex; diff --git a/modules/sdk-core/src/bitgo/utils/tss/index.ts b/modules/sdk-core/src/bitgo/utils/tss/index.ts index 2276906b6c..3ae476abb1 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/index.ts @@ -15,3 +15,4 @@ export { ITssUtils, IEddsaUtils, TxRequest, EddsaUnsignedTransaction } from './e export * as BaseTssUtils from './baseTSSUtils'; export * from './baseTypes'; export * from './addressVerification'; +export * from './recipientUtils'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts new file mode 100644 index 0000000000..424d291947 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -0,0 +1,127 @@ +import { TransactionParams } from '../../baseCoin'; +import { InvalidTransactionError } from '../../errors'; +import { PopulatedIntent, TxRequest } from './baseTypes'; + +/** + * Type guard that narrows `TxRequest.intent` (typed as `unknown`) to `PopulatedIntent`. + * Checks for the required `intentType` string field that all populated intents carry. + */ +function isPopulatedIntent(intent: unknown): intent is PopulatedIntent { + return ( + typeof intent === 'object' && + intent !== null && + 'intentType' in intent && + typeof (intent as PopulatedIntent).intentType === 'string' + ); +} + +/** + * Transaction types that legitimately carry no explicit recipients. + * Mirrors the bypass list in abstractEthLikeNewCoins.ts verifyTssTransaction. + * + * ECDSA types: acceleration, fillNonce, transferToken, tokenApproval, consolidate, + * bridgeFunds, enableToken, customTx, contractCall + * BSC/BNB delegation-based staking: delegate, undelegate, switchValidator + * CELO/ETH lock-based staking: stake, unstake, stakeWithCallData, unstakeWithCallData, + * transferStake, increaseStake, goUnstake + * Claim rewards (BSC, CELO — TRX/SOL use EdDSA and are unaffected): claim, stakeClaimRewards + */ +export const NO_RECIPIENT_TX_TYPES = new Set([ + // ECDSA types + 'acceleration', + 'fillNonce', + 'transferToken', + 'tokenApproval', + 'consolidate', + 'bridgeFunds', + 'enableToken', + 'customTx', + // Smart contract + 'contractCall', + + // BSC/BNB delegation-based staking + 'delegate', + 'undelegate', + 'switchValidator', + + // CELO/ETH lock-based staking + 'stake', + 'unstake', + 'stakeWithCallData', + 'unstakeWithCallData', + 'transferStake', + 'increaseStake', + 'goUnstake', + + // Claim rewards + 'claim', + 'stakeClaimRewards', + + 'createAccount', + 'transferAccept', + 'transferReject', + 'transferOfferWithdrawn', + 'cantonCommand', + 'pledge', +]); + +/** + * Resolves the effective txParams for TSS signing recipient verification. + * + * For smart contract interactions, recipients live in txRequest.intent.recipients + * (native amount = 0, so buildParams is empty). Falls back to intent recipients + * mapped to ITransactionRecipient shape when txParams.recipients is absent. + * + * Staking intents (BSC delegate/undelegate, CELO stake/unstake, etc.) are + * identified generically by the presence of `stakingRequestId` on the intent — + * a required field on BaseStakeIntent in @bitgo/public-types. These intents + * have no txParams recipients by design; validation is done at the coin layer. + * + * Throws InvalidTransactionError if no recipients can be resolved and the + * transaction is not a known no-recipient type. + */ +export function resolveEffectiveTxParams( + txRequest: TxRequest, + txParams: TransactionParams | undefined +): TransactionParams { + const intent = isPopulatedIntent(txRequest.intent) ? txRequest.intent : undefined; + + const intentRecipients = intent?.recipients?.map((intentRecipient) => ({ + address: intentRecipient.address.address, + amount: intentRecipient.amount.value, + data: intentRecipient.data, + })); + + const effectiveTxParams: TransactionParams = { + ...txParams, + recipients: txParams?.recipients?.length ? txParams.recipients : intentRecipients, + }; + + // Fall back to intent.intentType when txParams.type is not explicitly set. + // Staking wallets call signTransaction without txParams, so the type lives only in the intent. + const txType = effectiveTxParams.type ?? intent?.intentType ?? ''; + + // Propagate the resolved type so downstream callers (e.g. verifyTssTransaction) can use it. + if (!effectiveTxParams.type && txType) { + effectiveTxParams.type = txType; + } + + // Propagate stakingRequestId from intent into effectiveTxParams so verifyTssTransaction + // overrides can bypass the no-recipient guard without needing access to txRequest directly. + const intentStakingRequestId = intent?.stakingRequestId; + if (intentStakingRequestId && !effectiveTxParams.stakingRequestId) { + effectiveTxParams.stakingRequestId = intentStakingRequestId; + } + + // All staking intents carry stakingRequestId as a required field on BaseStakeIntent + // Use its presence as a generic staking signal + const isStakingIntent = !!intent?.stakingRequestId; + + if (!effectiveTxParams.recipients?.length && !isStakingIntent && !NO_RECIPIENT_TX_TYPES.has(txType)) { + throw new InvalidTransactionError( + 'Recipient details are required to verify this transaction before signing. Pass txParams with at least one recipient.' + ); + } + + return effectiveTxParams; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 2a087e3df4..8fe65e387b 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -309,6 +309,7 @@ export interface PrebuildTransactionResult extends TransactionPrebuild { // Consolidate ID is used for consolidate account transactions and indicates if this is // a consolidation and what consolidate group it should be referenced by. consolidateId?: string; + stakingRequestId?: string; consolidationDetails?: { senderAddressIndex: number; }; @@ -362,6 +363,7 @@ export interface WalletSignTransactionOptions extends WalletSignBaseOptions { apiVersion?: ApiVersion; multisigTypeVersion?: 'MPCv2'; walletPassphrase?: string; + verification?: VerificationOptions; /** * Optional transaction verification parameters. When provided, the transaction will be verified * using verifyTransaction before signing. diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 46c4e9a0b7..d4c98af6cd 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4765,6 +4765,7 @@ export class Wallet implements IWallet { prv: params.prv, reqId: params.reqId || new RequestTracer(), apiVersion: params.apiVersion, + verification: params.verification, }); } catch (e) { throw new Error('failed to sign transaction ' + e); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 12e2f5b09c..2fb4e1056d 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -660,6 +660,7 @@ describe('ECDSA MPC v2', async () => { try { await hotWalletUtils.signTxRequest({ txRequest, + txParams: { recipients: [{ address: '0x' + '00'.repeat(20), amount: '1000' }] }, prv: userShare.toString('base64'), reqId: { inc: sinon.stub(), toString: sinon.stub().returns('test-req') } as any, }); @@ -728,6 +729,7 @@ describe('ECDSA MPC v2', async () => { try { await evmUtils.signTxRequest({ txRequest, + txParams: { recipients: [{ address: '0x' + '00'.repeat(20), amount: '1000' }] }, prv: userShare.toString('base64'), reqId: { inc: sinon.stub(), toString: sinon.stub().returns('test-req') } as any, }); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts new file mode 100644 index 0000000000..bcf5d8fc02 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -0,0 +1,182 @@ +import assert from 'assert'; +import { NO_RECIPIENT_TX_TYPES, resolveEffectiveTxParams } from '../../../../../src/bitgo/utils/tss/recipientUtils'; +import { InvalidTransactionError } from '../../../../../src/bitgo/errors'; +import { TxRequest } from '../../../../../src/bitgo/utils/tss/baseTypes'; + +function makeTxRequest(overrides: Partial = {}): TxRequest { + return { + txRequestId: 'test-tx-request-id', + walletId: 'test-wallet-id', + intent: undefined, + ...overrides, + } as unknown as TxRequest; +} + +describe('recipientUtils', function () { + describe('NO_RECIPIENT_TX_TYPES', function () { + it('contains all expected types', function () { + const expected = [ + // ECDSA EVM + 'acceleration', + 'fillNonce', + 'transferToken', + 'tokenApproval', + 'consolidate', + 'bridgeFunds', + 'enableToken', + 'customTx', + 'contractCall', + // Staking + 'delegate', + 'undelegate', + 'switchValidator', + 'stake', + 'unstake', + 'stakeWithCallData', + 'unstakeWithCallData', + 'transferStake', + 'increaseStake', + 'goUnstake', + 'claim', + 'stakeClaimRewards', + // Dry-run confirmed + 'createAccount', + 'transferAccept', + 'transferReject', + 'transferOfferWithdrawn', + 'cantonCommand', + 'pledge', + ]; + expected.forEach((t) => assert.ok(NO_RECIPIENT_TX_TYPES.has(t), `${t} should be in NO_RECIPIENT_TX_TYPES`)); + assert.strictEqual(NO_RECIPIENT_TX_TYPES.size, expected.length); + }); + + it('does not contain value-transfer types', function () { + ['payment', 'fanout', 'vote', 'defi-deposit', 'defi-redeem'].forEach((t) => { + assert.ok(!NO_RECIPIENT_TX_TYPES.has(t), `${t} must NOT be in NO_RECIPIENT_TX_TYPES`); + }); + }); + }); + + describe('resolveEffectiveTxParams', function () { + it('passes through txParams.recipients when present', function () { + const txRequest = makeTxRequest(); + const txParams = { recipients: [{ address: '0xabc', amount: '100' }] }; + const result = resolveEffectiveTxParams(txRequest, txParams); + assert.deepStrictEqual(result.recipients, txParams.recipients); + }); + + it('falls back to intent.recipients when txParams has none', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: '0xabc' }, + amount: { value: '500', symbol: 'eth' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.recipients?.length, 1); + assert.strictEqual(result.recipients?.[0].address, '0xabc'); + assert.strictEqual(result.recipients?.[0].amount, '500'); + }); + + it('resolves txType from intent.intentType when txParams.type is absent', function () { + const txRequest = makeTxRequest({ + intent: { intentType: 'consolidate' } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.type, 'consolidate'); + }); + + it('does not throw for exempt types', function () { + for (const txType of [ + 'acceleration', + 'consolidate', + 'contractCall', + 'delegate', + 'stake', + 'createAccount', + 'pledge', + ]) { + const txRequest = makeTxRequest(); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType })); + } + }); + + it('throws InvalidTransactionError for unknown types with no recipients', function () { + const txRequest = makeTxRequest(); + assert.throws( + () => resolveEffectiveTxParams(txRequest, { type: 'payment' }), + InvalidTransactionError, + 'should throw for payment type with no recipients' + ); + }); + + it('throws when txParams is undefined and no intent', function () { + const txRequest = makeTxRequest(); + assert.throws(() => resolveEffectiveTxParams(txRequest, undefined), InvalidTransactionError); + }); + + it('does not throw when intent has stakingRequestId (staking bypass)', function () { + const txRequest = makeTxRequest({ + intent: { intentType: 'delegate', stakingRequestId: 'staking-req-123' } as any, + }); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {})); + }); + + it('propagates stakingRequestId from intent into effectiveTxParams', function () { + const txRequest = makeTxRequest({ + intent: { intentType: 'delegate', stakingRequestId: 'staking-req-456' } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.stakingRequestId, 'staking-req-456'); + }); + + it('does not overwrite existing stakingRequestId in txParams', function () { + const txRequest = makeTxRequest({ + intent: { intentType: 'delegate', stakingRequestId: 'from-intent' } as any, + }); + const result = resolveEffectiveTxParams(txRequest, { stakingRequestId: 'from-caller' }); + assert.strictEqual(result.stakingRequestId, 'from-caller'); + }); + + it('prefers txParams.recipients over intent.recipients', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [{ address: { address: '0xintent' }, amount: { value: '999', symbol: 'eth' } }], + } as any, + }); + const txParams = { recipients: [{ address: '0xcaller', amount: '100' }] }; + const result = resolveEffectiveTxParams(txRequest, txParams); + assert.strictEqual(result.recipients?.[0].address, '0xcaller'); + }); + }); + + describe('skipTssRecipientVerification integration', function () { + it('resolveEffectiveTxParams still throws for unknown types regardless of flag (flag is checked at coin layer)', function () { + // The flag is checked in verifyTssTransaction, not in resolveEffectiveTxParams. + // resolveEffectiveTxParams enforces recipient presence — it does not know about the flag. + const txRequest = makeTxRequest(); + assert.throws(() => resolveEffectiveTxParams(txRequest, { type: 'payment' }), InvalidTransactionError); + }); + + it('resolveEffectiveTxParams does not throw for exempt types (flag not needed)', function () { + const txRequest = makeTxRequest(); + for (const txType of NO_RECIPIENT_TX_TYPES) { + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType }), `${txType} should not throw`); + } + }); + + it('resolveEffectiveTxParams does not throw when recipients are present regardless of type', function () { + const txRequest = makeTxRequest(); + const txParams = { type: 'payment', recipients: [{ address: '0xabc', amount: '100' }] }; + const result = resolveEffectiveTxParams(txRequest, txParams); + assert.strictEqual(result.recipients?.length, 1); + }); + }); +});