diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 80c6b623b7..57d285cbf7 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'; @@ -3108,17 +3109,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { !( txParams.prebuildTx?.consolidateId || txPrebuild?.consolidateId || - (txParams.type && - [ - 'acceleration', - 'fillNonce', - 'transferToken', - 'tokenApproval', - 'consolidate', - 'bridgeFunds', - 'enableToken', - 'customTx', - ].includes(txParams.type)) + (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type)) ) ) { throw new Error('missing txParams'); diff --git a/modules/sdk-coin-bsc/src/bsc.ts b/modules/sdk-coin-bsc/src/bsc.ts index af5e529a26..55ea9cb3ad 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, @@ -68,10 +76,7 @@ export class Bsc extends AbstractEthLikeNewCoins { const { txParams, txPrebuild, wallet } = params; if ( !txParams?.recipients && - !( - txParams.prebuildTx?.consolidateId || - (txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type)) - ) + !(txParams.prebuildTx?.consolidateId || (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type))) ) { throw new Error(`missing txParams`); } diff --git a/modules/sdk-coin-bsc/src/bscToken.ts b/modules/sdk-coin-bsc/src/bscToken.ts index ea399bc65d..a8bf944b65 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'; @@ -43,6 +43,7 @@ export class BscToken extends EthLikeToken { getFullName(): string { return 'Bsc Token'; } + /** * Verify if a tss transaction is valid * @@ -56,10 +57,7 @@ export class BscToken extends EthLikeToken { const { txParams, txPrebuild, wallet } = params; if ( !txParams?.recipients && - !( - txParams.prebuildTx?.consolidateId || - (txParams.type && ['acceleration', 'fillNonce', 'transferToken'].includes(txParams.type)) - ) + !(txParams.prebuildTx?.consolidateId || (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type))) ) { throw new Error(`missing txParams`); } diff --git a/modules/sdk-coin-evm/src/evmCoin.ts b/modules/sdk-coin-evm/src/evmCoin.ts index 651b80116a..f76f04e29f 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 { @@ -115,11 +116,7 @@ export class EvmCoin extends AbstractEthLikeNewCoins { // Basic validation for legacy transactions only if ( !txParams?.recipients && - !( - txParams.prebuildTx?.consolidateId || - (txParams.type && - ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval', 'bridgeFunds'].includes(txParams.type)) - ) + !(txParams.prebuildTx?.consolidateId || (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type))) ) { throw new Error(`missing txParams`); } diff --git a/modules/sdk-coin-xdc/src/xdc.ts b/modules/sdk-coin-xdc/src/xdc.ts index 9724ea9c73..1415311950 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, @@ -47,6 +55,7 @@ export class Xdc extends AbstractEthLikeNewCoins { const explorerUrl = common.Environments[this.bitgo.getEnv()].xdcExplorerBaseUrl; return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken); } + /** * Verify if a tss transaction is valid * @@ -60,10 +69,7 @@ export class Xdc extends AbstractEthLikeNewCoins { const { txParams, txPrebuild, wallet } = params; if ( !txParams?.recipients && - !( - txParams.prebuildTx?.consolidateId || - (txParams.type && ['acceleration', 'fillNonce', 'transferToken'].includes(txParams.type)) - ) + !(txParams.prebuildTx?.consolidateId || (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type))) ) { throw new Error(`missing txParams`); } diff --git a/modules/sdk-coin-xdc/src/xdcToken.ts b/modules/sdk-coin-xdc/src/xdcToken.ts index 7a4b88dece..1c14e5d0a5 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, @@ -71,10 +78,7 @@ export class XdcToken extends EthLikeToken { const { txParams, txPrebuild, wallet } = params; if ( !txParams?.recipients && - !( - txParams.prebuildTx?.consolidateId || - (txParams.type && ['acceleration', 'fillNonce', 'transferToken'].includes(txParams.type)) - ) + !(txParams.prebuildTx?.consolidateId || (txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type))) ) { throw new Error(`missing txParams`); } 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 index 682228c4b0..499bea95bb 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -4,7 +4,8 @@ import { PopulatedIntent, TxRequest } from './baseTypes'; /** * Transaction types that legitimately carry no explicit recipients. - * verifyTransaction handles no-recipient validation for these internally. + * These are non-staking ECDSA types where verifyTransaction handles + * no-recipient validation internally. * Mirrors the bypass list in abstractEthLikeNewCoins.ts verifyTssTransaction. */ export const NO_RECIPIENT_TX_TYPES = new Set([ @@ -15,7 +16,7 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'consolidate', 'bridgeFunds', 'enableToken', - 'customTx', // DeFi/WalletConnect smart contract interactions have no traditional recipients + 'customTx', ]); /** @@ -25,8 +26,13 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ * (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 type is not a known no-recipient type. + * transaction is not a known no-recipient type. */ export function resolveEffectiveTxParams( txRequest: TxRequest, @@ -43,7 +49,21 @@ export function resolveEffectiveTxParams( recipients: txParams?.recipients?.length ? txParams.recipients : intentRecipients, }; - if (!effectiveTxParams.recipients?.length && !NO_RECIPIENT_TX_TYPES.has(effectiveTxParams.type ?? '')) { + // 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 ?? (txRequest.intent as PopulatedIntent)?.intentType ?? ''; + + // Propagate the resolved type so downstream callers (e.g. verifyTssTransaction) can use it. + if (!effectiveTxParams.type && txType) { + effectiveTxParams.type = txType; + } + + // All staking intents (BSC delegate/undelegate, CELO stake/unstake, etc.) carry + // stakingRequestId as a required field on BaseStakeIntent (@bitgo/public-types). + // Use its presence as a generic staking signal — no need to enumerate every intentType. + const isStakingIntent = !!(txRequest.intent as any)?.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.' ); 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 9ff4ad37c8..f5a155a547 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -5,11 +5,15 @@ import * as assert from 'assert'; const getModule = () => require('../../../../../src/bitgo/utils/tss/recipientUtils'); function makeTxRequest( - intentRecipients?: { address: { address: string }; amount: { value: string }; data?: string }[] + intentRecipients?: { address: { address: string }; amount: { value: string }; data?: string }[], + intentType = 'payment', + stakingRequestId?: string ): any { return { txRequestId: 'test-req-id', - intent: intentRecipients ? { intentType: 'contractCall', recipients: intentRecipients } : { intentType: 'payment' }, + intent: intentRecipients + ? { intentType: 'contractCall', recipients: intentRecipients } + : { intentType, ...(stakingRequestId ? { stakingRequestId } : {}) }, transactions: [], unsignedTxs: [], state: 'pendingUserSignature', @@ -23,7 +27,7 @@ function makeTxRequest( describe('recipientUtils', function () { describe('NO_RECIPIENT_TX_TYPES', function () { - it('contains exactly the 8 expected exempted types', function () { + it('contains all expected exempted types', function () { const { NO_RECIPIENT_TX_TYPES } = getModule(); const expected = [ 'acceleration', @@ -105,8 +109,8 @@ describe('recipientUtils', function () { 'tokenApproval', 'consolidate', 'bridgeFunds', - 'enableToken', // TSS wallets do not populate recipients for token enablement - 'customTx', // DeFi/WalletConnect smart contract interactions have no traditional recipients + 'enableToken', + 'customTx', ]; NO_RECIPIENT_TYPES.forEach((type) => { @@ -117,5 +121,44 @@ describe('recipientUtils', function () { result.type.should.equal(type); }); }); + + it('allows staking intent with no recipients when stakingRequestId is present (BSC delegate)', function () { + const { resolveEffectiveTxParams } = getModule(); + // Simulate BSC staking wallet: no txParams, intent has stakingRequestId + const txRequest = makeTxRequest(undefined, 'delegate', 'staking-req-123'); + const result = resolveEffectiveTxParams(txRequest, undefined); + result.type.should.equal('delegate'); + }); + + it('allows staking intent with no recipients when stakingRequestId is present (CELO stake)', function () { + const { resolveEffectiveTxParams } = getModule(); + const txRequest = makeTxRequest(undefined, 'stake', 'staking-req-456'); + const result = resolveEffectiveTxParams(txRequest, undefined); + result.type.should.equal('stake'); + }); + + it('throws for unknown staking-like type without stakingRequestId', function () { + const { resolveEffectiveTxParams } = getModule(); + // No stakingRequestId, no recipients, unknown type — should throw + const txRequest = makeTxRequest(undefined, 'delegate'); + assert.throws( + () => resolveEffectiveTxParams(txRequest, undefined), + /Recipient details are required to verify this transaction before signing/ + ); + }); + + it('propagates intent.intentType into effectiveTxParams.type when txParams.type is absent', function () { + const { resolveEffectiveTxParams } = getModule(); + const txRequest = makeTxRequest(undefined, 'stake', 'staking-req-789'); + const result = resolveEffectiveTxParams(txRequest, {}); + result.type.should.equal('stake'); + }); + + it('does not override txParams.type when already set', function () { + const { resolveEffectiveTxParams } = getModule(); + const txRequest = makeTxRequest(undefined, 'delegate', 'staking-req-000'); + const result = resolveEffectiveTxParams(txRequest, { type: 'acceleration' }); + result.type.should.equal('acceleration'); + }); }); });