Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -3109,16 +3110,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
!(
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');
Expand Down
14 changes: 12 additions & 2 deletions modules/sdk-coin-bsc/src/bsc.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -70,7 +78,9 @@ export class Bsc extends AbstractEthLikeNewCoins {
!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`);
Expand Down
6 changes: 4 additions & 2 deletions modules/sdk-coin-bsc/src/bscToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -58,7 +58,9 @@ export class BscToken extends EthLikeToken {
!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`);
Expand Down
6 changes: 4 additions & 2 deletions modules/sdk-coin-evm/src/evmCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -117,8 +118,9 @@ export class EvmCoin extends AbstractEthLikeNewCoins {
!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`);
Expand Down
15 changes: 12 additions & 3 deletions modules/sdk-coin-xdc/src/xdc.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -62,8 +70,9 @@ export class Xdc extends AbstractEthLikeNewCoins {
!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`);
Expand Down
14 changes: 11 additions & 3 deletions modules/sdk-coin-xdc/src/xdcToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,8 +80,9 @@ export class XdcToken extends EthLikeToken {
!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`);
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export interface TransactionParams {
type?: string;
memo?: Memo;
enableTokens?: TokenEnablement[];
stakingRequestId?: string;
}

export interface AddressVerificationData {
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase
}

export interface PopulatedIntent extends PopulatedIntentBase {
stakingRequestId?: string;
recipients?: IntentRecipient[];
nonce?: string;
token?: string;
Expand Down
5 changes: 3 additions & 2 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -797,14 +798,14 @@ export class EcdsaUtils extends BaseEcdsaUtils {
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(),
});
} 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(),
});
Expand Down
5 changes: 3 additions & 2 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -826,14 +827,14 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
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(),
});
} 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(),
});
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/utils/tss/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
116 changes: 116 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { TransactionParams } from '../../baseCoin';
import { InvalidTransactionError } from '../../errors';
import { PopulatedIntent, TxRequest } from './baseTypes';

/**
* Transaction types that legitimately carry no explicit recipients.
* These are the intentType strings as stored in TxRequest.intent.intentType by WP.
* verifyTransaction handles no-recipient validation for these internally.
* Mirrors the bypass list in abstractEthLikeNewCoins.ts verifyTssTransaction.
*
* ECDSA types: acceleration, fillNonce, transferToken, tokenApproval, consolidate,
* bridgeFunds, enableToken, customTx
* 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
* Dry-run confirmed (2026-05-20 investigation): createAccount, transferAccept, transferReject,
* transferOfferWithdrawn, cantonCommand, pledge
*/
export const NO_RECIPIENT_TX_TYPES = new Set([
// ECDSA types
'acceleration',
'fillNonce',
'transferToken',
'tokenApproval',
'consolidate',
'bridgeFunds',
'enableToken',
'customTx',

// BSC/BNB delegation-based staking — intentType strings from TxRequest.intent.intentType
'delegate',
'undelegate',
'switchValidator',

// CELO/ETH lock-based staking
'stake',
'unstake',
'stakeWithCallData',
'unstakeWithCallData',
'transferStake',
'increaseStake',
'goUnstake',

// Claim rewards — BSC and CELO (TRX/SOL/Cosmos use EdDSA, not affected by this guard)
'claim',
'stakeClaimRewards',

// Dry-run confirmed no-recipient types (13-day observation, 2026-05-20)
'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 intentRecipients = (txRequest.intent as PopulatedIntent)?.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 ?? (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;
}

// Propagate stakingRequestId from intent into effectiveTxParams so verifyTssTransaction
// overrides can bypass the no-recipient guard without needing access to txRequest directly.
const intentStakingRequestId = (txRequest.intent as PopulatedIntent)?.stakingRequestId;
if (intentStakingRequestId && !effectiveTxParams.stakingRequestId) {
effectiveTxParams.stakingRequestId = intentStakingRequestId;
}

// 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 PopulatedIntent)?.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;
}
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand Down
Loading
Loading