From b42da9e9d12dba9a66820edb7f49715d0a77c462 Mon Sep 17 00:00:00 2001 From: mrdanish26 Date: Tue, 2 Jun 2026 19:42:49 -0700 Subject: [PATCH] fix(sdk-core): enforce recipient verification in ECDSA TSS signing Ticket: WCN-151 --- .../src/abstractEthLikeNewCoins.ts | 14 +- modules/sdk-coin-bsc/src/bsc.ts | 14 +- modules/sdk-coin-bsc/src/bscToken.ts | 6 +- modules/sdk-coin-evm/src/evmCoin.ts | 6 +- modules/sdk-coin-xdc/src/xdc.ts | 15 +- modules/sdk-coin-xdc/src/xdcToken.ts | 14 +- .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 1 + .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 1 + .../src/bitgo/utils/tss/ecdsa/ecdsa.ts | 5 +- .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 5 +- modules/sdk-core/src/bitgo/utils/tss/index.ts | 1 + .../src/bitgo/utils/tss/recipientUtils.ts | 122 +++++++++++++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 1 + .../unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 2 + .../unit/bitgo/utils/tss/recipientUtils.ts | 161 ++++++++++++++++++ 15 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 89e3bd9482..fd8bba0a7a 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -46,6 +46,7 @@ import { isTssVerifyAddressOptions, DeriveAddressOptions, DeriveAddressResult, + NO_RECIPIENT_TX_TYPES, } from '@bitgo/sdk-core'; import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { bip32 } from '@bitgo/secp256k1'; @@ -3149,16 +3150,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'); diff --git a/modules/sdk-coin-bsc/src/bsc.ts b/modules/sdk-coin-bsc/src/bsc.ts index af5e529a26..9b3e255500 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, @@ -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`); diff --git a/modules/sdk-coin-bsc/src/bscToken.ts b/modules/sdk-coin-bsc/src/bscToken.ts index ea399bc65d..b2923cd137 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'; @@ -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`); diff --git a/modules/sdk-coin-evm/src/evmCoin.ts b/modules/sdk-coin-evm/src/evmCoin.ts index 651b80116a..e92406163a 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 { @@ -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`); diff --git a/modules/sdk-coin-xdc/src/xdc.ts b/modules/sdk-coin-xdc/src/xdc.ts index 285004fc25..1c3d9445e0 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, @@ -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`); diff --git a/modules/sdk-coin-xdc/src/xdcToken.ts b/modules/sdk-coin-xdc/src/xdcToken.ts index 3224250cce..29b0931971 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, @@ -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`); diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index dfc51997b7..655e34a270 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -267,6 +267,7 @@ export interface TransactionParams { type?: string; memo?: Memo; enableTokens?: TokenEnablement[]; + stakingRequestId?: string; } export interface AddressVerificationData { diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 306dd20549..d95f29bab6 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -410,6 +410,7 @@ export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase } export interface PopulatedIntent extends PopulatedIntentBase, DefiIntentFields { + stakingRequestId?: string; recipients?: IntentRecipient[]; nonce?: string; token?: string; 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 b386a23871..4a08647480 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; @@ -814,14 +815,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(), }); 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 5f3ca85d3a..329a0c4ac7 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -57,6 +57,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'; @@ -843,14 +844,14 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { if (isIcp || isPreHashed) { 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(), }); diff --git a/modules/sdk-core/src/bitgo/utils/tss/index.ts b/modules/sdk-core/src/bitgo/utils/tss/index.ts index 58a6db8be9..4604ecd293 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/index.ts @@ -16,3 +16,4 @@ export * as BaseTssUtils from './baseTSSUtils'; export * from './baseTypes'; export * from './addressVerification'; export * from './preHashedSignable'; +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..6cd6d83f96 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -0,0 +1,122 @@ +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, 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 + * Dry-run confirmed (2026-05-20 investigation): createAccount, transferAccept, transferReject, + * transferOfferWithdrawn, cantonCommand, pledge + * Dry-run confirmed (2026-06-03 investigation): contractCall (hash/Provenance — secp256k1, + * smart contract invocation with no explicit recipients) + */ +export const NO_RECIPIENT_TX_TYPES = new Set([ + // ECDSA types + 'acceleration', + 'fillNonce', + 'transferToken', + 'tokenApproval', + 'consolidate', + 'bridgeFunds', + 'enableToken', + 'enabletoken', + 'disabletoken', + 'customTx', + // Smart contract invocations with no explicit SDK-level recipients (e.g. hash/Provenance) + 'contractCall', + + // 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; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 2a087e3df4..2ede4fc716 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; }; 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 579cea332d..e0be05481b 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 @@ -663,6 +663,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, }); @@ -731,6 +732,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..94b6abd8e8 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -0,0 +1,161 @@ +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', + 'enabletoken', + 'disabletoken', + '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'); + }); + }); +});