Skip to content

Commit 4959aa3

Browse files
committed
fix(suite-desktop): better support for stepped clear signing support
1 parent 5216163 commit 4959aa3

7 files changed

Lines changed: 260 additions & 38 deletions

File tree

packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalBodyInner.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ export const TransactionReviewModalBodyInner = ({
116116
const tradingToken = useSelector(selectTradingComposedTransactionInfo).composed?.token;
117117

118118
const isApprovalTx = isEvmApprovalTx(precomposedForm.transactionData);
119+
// A contract call's single "address" row is the contract, not a payment recipient, so the
120+
// step-back heuristic below must not treat its ConfirmOutput as a re-confirmed output. A vault
121+
// deposit otherwise matches every condition and walks the review backwards mid-signing.
122+
const isContractCall = !!precomposedForm.transactionData;
119123

120124
const totalRecipients = outputs.filter(({ type }) => type === 'address').length;
121125
const hasOpReturn = outputs.some(output => output.type === 'opreturn');
@@ -151,7 +155,8 @@ export const TransactionReviewModalBodyInner = ({
151155
totalRecipients === 1 && // Currently we only support going bak for =1
152156
lastButtonRequestCode === 'ButtonRequest_ConfirmOutput' &&
153157
!hasOpReturn &&
154-
!isApprovalTx
158+
!isApprovalTx &&
159+
!isContractCall
155160
) {
156161
setReviewStep(prev => prev - 1);
157162
} else {
@@ -166,6 +171,7 @@ export const TransactionReviewModalBodyInner = ({
166171
totalRecipients,
167172
hasOpReturn,
168173
isApprovalTx,
174+
isContractCall,
169175
]);
170176

171177
const isInternalTransfer = useSelector(state =>

packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
import { selectLanguage } from '@suite/settings';
1010
import { isApprovalFlowSupported, selectSelectedDevice } from '@suite-common/device';
1111
import { type Locale, type TrezorDevice } from '@suite-common/suite-types';
12-
import { type NetworkType, getNetworkDisplaySymbol } from '@suite-common/wallet-config';
12+
import {
13+
type NetworkType,
14+
getNetworkDisplaySymbol,
15+
getWrappedNativeSymbol,
16+
} from '@suite-common/wallet-config';
1317
import { BTC_LOCKTIME_VALUE } from '@suite-common/wallet-constants';
1418
import { selectAccounts } from '@suite-common/wallet-core';
1519
import {
@@ -119,6 +123,19 @@ const isYieldAction = (
119123
): evmTxType is keyof typeof yieldStrings =>
120124
!!evmTxType && Object.keys(yieldStrings).includes(evmTxType);
121125

126+
type WrappedNativeAction = Extract<EvmTransactionPurpose, 'wrap' | 'unwrap'>;
127+
128+
const isWrappedNativeAction = (
129+
evmTxType: EvmTransactionPurpose | undefined,
130+
): evmTxType is WrappedNativeAction => evmTxType === 'wrap' || evmTxType === 'unwrap';
131+
132+
// Mirrors the firmware's clear-signing "Intent" screen, which reads "Wrap ETH to WETH" /
133+
// "Unwrap WETH to ETH" for a canonical WETH deposit()/withdraw().
134+
const wrappedNativeIntentStrings: Record<WrappedNativeAction, TranslationKey> = {
135+
wrap: 'TR_EARN_YIELD_WRAP_TITLE',
136+
unwrap: 'TR_EARN_YIELD_UNWRAP_TITLE',
137+
};
138+
122139
const getTranslationValues = (
123140
networkType: NetworkType,
124141
stakeType?: StakeType,
@@ -223,7 +240,7 @@ const getOutputTitle = (
223240
return <Translation id={translation ? translation.label : 'TR_RECIPIENT_ADDRESS'} />;
224241

225242
case 'amount':
226-
if (isYieldAction(evmTxType)) {
243+
if (isYieldAction(evmTxType) || isWrappedNativeAction(evmTxType)) {
227244
return <Translation id="AMOUNT" />;
228245
}
229246

@@ -255,6 +272,7 @@ const getOutputTitle = (
255272
case 'recipient_name':
256273
return <Translation id="TR_TRADING_PROVIDER" />;
257274
case 'swap_intent':
275+
case 'contract_intent':
258276
return <Translation id="TR_TRADING_INTENT" />;
259277
case 'traded_assets':
260278
return <Translation id={receiveAddress ? 'TR_CONTRACT' : 'TR_MY_ASSETS'} />;
@@ -467,6 +485,19 @@ const getOutputLines = ({
467485
value: translationString('TR_TRADING_INTENT_SWAP', {}),
468486
},
469487
];
488+
case 'contract_intent':
489+
return [
490+
{
491+
id: 'contract_intent',
492+
type: 'data',
493+
value: isWrappedNativeAction(evmTxType)
494+
? translationString(wrappedNativeIntentStrings[evmTxType], {
495+
nativeSymbol: getNetworkDisplaySymbol(symbol),
496+
tokenSymbol: getWrappedNativeSymbol(symbol),
497+
})
498+
: '',
499+
},
500+
];
470501
case 'amount': {
471502
if (isYieldAction(evmTxType)) {
472503
return [
@@ -489,7 +520,11 @@ const getOutputLines = ({
489520
const output: OutputElementLine[] = [
490521
{
491522
id: type,
492-
label: <Translation id="AMOUNT" />,
523+
// The card heading already reads "Amount" for a wrap/unwrap, so labelling
524+
// the line too would print it twice.
525+
label: isWrappedNativeAction(evmTxType) ? undefined : (
526+
<Translation id="AMOUNT" />
527+
),
493528
value,
494529
type: 'amount',
495530
token: token || nativeToken,

packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
} from '@suite-common/wallet-types';
1515
import {
1616
findAccountsByAddress,
17-
getEvmTransactionTextSignature,
17+
getEvmTransactionPurpose,
1818
isEvmApprovalTx,
1919
isEvmYieldTxByTextSignature,
2020
} from '@suite-common/wallet-utils';
@@ -102,7 +102,15 @@ export const TransactionReviewOutputList = ({
102102

103103
const isApprovalTx = isEvmApprovalTx(precomposedForm.transactionData);
104104

105-
const evmTxType = getEvmTransactionTextSignature(precomposedForm.transactionData);
105+
// Resolved from the full context, not the calldata alone, so a WETH deposit()/withdraw() is
106+
// classified as wrap/unwrap — the review rows for those mirror the device's clear-signing
107+
// screens and need to know which of the two it is.
108+
const evmTxType = getEvmTransactionPurpose({
109+
networkSymbol: symbol,
110+
to: precomposedTx.outputs.find(o => 'address' in o && typeof o.address === 'string')
111+
?.address,
112+
data: precomposedForm.transactionData,
113+
});
106114

107115
const isYieldOperation = isEvmYieldTxByTextSignature(evmTxType) || evmTxType === 'claim';
108116

packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewTotalOutput.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getIsUpdatedEthereumSendFlow,
1414
getIsUpdatedSendFlow,
1515
isClearSignedEvmTradingSwapTransaction,
16+
isClearSignedWrappedNativeTransaction,
1617
isEvmApprovalTx,
1718
isEvmYieldTxByTextSignature,
1819
isTestnet,
@@ -38,6 +39,7 @@ interface GetLinesParams {
3839
stakeType?: StakeType;
3940
nativeToken?: TokenInfo;
4041
isClearSignedTradingSwap: boolean;
42+
isClearSignedWrapUnwrap: boolean;
4143
isTronStakeFreeze: boolean;
4244
tronResourceLabel: string;
4345
}
@@ -51,6 +53,7 @@ const getLines = ({
5153
stakeType,
5254
nativeToken,
5355
isClearSignedTradingSwap,
56+
isClearSignedWrapUnwrap,
5457
isTronStakeFreeze,
5558
tronResourceLabel,
5659
}: GetLinesParams): OutputElementLine[] => {
@@ -129,7 +132,10 @@ const getLines = ({
129132
const isFeeOnly =
130133
isUnknownStakingValue ||
131134
(isEvmApprovalTx(precomposedForm.transactionData) && isApprovalFlowSupported(device)) ||
132-
isYieldOrClaimOperation;
135+
isYieldOrClaimOperation ||
136+
// A clear-signed wrap/unwrap already confirms the amount on its own row, and the
137+
// device leaves it off its summary screen for the same reason.
138+
isClearSignedWrapUnwrap;
133139

134140
return isFeeOnly ? [feeLine] : [amountLine, feeLine];
135141
}
@@ -222,6 +228,12 @@ export const TransactionReviewTotalOutput = ({
222228
transactionData: precomposedForm.transactionData,
223229
trading: precomposedForm.trading,
224230
});
231+
const isClearSignedWrapUnwrap = isClearSignedWrappedNativeTransaction({
232+
account,
233+
device,
234+
precomposedTx,
235+
transactionData: precomposedForm.transactionData,
236+
});
225237
const lines = getLines({
226238
device,
227239
networkType,
@@ -231,12 +243,14 @@ export const TransactionReviewTotalOutput = ({
231243
stakeType,
232244
nativeToken,
233245
isClearSignedTradingSwap,
246+
isClearSignedWrapUnwrap,
234247
isTronStakeFreeze,
235248
tronResourceLabel,
236249
});
237250

238251
const titleId = (() => {
239-
if (isClearSignedTradingSwap) return 'TR_NETWORK_FEE';
252+
// Both list the fee alone, so "Total including fee" would be misleading.
253+
if (isClearSignedTradingSwap || isClearSignedWrapUnwrap) return 'TR_NETWORK_FEE';
240254
if (precomposedForm.trading?.isSlip24Active) return 'TR_SUMMARY';
241255
if (isTronStakeFreeze) return 'TR_SUMMARY';
242256

suite-common/wallet-types/src/transactionReviewOutput.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type ReviewOutput =
2424
| 'approve_data'
2525
| 'recipient_name'
2626
| 'swap_intent'
27+
| 'contract_intent'
2728
| 'tron-vote'
2829
| 'tron-withdraw'
2930
| 'tron-claim'

suite-common/wallet-utils/src/reviewTransactionUtils.test.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { buildApprovalTransactionData } from './ethUtils';
1414
import {
1515
constructTransactionReviewOutputs,
1616
isClearSignedEvmTradingSwapTransaction,
17+
isClearSignedWrappedNativeTransaction,
1718
} from './reviewTransactionUtils';
1819

1920
const buildPrecomposedTx = (to: string | undefined): GeneralPrecomposedTransactionFinal =>
@@ -118,6 +119,15 @@ const buildPrecomposedTransaction = ({
118119
isTokenKnown,
119120
}) as unknown as GeneralPrecomposedTransactionFinal;
120121

122+
const wethToken: TokenInfo = {
123+
balance: '1000000',
124+
contract: WETH_MAINNET.toLowerCase(),
125+
decimals: 18,
126+
name: 'Wrapped Ether',
127+
standard: 'ERC20',
128+
symbol: 'WETH',
129+
};
130+
121131
const usdcToken: TokenInfo = {
122132
balance: '1000000',
123133
contract: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
@@ -185,6 +195,61 @@ describe('isClearSignedEvmTradingSwapTransaction', () => {
185195
});
186196
});
187197

198+
describe('isClearSignedWrappedNativeTransaction', () => {
199+
const account = buildEthereumAccount();
200+
const device = buildUpdatedDevice();
201+
202+
it.each([
203+
{ op: 'wrap', transactionData: WETH_DEPOSIT_DATA },
204+
{ op: 'unwrap', transactionData: WETH_WITHDRAW_DATA },
205+
])('returns true for a canonical WETH $op', ({ transactionData }) => {
206+
const result = isClearSignedWrappedNativeTransaction({
207+
account,
208+
device,
209+
precomposedTx: buildPrecomposedTransaction({ to: WETH_MAINNET }),
210+
transactionData,
211+
});
212+
213+
expect(result).toBe(true);
214+
});
215+
216+
it('returns false for a wrapped native the firmware does not clear-sign (WBNB on BSC)', () => {
217+
const result = isClearSignedWrappedNativeTransaction({
218+
account: buildEthereumAccount({ symbol: 'bsc' }),
219+
device,
220+
precomposedTx: buildPrecomposedTransaction({ to: WBNB_BSC }),
221+
transactionData: WETH_DEPOSIT_DATA,
222+
});
223+
224+
expect(result).toBe(false);
225+
});
226+
227+
it('returns false when the device cannot clear-sign', () => {
228+
const result = isClearSignedWrappedNativeTransaction({
229+
account,
230+
device: mockSuiteDevice(
231+
{ unavailableCapabilities: { evmClearSigning: 'no-support' } },
232+
{ major_version: 2, minor_version: 8, patch_version: 0 },
233+
),
234+
precomposedTx: buildPrecomposedTransaction({ to: WETH_MAINNET }),
235+
transactionData: WETH_DEPOSIT_DATA,
236+
});
237+
238+
expect(result).toBe(false);
239+
});
240+
241+
it('returns false for an unrelated contract call to the WETH address', () => {
242+
const result = isClearSignedWrappedNativeTransaction({
243+
account,
244+
device,
245+
precomposedTx: buildPrecomposedTransaction({ to: WETH_MAINNET }),
246+
transactionData: ERC20_TRANSFER_DATA,
247+
});
248+
249+
expect(result).toBe(false);
250+
});
251+
});
252+
188253
describe('constructTransactionReviewOutputs', () => {
189254
const account = buildEthereumAccount();
190255
const device = buildUpdatedDevice();
@@ -393,25 +458,52 @@ describe('constructTransactionReviewOutputs', () => {
393458
it.each([
394459
{ op: 'wrap', transactionData: WETH_DEPOSIT_DATA },
395460
{ op: 'unwrap', transactionData: WETH_WITHDRAW_DATA },
396-
])('suppresses the raw data row for a clear-signed WETH $op', ({ transactionData }) => {
461+
])('mirrors the four device screens for a clear-signed WETH $op', ({ transactionData, op }) => {
397462
const outputs = constructTransactionReviewOutputs({
398463
account,
399464
device,
400465
decreaseOutputId: undefined,
401466
precomposedForm: buildFormState({ transactionData }),
467+
precomposedTx: buildPrecomposedTransaction({
468+
to: WETH_MAINNET,
469+
// An unwrap review carries the WETH token; the amount row must still be
470+
// native, matching the device's AmountFormatter.
471+
token: op === 'unwrap' ? wethToken : undefined,
472+
}),
473+
});
474+
475+
// `confirm_ethereum_clear_signing` walks provider → intent → amount → summary. The
476+
// summary is the review's own total row, so three outputs precede it.
477+
expect(outputs).toEqual([
478+
{ type: 'recipient_name', value: 'WETH' },
479+
{ type: 'contract_intent', value: '' },
480+
{ type: 'amount', value: '1000000' },
481+
]);
482+
// No token on the amount row: wrapping is 1:1 and the device prints ETH both ways.
483+
expect(outputs[2]).not.toHaveProperty('token');
484+
});
485+
486+
it('renders the blind-signing rows for a WETH wrap on firmware without clear signing', () => {
487+
const outputs = constructTransactionReviewOutputs({
488+
account,
489+
device: mockSuiteDevice(
490+
{ unavailableCapabilities: { evmClearSigning: 'update-required' } },
491+
{ major_version: 2, minor_version: 8, patch_version: 0 },
492+
),
493+
decreaseOutputId: undefined,
494+
precomposedForm: buildFormState({ transactionData: WETH_DEPOSIT_DATA }),
402495
precomposedTx: buildPrecomposedTransaction({ to: WETH_MAINNET }),
403496
});
404497

405-
// The device clear-signs the intent + amount, so the raw calldata row is dropped...
406-
expect(outputs).not.toEqual(
407-
expect.arrayContaining([expect.objectContaining({ type: 'data' })]),
408-
);
409-
// ...but the contract address (and the fee/total summary) still show.
410498
expect(outputs).toEqual(
411499
expect.arrayContaining([
500+
expect.objectContaining({ type: 'data', value: WETH_DEPOSIT_DATA }),
412501
expect.objectContaining({ type: 'contract', value: WETH_MAINNET }),
413502
]),
414503
);
504+
expect(outputs).not.toEqual(
505+
expect.arrayContaining([expect.objectContaining({ type: 'contract_intent' })]),
506+
);
415507
});
416508

417509
it('keeps the raw data row for a wrapped native the firmware does not clear-sign (WBNB on BSC)', () => {

0 commit comments

Comments
 (0)