Skip to content

Commit 467ff30

Browse files
committed
fix(sdk-core): bypass TSS recipient guard generically for staking intents
BSC/BNB hot wallet staking breaks after WAL-375 SDK bump because the staking wallet calls signTransaction without txParams, and staking intentType strings were absent from the recipient bypass set. Instead of enumerating every staking intentType string (which drifts as new coins add staking support), detect staking intents generically via the presence of stakingRequestId on the intent — a required field on BaseStakeIntent in @bitgo/public-types that all staking intents inherit. Fix: - NO_RECIPIENT_TX_TYPES retains only the 8 non-staking ECDSA types - resolveEffectiveTxParams checks stakingRequestId as a generic staking signal; throws only if no recipients, not staking, and not an ECDSA no-recipient type - Keep intent.intentType fallback so txType propagates to downstream verifyTssTransaction callers - Revert verifyTssTransaction bypass list in abstractEthLikeNewCoins.ts back to 8 original ECDSA types - Remove stale verifyTssTransaction override from bsc.ts (parent handles) - Tests: updated makeTxRequest to accept stakingRequestId; realistic tests using delegate/stake + stakingRequestId; negative test confirms delegate without stakingRequestId still throws Refs: WAL-756 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> TICKET: WAL-756
1 parent 6c87b40 commit 467ff30

2 files changed

Lines changed: 72 additions & 9 deletions

File tree

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { PopulatedIntent, TxRequest } from './baseTypes';
44

55
/**
66
* Transaction types that legitimately carry no explicit recipients.
7-
* verifyTransaction handles no-recipient validation for these internally.
7+
* These are non-staking ECDSA types where verifyTransaction handles
8+
* no-recipient validation internally.
89
* Mirrors the bypass list in abstractEthLikeNewCoins.ts verifyTssTransaction.
910
*/
1011
export const NO_RECIPIENT_TX_TYPES = new Set([
@@ -15,7 +16,7 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
1516
'consolidate',
1617
'bridgeFunds',
1718
'enableToken',
18-
'customTx', // DeFi/WalletConnect smart contract interactions have no traditional recipients
19+
'customTx',
1920
]);
2021

