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
37 changes: 30 additions & 7 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
]);

/**
Expand All @@ -74,23 +79,41 @@ 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
* 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.
*
* @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,
Expand Down
218 changes: 202 additions & 16 deletions modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('recipientUtils', function () {
'defiDeposit',
'defiWithdraw',
'contractCall',
// Staking
// Staking — 'delegate' also covers SOL solDelegateIntent
'delegate',
'undelegate',
'switchValidator',
Expand All @@ -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);
Expand All @@ -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, {});
Expand All @@ -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');
});
Expand All @@ -110,26 +105,21 @@ describe('recipientUtils', function () {
'pledge',
'import',
'importtoc',
'deactivate',
]) {
const txRequest = makeTxRequest();
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType }));
}
});

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, {}));
}
});

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: [] }));
});
Expand Down Expand Up @@ -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);
});
});
});
});
Loading