Skip to content

Commit 3db2886

Browse files
Marzooqabitgobot
authored andcommitted
fix(sdk-core): preserve tokenName and add SOL no-recipient intents
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 96658f1). 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
1 parent c13937e commit 3db2886

2 files changed

Lines changed: 222 additions & 17 deletions

File tree

modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
3434
// Smart contract invocations with no explicit SDK-level recipients
3535
'contractCall',
3636

37-
// BSC/BNB delegation-based staking — intentType strings from TxRequest.intent.intentType
37+
// BSC/BNB delegation-based staking — intentType strings from TxRequest.intent.intentType.
38+
// Note: SOL solDelegateIntent also uses intentType "delegate".
3839
'delegate',
3940
'undelegate',
4041
'switchValidator',
@@ -65,6 +66,9 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
6566
// with intentType 'import' (P-chain) or 'importtoc' (C-chain).
6667
'import',
6768
'importtoc',
69+
70+
// SOL: deactivate stake account — no on-chain transfer recipient.
71+
'deactivate',
6872
]);
6973

7074
/**
@@ -74,23 +78,41 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
7478
* (native amount = 0, so buildParams is empty). Falls back to intent recipients
7579
* mapped to ITransactionRecipient shape when txParams.recipients is absent.
7680
*
81+
* tokenName is derived from tokenData.tokenName when present, otherwise from
82+
* amount.symbol when chainName is provided and symbol differs from it.
83+
*
7784
* Staking intents (BSC delegate/undelegate, CELO stake/unstake, etc.) are
7885
* identified generically by the presence of `stakingRequestId` on the intent —
7986
* a required field on BaseStakeIntent in @bitgo/public-types. These intents
8087
* have no txParams recipients by design; validation is done at the coin layer.
8188
*
8289
* Throws InvalidTransactionError if no recipients can be resolved and the
8390
* transaction is not a known no-recipient type.
91+
*
92+
* @param txRequest - the transaction request containing the persisted intent
93+
* @param txParams - the caller-supplied transaction parameters (may be undefined)
94+
* @param chainName - the base chain name (e.g. 'sol', 'tsol') used to exclude
95+
* native-coin transfers from tokenName; pass baseCoin.getChain()
8496
*/
8597
export function resolveEffectiveTxParams(
8698
txRequest: TxRequest,
87-
txParams: TransactionParams | undefined
99+
txParams: TransactionParams | undefined,
100+
chainName?: string
88101
): TransactionParams {
89-
const intentRecipients = (txRequest.intent as PopulatedIntent)?.recipients?.map((intentRecipient) => ({
90-
address: intentRecipient.address.address,
91-
amount: intentRecipient.amount.value,
92-
data: intentRecipient.data,
93-
}));
102+
const intentRecipients = (txRequest.intent as PopulatedIntent)?.recipients?.map((intentRecipient) => {
103+
// Prefer tokenData.tokenName; fall back to amount.symbol when chainName is
104+
// provided and differs from it. When absent, skip the symbol fallback.
105+
const { symbol } = intentRecipient.amount;
106+
const tokenName =
107+
intentRecipient.tokenData?.tokenName ||
108+
(chainName !== undefined && symbol && symbol !== chainName ? symbol : undefined);
109+
return {
110+
address: intentRecipient.address.address,
111+
amount: intentRecipient.amount.value,
112+
data: intentRecipient.data,
113+
...(tokenName && { tokenName }),
114+
};
115+
});
94116

95117
const effectiveTxParams: TransactionParams = {
96118
...txParams,

modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts

Lines changed: 193 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('recipientUtils', function () {
3131
'defiDeposit',
3232
'defiWithdraw',
3333
'contractCall',
34-
// Staking
34+
// Staking — 'delegate' also covers SOL solDelegateIntent
3535
'delegate',
3636
'undelegate',
3737
'switchValidator',
@@ -53,6 +53,8 @@ describe('recipientUtils', function () {
5353
// Avalanche / Flare cross-chain atomic imports
5454
'import',
5555
'importtoc',
56+
// SOL: deactivate stake account (solDeactivateIntent)
57+
'deactivate',
5658
];
5759
expected.forEach((t) => assert.ok(NO_RECIPIENT_TX_TYPES.has(t), `${t} should be in NO_RECIPIENT_TX_TYPES`));
5860
assert.strictEqual(NO_RECIPIENT_TX_TYPES.size, expected.length);
@@ -77,12 +79,7 @@ describe('recipientUtils', function () {
7779
const txRequest = makeTxRequest({
7880
intent: {
7981
intentType: 'payment',
80-
recipients: [
81-
{
82-
address: { address: '0xabc' },
83-
amount: { value: '500', symbol: 'eth' },
84-
},
85-
],
82+
recipients: [{ address: { address: '0xabc' }, amount: { value: '500', symbol: 'eth' } }],
8683
} as any,
8784
});
8885
const result = resolveEffectiveTxParams(txRequest, {});
@@ -92,9 +89,7 @@ describe('recipientUtils', function () {
9289
});
9390

9491
it('resolves txType from intent.intentType when txParams.type is absent', function () {
95-
const txRequest = makeTxRequest({
96-
intent: { intentType: 'consolidate' } as any,
97-
});
92+
const txRequest = makeTxRequest({ intent: { intentType: 'consolidate' } as any });
9893
const result = resolveEffectiveTxParams(txRequest, {});
9994
assert.strictEqual(result.type, 'consolidate');
10095
});
@@ -110,6 +105,7 @@ describe('recipientUtils', function () {
110105
'pledge',
111106
'import',
112107
'importtoc',
108+
'deactivate',
113109
]) {
114110
const txRequest = makeTxRequest();
115111
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType }));
@@ -182,5 +178,192 @@ describe('recipientUtils', function () {
182178
const result = resolveEffectiveTxParams(txRequest, txParams);
183179
assert.strictEqual(result.recipients?.[0].address, '0xcaller');
184180
});
181+
182+
it('preserves data field from intent recipients', function () {
183+
const txRequest = makeTxRequest({
184+
intent: {
185+
intentType: 'payment',
186+
recipients: [{ address: { address: '0xabc' }, amount: { value: '100', symbol: 'eth' }, data: '0xdeadbeef' }],
187+
} as any,
188+
});
189+
const result = resolveEffectiveTxParams(txRequest, {});
190+
assert.strictEqual(result.recipients?.[0].data, '0xdeadbeef');
191+
});
192+
193+
describe('tokenName preservation regression tests', function () {
194+
it('preserves tokenName from amount.symbol when it differs from chainName', function () {
195+
const txRequest = makeTxRequest({
196+
intent: {
197+
intentType: 'payment',
198+
recipients: [
199+
{
200+
address: { address: 'UserWalletAddress111111111111111111111111111' },
201+
amount: { value: '1000000', symbol: 'tsol:usdc' },
202+
},
203+
],
204+
} as any,
205+
});
206+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
207+
assert.strictEqual(result.recipients?.length, 1);
208+
assert.strictEqual(result.recipients?.[0].tokenName, 'tsol:usdc');
209+
});
210+
211+
it('does NOT set tokenName when symbol equals chainName (native SOL transfer)', function () {
212+
const txRequest = makeTxRequest({
213+
intent: {
214+
intentType: 'payment',
215+
recipients: [
216+
{
217+
address: { address: 'RecipientAddress111111111111111111111111111' },
218+
amount: { value: '5000000000', symbol: 'tsol' },
219+
},
220+
],
221+
} as any,
222+
});
223+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
224+
assert.strictEqual(result.recipients?.[0].tokenName, undefined);
225+
});
226+
227+
it('prefers tokenData.tokenName over amount.symbol (uses distinct values to verify)', function () {
228+
const txRequest = makeTxRequest({
229+
intent: {
230+
intentType: 'payment',
231+
recipients: [
232+
{
233+
address: { address: 'RecipientAddress111111111111111111111111111' },
234+
amount: { value: '500000', symbol: 'tsol:usdc-alt' },
235+
tokenData: { tokenType: 'fungible', tokenQuantity: '500000', tokenName: 'canonical-token-name' },
236+
},
237+
],
238+
} as any,
239+
});
240+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
241+
assert.strictEqual(result.recipients?.[0].tokenName, 'canonical-token-name');
242+
});
243+
244+
it('falls back to amount.symbol when tokenData.tokenName is absent', function () {
245+
const txRequest = makeTxRequest({
246+
intent: {
247+
intentType: 'payment',
248+
recipients: [
249+
{
250+
address: { address: 'RecipientAddress111111111111111111111111111' },
251+
amount: { value: '200000', symbol: 'sol:usdc' },
252+
tokenData: { tokenType: 'fungible', tokenQuantity: '200000' },
253+
},
254+
],
255+
} as any,
256+
});
257+
const result = resolveEffectiveTxParams(txRequest, {}, 'sol');
258+
assert.strictEqual(result.recipients?.[0].tokenName, 'sol:usdc');
259+
});
260+
261+
it('falls back to amount.symbol when tokenData.tokenName is empty string', function () {
262+
const txRequest = makeTxRequest({
263+
intent: {
264+
intentType: 'payment',
265+
recipients: [
266+
{
267+
address: { address: 'RecipientAddress111111111111111111111111111' },
268+
amount: { value: '100000', symbol: 'tsol:usdc' },
269+
tokenData: { tokenType: 'fungible', tokenQuantity: '100000', tokenName: '' },
270+
},
271+
],
272+
} as any,
273+
});
274+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
275+
assert.strictEqual(result.recipients?.[0].tokenName, 'tsol:usdc');
276+
});
277+
278+
it('does NOT set tokenName when chainName is absent (legacy ECDSA callers, no tokenData)', function () {
279+
const txRequest = makeTxRequest({
280+
intent: {
281+
intentType: 'payment',
282+
recipients: [{ address: { address: '0xabc' }, amount: { value: '100', symbol: 'eth' } }],
283+
} as any,
284+
});
285+
const result = resolveEffectiveTxParams(txRequest, {});
286+
assert.strictEqual(result.recipients?.[0].tokenName, undefined);
287+
assert.strictEqual(result.recipients?.[0].address, '0xabc');
288+
});
289+
290+
it('preserves tokenData.tokenName when chainName is absent (legacy ECDSA with tokenData)', function () {
291+
const txRequest = makeTxRequest({
292+
intent: {
293+
intentType: 'transferToken',
294+
recipients: [
295+
{
296+
address: { address: '0xabc' },
297+
amount: { value: '1000', symbol: 'erc20:usdc' },
298+
tokenData: { tokenType: 'fungible', tokenQuantity: '1000', tokenName: 'eth:usdc' },
299+
},
300+
],
301+
} as any,
302+
});
303+
const result = resolveEffectiveTxParams(txRequest, {});
304+
assert.strictEqual(result.recipients?.[0].tokenName, 'eth:usdc');
305+
});
306+
307+
it('sendMany tsol:usdc: does not throw and preserves tokenName in full round-trip', function () {
308+
const txRequest = makeTxRequest({
309+
intent: {
310+
intentType: 'payment',
311+
recipients: [
312+
{
313+
address: { address: 'SolUserWallet1111111111111111111111111111111' },
314+
amount: { value: '2000000', symbol: 'tsol:usdc' },
315+
},
316+
{
317+
address: { address: 'SolUserWallet2222222222222222222222222222222' },
318+
amount: { value: '3000000', symbol: 'tsol:usdc' },
319+
},
320+
],
321+
} as any,
322+
});
323+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
324+
assert.strictEqual(result.recipients?.length, 2);
325+
result.recipients!.forEach((r) => assert.strictEqual(r.tokenName, 'tsol:usdc'));
326+
assert.strictEqual(result.recipients![0].amount, '2000000');
327+
assert.strictEqual(result.recipients![1].amount, '3000000');
328+
});
329+
330+
it('handles mixed native + token recipients correctly', function () {
331+
const txRequest = makeTxRequest({
332+
intent: {
333+
intentType: 'payment',
334+
recipients: [
335+
{
336+
address: { address: 'SolNative111111111111111111111111111111111' },
337+
amount: { value: '1000000000', symbol: 'tsol' },
338+
},
339+
{
340+
address: { address: 'SolToken111111111111111111111111111111111' },
341+
amount: { value: '500000', symbol: 'tsol:usdc' },
342+
},
343+
],
344+
} as any,
345+
});
346+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
347+
assert.strictEqual(result.recipients![0].tokenName, undefined);
348+
assert.strictEqual(result.recipients![1].tokenName, 'tsol:usdc');
349+
});
350+
});
351+
352+
describe('SOL no-recipient intent types', function () {
353+
it('does not throw for "delegate" (solDelegateIntent)', function () {
354+
const txRequest = makeTxRequest({ intent: { intentType: 'delegate' } as any });
355+
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {}));
356+
});
357+
358+
it('does not throw for "deactivate" (solDeactivateIntent)', function () {
359+
const txRequest = makeTxRequest({ intent: { intentType: 'deactivate' } as any });
360+
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {}));
361+
});
362+
363+
it('throws for stakingAuthorize — must be validated at coin layer', function () {
364+
const txRequest = makeTxRequest({ intent: { intentType: 'stakingAuthorize' } as any });
365+
assert.throws(() => resolveEffectiveTxParams(txRequest, {}), InvalidTransactionError);
366+
});
367+
});
185368
});
186369
});

0 commit comments

Comments
 (0)