2122
/**
@@ -25,8 +26,13 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
2526
* (native amount = 0, so buildParams is empty). Falls back to intent recipients
2627
* mapped to ITransactionRecipient shape when txParams.recipients is absent.
2728
*
29+
* Staking intents (BSC delegate/undelegate, CELO stake/unstake, etc.) are
30+
* identified generically by the presence of `stakingRequestId` on the intent —
31+
* a required field on BaseStakeIntent in @bitgo/public-types. These intents
32+
* have no txParams recipients by design; validation is done at the coin layer.
33+
*
2834
* Throws InvalidTransactionError if no recipients can be resolved and the
29-
* transaction type is not a known no-recipient type.
35+
* transaction is not a known no-recipient type.
3036
*/
3137
export function resolveEffectiveTxParams(
3238
txRequest: TxRequest,
@@ -43,7 +49,21 @@ export function resolveEffectiveTxParams(
4349
recipients: txParams?.recipients?.length ? txParams.recipients : intentRecipients,
4450
};
4551

46-
if (!effectiveTxParams.recipients?.length && !NO_RECIPIENT_TX_TYPES.has(effectiveTxParams.type ?? '')) {
52+
// Fall back to intent.intentType when txParams.type is not explicitly set.
53+
// Staking wallets call signTransaction without txParams, so the type lives only in the intent.
54+
const txType = effectiveTxParams.type ?? (txRequest.intent as PopulatedIntent)?.intentType ?? '';
55+
56+
// Propagate the resolved type so downstream callers (e.g. verifyTssTransaction) can use it.
57+
if (!effectiveTxParams.type && txType) {
58+
effectiveTxParams.type = txType;
59+
}
60+
61+
// All staking intents (BSC delegate/undelegate, CELO stake/unstake, etc.) carry
62+
// stakingRequestId as a required field on BaseStakeIntent (@bitgo/public-types).
63+
// Use its presence as a generic staking signal — no need to enumerate every intentType.
64+
const isStakingIntent = !!(txRequest.intent as any)?.stakingRequestId;
65+
66+
if (!effectiveTxParams.recipients?.length && !isStakingIntent && !NO_RECIPIENT_TX_TYPES.has(txType)) {
4767
throw new InvalidTransactionError(
4868
'Recipient details are required to verify this transaction before signing. Pass txParams with at least one recipient.'
4969
);

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

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import * as assert from 'assert';
55
const getModule = () => require('../../../../../src/bitgo/utils/tss/recipientUtils');
66

77
function makeTxRequest(
8-
intentRecipients?: { address: { address: string }; amount: { value: string }; data?: string }[]
8+
intentRecipients?: { address: { address: string }; amount: { value: string }; data?: string }[],
9+
intentType = 'payment',
10+
stakingRequestId?: string
911
): any {
1012
return {
1113
txRequestId: 'test-req-id',
12-
intent: intentRecipients ? { intentType: 'contractCall', recipients: intentRecipients } : { intentType: 'payment' },
14+
intent: intentRecipients
15+
? { intentType: 'contractCall', recipients: intentRecipients }
16+
: { intentType, ...(stakingRequestId ? { stakingRequestId } : {}) },
1317
transactions: [],
1418
unsignedTxs: [],
1519
state: 'pendingUserSignature',
@@ -23,7 +27,7 @@ function makeTxRequest(
2327

2428
describe('recipientUtils', function () {
2529
describe('NO_RECIPIENT_TX_TYPES', function () {
26-
it('contains exactly the 8 expected exempted types', function () {
30+
it('contains all expected exempted types', function () {
2731
const { NO_RECIPIENT_TX_TYPES } = getModule();
2832
const expected = [
2933
'acceleration',
@@ -105,8 +109,8 @@ describe('recipientUtils', function () {
105109
'tokenApproval',
106110
'consolidate',
107111
'bridgeFunds',
108-
'enableToken', // TSS wallets do not populate recipients for token enablement
109-
'customTx', // DeFi/WalletConnect smart contract interactions have no traditional recipients
112+
'enableToken',
113+
'customTx',
110114
];
111115

112116
NO_RECIPIENT_TYPES.forEach((type) => {
@@ -117,5 +121,44 @@ describe('recipientUtils', function () {
117121
result.type.should.equal(type);
118122
});
119123
});
124+
125+
it('allows staking intent with no recipients when stakingRequestId is present (BSC delegate)', function () {
126+
const { resolveEffectiveTxParams } = getModule();
127+
// Simulate BSC staking wallet: no txParams, intent has stakingRequestId
128+
const txRequest = makeTxRequest(undefined, 'delegate', 'staking-req-123');
129+
const result = resolveEffectiveTxParams(txRequest, undefined);
130+
result.type.should.equal('delegate');
131+
});
132+
133+
it('allows staking intent with no recipients when stakingRequestId is present (CELO stake)', function () {
134+
const { resolveEffectiveTxParams } = getModule();
135+
const txRequest = makeTxRequest(undefined, 'stake', 'staking-req-456');
136+
const result = resolveEffectiveTxParams(txRequest, undefined);
137+
result.type.should.equal('stake');
138+
});
139+
140+
it('throws for unknown staking-like type without stakingRequestId', function () {
141+
const { resolveEffectiveTxParams } = getModule();
142+
// No stakingRequestId, no recipients, unknown type — should throw
143+
const txRequest = makeTxRequest(undefined, 'delegate');
144+
assert.throws(
145+
() => resolveEffectiveTxParams(txRequest, undefined),
146+
/Recipient details are required to verify this transaction before signing/
147+
);
148+
});
149+
150+
it('propagates intent.intentType into effectiveTxParams.type when txParams.type is absent', function () {
151+
const { resolveEffectiveTxParams } = getModule();
152+
const txRequest = makeTxRequest(undefined, 'stake', 'staking-req-789');
153+
const result = resolveEffectiveTxParams(txRequest, {});
154+
result.type.should.equal('stake');
155+
});
156+
157+
it('does not override txParams.type when already set', function () {
158+
const { resolveEffectiveTxParams } = getModule();
159+
const txRequest = makeTxRequest(undefined, 'delegate', 'staking-req-000');
160+
const result = resolveEffectiveTxParams(txRequest, { type: 'acceleration' });
161+
result.type.should.equal('acceleration');
162+
});
120163
});
121164
});

0 commit comments

Comments
 (0)