Skip to content

Commit 6b7da7f

Browse files
committed
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 66a83ea commit 6b7da7f

2 files changed

Lines changed: 237 additions & 23 deletions

File tree

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

Lines changed: 35 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" (@bitgo/public-types intentType.ts).
3839
'delegate',
3940
'undelegate',
4041
'switchValidator',
@@ -65,6 +66,10 @@ 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 (solDeactivateIntent, intentType "deactivate" per
71+
// @bitgo/public-types intentType.ts:31) — no on-chain transfer recipient.
72+
'deactivate',
6873
]);
6974

7075
/**
@@ -74,23 +79,46 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
7479
* (native amount = 0, so buildParams is empty). Falls back to intent recipients
7580
* mapped to ITransactionRecipient shape when txParams.recipients is absent.
7681
*
82+
* tokenName is preserved from the intent recipient's tokenData.tokenName (preferred)
83+
* or from amount.symbol when chainName is provided and it differs from chainName —
84+
* matching the logic in txParamsFromIntent so SOL token sendMany (e.g. tsol:usdc)
85+
* works correctly. When chainName is absent (legacy ECDSA callers), the symbol-based
86+
* fallback is skipped and only tokenData.tokenName is used.
87+
*
7788
* Staking intents (BSC delegate/undelegate, CELO stake/unstake, etc.) are
7889
* identified generically by the presence of `stakingRequestId` on the intent —
7990
* a required field on BaseStakeIntent in @bitgo/public-types. These intents
8091
* have no txParams recipients by design; validation is done at the coin layer.
8192
*
8293
* Throws InvalidTransactionError if no recipients can be resolved and the
8394
* transaction is not a known no-recipient type.
95+
*
96+
* @param txRequest - the transaction request containing the persisted intent
97+
* @param txParams - the caller-supplied transaction parameters (may be undefined)
98+
* @param chainName - the base chain name (e.g. 'sol', 'tsol') used to exclude
99+
* native-coin transfers from tokenName; pass baseCoin.getChain()
84100
*/
85101
export function resolveEffectiveTxParams(
86102
txRequest: TxRequest,
87-
txParams: TransactionParams | undefined
103+
txParams: TransactionParams | undefined,
104+
chainName?: string
88105
): 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-
}));
106+
const intentRecipients = (txRequest.intent as PopulatedIntent)?.recipients?.map((intentRecipient) => {
107+
// Prefer structured tokenData.tokenName; fall back to amount.symbol when chainName is
108+
// provided and symbol differs from it — identical to txParamsFromIntent's logic.
109+
// When chainName is absent (ECDSA callers), skip the symbol fallback so native-coin
110+
// symbol is not mistakenly used as a tokenName.
111+
const { symbol } = intentRecipient.amount;
112+
const tokenName =
113+
intentRecipient.tokenData?.tokenName ||
114+
(chainName !== undefined && symbol && symbol !== chainName ? symbol : undefined);
115+
return {
116+
address: intentRecipient.address.address,
117+
amount: intentRecipient.amount.value,
118+
data: intentRecipient.data,
119+
...(tokenName && { tokenName }),
120+
};
121+
});
94122

95123
const effectiveTxParams: TransactionParams = {
96124
...txParams,

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

Lines changed: 202 additions & 16 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,26 +105,21 @@ describe('recipientUtils', function () {
110105
'pledge',
111106
'import',
112107
'importtoc',
108+
'deactivate',
113109
]) {
114110
const txRequest = makeTxRequest();
115111
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType }));
116112
}
117113
});
118114

119115
it('does not throw for Avalanche cross-chain imports resolved from intent.intentType', function () {
120-
// P-chain and C-chain import intents legitimately carry no recipients —
121-
// the wallet imports its own UTXOs and the destination address is the
122-
// wallet itself. The intentType lives only on the intent (txParams.type
123-
// is unset on the MPC signing call) so the guard must read it from there.
124116
for (const intentType of ['import', 'importtoc']) {
125117
const txRequest = makeTxRequest({ intent: { intentType } as any });
126118
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {}));
127119
}
128120
});
129121

