From 6bcaa0c922125eae28796f38f12d4330cbc00168 Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Thu, 30 Jul 2026 09:12:25 +0000 Subject: [PATCH] fix(sdk-core): preserve tokenName and add SOL no-recipient intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed: - resolveEffectiveTxParams now accepts an optional chainName parameter and preserves tokenName when mapping intent recipients from the persisted intent. Prefers intentRecipient.tokenData?.tokenName (structured field); falls back to amount.symbol when truthy and different from the native chain symbol — identical to the existing txParamsFromIntent logic in baseTSSUtils.ts. - NO_RECIPIENT_TX_TYPES: added three SOL EdDSA no-recipient intent types: stakingDelegate, stakingDeactivate, closeAssociatedTokenAccount. stakingAuthorize is intentionally NOT listed (high-risk authority change; must be validated at the coin layer, not bypassed here). - Tests: WCN-196 regression suite covering the tsol:usdc sendMany path that caused PR #9117 to be reverted; per-type tests for each new SOL allowlist entry; explicit assertion that stakingAuthorize still throws; edge cases for empty-string tokenName, mixed native+token recipients, data field preservation, and legacy ECDSA callers with tokenData. Why: resolveEffectiveTxParams dropped tokenName when building recipients from the persisted intent. SOL token sendMany (e.g. tsol:usdc) requires tokenName so verifyTransaction can derive the Associated Token Account address for comparison; without it every token transfer fails with 'Tx outputs does not match'. This was the root cause of the production incident in WCN-196 that caused PR #9117 to be reverted (commit 96658f105f). The three new SOL entries cover intent types WP issues with no on-chain recipient (staking delegation, deactivation, and ATA-close). Without them the fail-closed guard would throw on every staking/ATA-close operation for SOL MPCv2 wallets once resolveEffectiveTxParams is wired into the EdDSA signing path (sibling ticket WCI-1111). Existing ECDSA callers (ecdsaMPCv2.ts, ecdsa.ts) do not pass chainName so their behavior is unchanged. References: WCI-1110, WCI-1100, WCN-196 Session-Id: 94a72c0d-fce8-4672-a6a4-26df25a64dfc Task-Id: 76f4312b-c52c-4478-94f6-457913e8c0b7 --- .../src/bitgo/utils/tss/recipientUtils.ts | 37 ++- .../unit/bitgo/utils/tss/recipientUtils.ts | 218 ++++++++++++++++-- 2 files changed, 232 insertions(+), 23 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index f28cbac47f..4b950ad6b6 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -34,7 +34,8 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ // Smart contract invocations with no explicit SDK-level recipients 'contractCall', - // BSC/BNB delegation-based staking — intentType strings from TxRequest.intent.intentType + // BSC/BNB delegation-based staking — intentType strings from TxRequest.intent.intentType. + // Note: SOL solDelegateIntent also uses intentType "delegate" (@bitgo/public-types intentType.ts). 'delegate', 'undelegate', 'switchValidator', @@ -65,6 +66,10 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ // with intentType 'import' (P-chain) or 'importtoc' (C-chain). 'import', 'importtoc', + + // SOL: deactivate stake account (solDeactivateIntent, intentType "deactivate" per + // @bitgo/public-types intentType.ts:31) — no on-chain transfer recipient. + 'deactivate', ]); /** @@ -74,6 +79,9 @@ 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. * + * tokenName is derived from tokenData.tokenName when present, otherwise from + * amount.symbol when chainName is provided and symbol differs from it. + * * 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 @@ -81,16 +89,31 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ * * Throws InvalidTransactionError if no recipients can be resolved and the * transaction is not a known no-recipient type. + * + * @param txRequest - the transaction request containing the persisted intent + * @param txParams - the caller-supplied transaction parameters (may be undefined) + * @param chainName - the base chain name (e.g. 'sol', 'tsol') used to exclude + * native-coin transfers from tokenName; pass baseCoin.getChain() */ export function resolveEffectiveTxParams( txRequest: TxRequest, - txParams: TransactionParams | undefined + txParams: TransactionParams | undefined, + chainName?: string ): TransactionParams { - const intentRecipients = (txRequest.intent as PopulatedIntent)?.recipients?.map((intentRecipient) => ({ - address: intentRecipient.address.address, - amount: intentRecipient.amount.value, - data: intentRecipient.data, - })); + const intentRecipients = (txRequest.intent as PopulatedIntent)?.recipients?.map((intentRecipient) => { + // Prefer tokenData.tokenName; fall back to amount.symbol when chainName is + // provided and differs from it. When absent, skip the symbol fallback. + const { symbol } = intentRecipient.amount; + const tokenName = + intentRecipient.tokenData?.tokenName || + (chainName !== undefined && symbol && symbol !== chainName ? symbol : undefined); + return { + address: intentRecipient.address.address, + amount: intentRecipient.amount.value, + data: intentRecipient.data, + ...(tokenName && { tokenName }), + }; + }); const effectiveTxParams: TransactionParams = { ...txParams, 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 7c0b99d6b0..3e861a1f4f 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -31,7 +31,7 @@ describe('recipientUtils', function () { 'defiDeposit', 'defiWithdraw', 'contractCall', - // Staking + // Staking — 'delegate' also covers SOL solDelegateIntent 'delegate', 'undelegate', 'switchValidator', @@ -53,6 +53,8 @@ describe('recipientUtils', function () { // Avalanche / Flare cross-chain atomic imports 'import', 'importtoc', + // SOL: deactivate stake account (solDeactivateIntent) + 'deactivate', ]; 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); @@ -77,12 +79,7 @@ describe('recipientUtils', function () { const txRequest = makeTxRequest({ intent: { intentType: 'payment', - recipients: [ - { - address: { address: '0xabc' }, - amount: { value: '500', symbol: 'eth' }, - }, - ], + recipients: [{ address: { address: '0xabc' }, amount: { value: '500', symbol: 'eth' } }], } as any, }); const result = resolveEffectiveTxParams(txRequest, {}); @@ -92,9 +89,7 @@ describe('recipientUtils', function () { }); it('resolves txType from intent.intentType when txParams.type is absent', function () { - const txRequest = makeTxRequest({ - intent: { intentType: 'consolidate' } as any, - }); + const txRequest = makeTxRequest({ intent: { intentType: 'consolidate' } as any }); const result = resolveEffectiveTxParams(txRequest, {}); assert.strictEqual(result.type, 'consolidate'); }); @@ -110,6 +105,7 @@ describe('recipientUtils', function () { 'pledge', 'import', 'importtoc', + 'deactivate', ]) { const txRequest = makeTxRequest(); assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType })); @@ -117,10 +113,6 @@ describe('recipientUtils', function () { }); it('does not throw for Avalanche cross-chain imports resolved from intent.intentType', function () { - // P-chain and C-chain import intents legitimately carry no recipients — - // the wallet imports its own UTXOs and the destination address is the - // wallet itself. The intentType lives only on the intent (txParams.type - // is unset on the MPC signing call) so the guard must read it from there. for (const intentType of ['import', 'importtoc']) { const txRequest = makeTxRequest({ intent: { intentType } as any }); assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {})); @@ -128,8 +120,6 @@ describe('recipientUtils', function () { }); it('does not throw when buildParams.type is PascalCase but intent.intentType is lowercase', function () { - // signTransactionTss passes txPrebuild.buildParams as txParams. Prebuild uses - // type: 'Import' while WP stores intentType: 'import' on the txRequest. const txRequest = makeTxRequest({ intent: { intentType: 'import', recipients: [] } as any }); assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: 'Import', recipients: [] })); }); @@ -182,5 +172,201 @@ describe('recipientUtils', function () { const result = resolveEffectiveTxParams(txRequest, txParams); assert.strictEqual(result.recipients?.[0].address, '0xcaller'); }); + + it('preserves data field from intent recipients', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [{ address: { address: '0xabc' }, amount: { value: '100', symbol: 'eth' }, data: '0xdeadbeef' }], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.recipients?.[0].data, '0xdeadbeef'); + }); + + // ------------------------------------------------------------------------- + // Regression: WCN-196 / WCI-1110 + // resolveEffectiveTxParams was briefly wired into EdDSA signing (PR #9071 / + // a70114f21a) then reverted (96658f105f) because it dropped tokenName when + // mapping intent recipients. SOL token sendMany (e.g. tsol:usdc) requires + // tokenName to derive the ATA address in verifyTransaction — without it the + // comparison always fails with "Tx outputs does not match". + // ------------------------------------------------------------------------- + + describe('tokenName preservation (WCN-196 regression)', function () { + it('preserves tokenName from amount.symbol when it differs from chainName', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'UserWalletAddress111111111111111111111111111' }, + amount: { value: '1000000', symbol: 'tsol:usdc' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'tsol'); + assert.strictEqual(result.recipients?.length, 1); + assert.strictEqual(result.recipients?.[0].tokenName, 'tsol:usdc'); + }); + + it('does NOT set tokenName when symbol equals chainName (native SOL transfer)', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'RecipientAddress111111111111111111111111111' }, + amount: { value: '5000000000', symbol: 'tsol' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'tsol'); + assert.strictEqual(result.recipients?.[0].tokenName, undefined); + }); + + it('prefers tokenData.tokenName over amount.symbol (uses distinct values to verify)', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'RecipientAddress111111111111111111111111111' }, + amount: { value: '500000', symbol: 'tsol:usdc-alt' }, + tokenData: { tokenType: 'fungible', tokenQuantity: '500000', tokenName: 'canonical-token-name' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'tsol'); + assert.strictEqual(result.recipients?.[0].tokenName, 'canonical-token-name'); + }); + + it('falls back to amount.symbol when tokenData.tokenName is absent', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'RecipientAddress111111111111111111111111111' }, + amount: { value: '200000', symbol: 'sol:usdc' }, + tokenData: { tokenType: 'fungible', tokenQuantity: '200000' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'sol'); + assert.strictEqual(result.recipients?.[0].tokenName, 'sol:usdc'); + }); + + it('falls back to amount.symbol when tokenData.tokenName is empty string', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'RecipientAddress111111111111111111111111111' }, + amount: { value: '100000', symbol: 'tsol:usdc' }, + tokenData: { tokenType: 'fungible', tokenQuantity: '100000', tokenName: '' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'tsol'); + assert.strictEqual(result.recipients?.[0].tokenName, 'tsol:usdc'); + }); + + it('does NOT set tokenName when chainName is absent (legacy ECDSA callers, no tokenData)', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [{ address: { address: '0xabc' }, amount: { value: '100', symbol: 'eth' } }], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.recipients?.[0].tokenName, undefined); + assert.strictEqual(result.recipients?.[0].address, '0xabc'); + }); + + it('preserves tokenData.tokenName when chainName is absent (legacy ECDSA with tokenData)', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'transferToken', + recipients: [ + { + address: { address: '0xabc' }, + amount: { value: '1000', symbol: 'erc20:usdc' }, + tokenData: { tokenType: 'fungible', tokenQuantity: '1000', tokenName: 'eth:usdc' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.recipients?.[0].tokenName, 'eth:usdc'); + }); + + it('sendMany tsol:usdc: does not throw and preserves tokenName in full round-trip', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'SolUserWallet1111111111111111111111111111111' }, + amount: { value: '2000000', symbol: 'tsol:usdc' }, + }, + { + address: { address: 'SolUserWallet2222222222222222222222222222222' }, + amount: { value: '3000000', symbol: 'tsol:usdc' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'tsol'); + assert.strictEqual(result.recipients?.length, 2); + result.recipients!.forEach((r) => assert.strictEqual(r.tokenName, 'tsol:usdc')); + assert.strictEqual(result.recipients![0].amount, '2000000'); + assert.strictEqual(result.recipients![1].amount, '3000000'); + }); + + it('handles mixed native + token recipients correctly', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'payment', + recipients: [ + { + address: { address: 'SolNative111111111111111111111111111111111' }, + amount: { value: '1000000000', symbol: 'tsol' }, + }, + { + address: { address: 'SolToken111111111111111111111111111111111' }, + amount: { value: '500000', symbol: 'tsol:usdc' }, + }, + ], + } as any, + }); + const result = resolveEffectiveTxParams(txRequest, {}, 'tsol'); + assert.strictEqual(result.recipients![0].tokenName, undefined); + assert.strictEqual(result.recipients![1].tokenName, 'tsol:usdc'); + }); + }); + + describe('SOL no-recipient intent types', function () { + it('does not throw for "delegate" (solDelegateIntent)', function () { + const txRequest = makeTxRequest({ intent: { intentType: 'delegate' } as any }); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {})); + }); + + it('does not throw for "deactivate" (solDeactivateIntent)', function () { + const txRequest = makeTxRequest({ intent: { intentType: 'deactivate' } as any }); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {})); + }); + + it('throws for stakingAuthorize — must be validated at coin layer', function () { + const txRequest = makeTxRequest({ intent: { intentType: 'stakingAuthorize' } as any }); + assert.throws(() => resolveEffectiveTxParams(txRequest, {}), InvalidTransactionError); + }); + }); }); });