diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 77e7e12124..25a3ef6580 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'; @@ -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'); 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 80cf6d3236..9f12a69ab4 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 { diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 19682197f4..5ce16c2467 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -389,6 +389,7 @@ export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase } export interface PopulatedIntent extends PopulatedIntentBase { + 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 d38dbc5898..d69bff395f 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; @@ -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(), }); 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 a10e2b0aac..b2482aace1 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'; @@ -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(), }); 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..2eaee07503 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -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; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 568eea39c2..144d52d2ca 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -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; }; 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 b9269d3d22..6a8fa2cd52 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..eda9e5c2ee --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -0,0 +1,150 @@ +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', + // 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', 'contractCall', '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', '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'); + }); + }); +});