130122
it('does not throw when buildParams.type is PascalCase but intent.intentType is lowercase', function () {
131-
// signTransactionTss passes txPrebuild.buildParams as txParams. Prebuild uses
132-
// type: 'Import' while WP stores intentType: 'import' on the txRequest.
133123
const txRequest = makeTxRequest({ intent: { intentType: 'import', recipients: [] } as any });
134124
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: 'Import', recipients: [] }));
135125
});
@@ -182,5 +172,201 @@ describe('recipientUtils', function () {
182172
const result = resolveEffectiveTxParams(txRequest, txParams);
183173
assert.strictEqual(result.recipients?.[0].address, '0xcaller');
184174
});
175+
176+
it('preserves data field from intent recipients', function () {
177+
const txRequest = makeTxRequest({
178+
intent: {
179+
intentType: 'payment',
180+
recipients: [{ address: { address: '0xabc' }, amount: { value: '100', symbol: 'eth' }, data: '0xdeadbeef' }],
181+
} as any,
182+
});
183+
const result = resolveEffectiveTxParams(txRequest, {});
184+
assert.strictEqual(result.recipients?.[0].data, '0xdeadbeef');
185+
});
186+
187+
// -------------------------------------------------------------------------
188+
// Regression: WCN-196 / WCI-1110
189+
// resolveEffectiveTxParams was briefly wired into EdDSA signing (PR #9071 /
190+
// a70114f21a) then reverted (96658f105f) because it dropped tokenName when
191+
// mapping intent recipients. SOL token sendMany (e.g. tsol:usdc) requires
192+
// tokenName to derive the ATA address in verifyTransaction — without it the
193+
// comparison always fails with "Tx outputs does not match".
194+
// -------------------------------------------------------------------------
195+
196+
describe('tokenName preservation (WCN-196 regression)', function () {
197+
it('preserves tokenName from amount.symbol when it differs from chainName', function () {
198+
const txRequest = makeTxRequest({
199+
intent: {
200+
intentType: 'payment',
201+
recipients: [
202+
{
203+
address: { address: 'UserWalletAddress111111111111111111111111111' },
204+
amount: { value: '1000000', symbol: 'tsol:usdc' },
205+
},
206+
],
207+
} as any,
208+
});
209+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
210+
assert.strictEqual(result.recipients?.length, 1);
211+
assert.strictEqual(result.recipients?.[0].tokenName, 'tsol:usdc');
212+
});
213+
214+
it('does NOT set tokenName when symbol equals chainName (native SOL transfer)', function () {
215+
const txRequest = makeTxRequest({
216+
intent: {
217+
intentType: 'payment',
218+
recipients: [
219+
{
220+
address: { address: 'RecipientAddress111111111111111111111111111' },
221+
amount: { value: '5000000000', symbol: 'tsol' },
222+
},
223+
],
224+
} as any,
225+
});
226+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
227+
assert.strictEqual(result.recipients?.[0].tokenName, undefined);
228+
});
229+
230+
it('prefers tokenData.tokenName over amount.symbol (uses distinct values to verify)', function () {
231+
const txRequest = makeTxRequest({
232+
intent: {
233+
intentType: 'payment',
234+
recipients: [
235+
{
236+
address: { address: 'RecipientAddress111111111111111111111111111' },
237+
amount: { value: '500000', symbol: 'tsol:usdc-alt' },
238+
tokenData: { tokenType: 'fungible', tokenQuantity: '500000', tokenName: 'canonical-token-name' },
239+
},
240+
],
241+
} as any,
242+
});
243+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
244+
assert.strictEqual(result.recipients?.[0].tokenName, 'canonical-token-name');
245+
});
246+
247+
it('falls back to amount.symbol when tokenData.tokenName is absent', function () {
248+
const txRequest = makeTxRequest({
249+
intent: {
250+
intentType: 'payment',
251+
recipients: [
252+
{
253+
address: { address: 'RecipientAddress111111111111111111111111111' },
254+
amount: { value: '200000', symbol: 'sol:usdc' },
255+
tokenData: { tokenType: 'fungible', tokenQuantity: '200000' },
256+
},
257+
],
258+
} as any,
259+
});
260+
const result = resolveEffectiveTxParams(txRequest, {}, 'sol');
261+
assert.strictEqual(result.recipients?.[0].tokenName, 'sol:usdc');
262+
});
263+
264+
it('falls back to amount.symbol when tokenData.tokenName is empty string', function () {
265+
const txRequest = makeTxRequest({
266+
intent: {
267+
intentType: 'payment',
268+
recipients: [
269+
{
270+
address: { address: 'RecipientAddress111111111111111111111111111' },
271+
amount: { value: '100000', symbol: 'tsol:usdc' },
272+
tokenData: { tokenType: 'fungible', tokenQuantity: '100000', tokenName: '' },
273+
},
274+
],
275+
} as any,
276+
});
277+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
278+
assert.strictEqual(result.recipients?.[0].tokenName, 'tsol:usdc');
279+
});
280+
281+
it('does NOT set tokenName when chainName is absent (legacy ECDSA callers, no tokenData)', function () {
282+
const txRequest = makeTxRequest({
283+
intent: {
284+
intentType: 'payment',
285+
recipients: [{ address: { address: '0xabc' }, amount: { value: '100', symbol: 'eth' } }],
286+
} as any,
287+
});
288+
const result = resolveEffectiveTxParams(txRequest, {});
289+
assert.strictEqual(result.recipients?.[0].tokenName, undefined);
290+
assert.strictEqual(result.recipients?.[0].address, '0xabc');
291+
});
292+
293+
it('preserves tokenData.tokenName when chainName is absent (legacy ECDSA with tokenData)', function () {
294+
const txRequest = makeTxRequest({
295+
intent: {
296+
intentType: 'transferToken',
297+
recipients: [
298+
{
299+
address: { address: '0xabc' },
300+
amount: { value: '1000', symbol: 'erc20:usdc' },
301+
tokenData: { tokenType: 'fungible', tokenQuantity: '1000', tokenName: 'eth:usdc' },
302+
},
303+
],
304+
} as any,
305+
});
306+
const result = resolveEffectiveTxParams(txRequest, {});
307+
assert.strictEqual(result.recipients?.[0].tokenName, 'eth:usdc');
308+
});
309+
310+
it('sendMany tsol:usdc: does not throw and preserves tokenName in full round-trip', function () {
311+
const txRequest = makeTxRequest({
312+
intent: {
313+
intentType: 'payment',
314+
recipients: [
315+
{
316+
address: { address: 'SolUserWallet1111111111111111111111111111111' },
317+
amount: { value: '2000000', symbol: 'tsol:usdc' },
318+
},
319+
{
320+
address: { address: 'SolUserWallet2222222222222222222222222222222' },
321+
amount: { value: '3000000', symbol: 'tsol:usdc' },
322+
},
323+
],
324+
} as any,
325+
});
326+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
327+
assert.strictEqual(result.recipients?.length, 2);
328+
result.recipients!.forEach((r) => assert.strictEqual(r.tokenName, 'tsol:usdc'));
329+
assert.strictEqual(result.recipients![0].amount, '2000000');
330+
assert.strictEqual(result.recipients![1].amount, '3000000');
331+
});
332+
333+
it('handles mixed native + token recipients correctly', function () {
334+
const txRequest = makeTxRequest({
335+
intent: {
336+
intentType: 'payment',
337+
recipients: [
338+
{
339+
address: { address: 'SolNative111111111111111111111111111111111' },
340+
amount: { value: '1000000000', symbol: 'tsol' },
341+
},
342+
{
343+
address: { address: 'SolToken111111111111111111111111111111111' },
344+
amount: { value: '500000', symbol: 'tsol:usdc' },
345+
},
346+
],
347+
} as any,
348+
});
349+
const result = resolveEffectiveTxParams(txRequest, {}, 'tsol');
350+
assert.strictEqual(result.recipients![0].tokenName, undefined);
351+
assert.strictEqual(result.recipients![1].tokenName, 'tsol:usdc');
352+
});
353+
});
354+
355+
describe('SOL no-recipient intent types', function () {
356+
it('does not throw for "delegate" (solDelegateIntent)', function () {
357+
const txRequest = makeTxRequest({ intent: { intentType: 'delegate' } as any });
358+
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {}));
359+
});
360+
361+
it('does not throw for "deactivate" (solDeactivateIntent)', function () {
362+
const txRequest = makeTxRequest({ intent: { intentType: 'deactivate' } as any });
363+
assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {}));
364+
});
365+
366+
it('throws for stakingAuthorize — must be validated at coin layer', function () {
367+
const txRequest = makeTxRequest({ intent: { intentType: 'stakingAuthorize' } as any });
368+
assert.throws(() => resolveEffectiveTxParams(txRequest, {}), InvalidTransactionError);
369+
});
370+
});
185371
});
186372
});

0 commit comments

Comments
 (0)