From 399daf49a5b405213146e608074db5c2774597d3 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:43:04 -0300 Subject: [PATCH 01/15] feat(rn_cli_wallet): redesign payment options UI with per-option fee estimates Refactor PaymentOptionsModal to estimate gas/approval fees per option upfront, share a new OptionItem component across select and review views, and add a dedicated gas fee explainer. Persist the last paid token unit via MMKV so it appears first on subsequent payments, and clear it on wallet import. Add Shimmer loading component, GasPump, Info, Pencil and SelectToken icons. Add PaymentStore unit tests. --- .../__tests__/PaymentStore.test.ts | 615 ++++++++++++++++++ .../__tests__/PaymentUtil.test.ts | 47 +- wallets/rn_cli_wallet/src/assets/GasPump.tsx | 12 + wallets/rn_cli_wallet/src/assets/Info.tsx | 12 + wallets/rn_cli_wallet/src/assets/Pencil.tsx | 12 + .../rn_cli_wallet/src/assets/QuestionMark.tsx | 12 - .../rn_cli_wallet/src/assets/SelectToken.tsx | 12 + .../src/components/FadeGradient.tsx | 8 +- .../src/components/OptionItem.tsx | 186 ++++++ .../rn_cli_wallet/src/components/Shimmer.tsx | 122 ++++ wallets/rn_cli_wallet/src/components/Text.tsx | 7 + wallets/rn_cli_wallet/src/constants/Canton.ts | 5 +- wallets/rn_cli_wallet/src/hooks/useNfc.ts | 4 +- .../src/modals/ImportWalletModal.tsx | 12 + .../CollectDataWebView.tsx | 2 +- .../PaymentOptionsModal/ExpiryWarningView.tsx | 7 +- .../modals/PaymentOptionsModal/GasFeeView.tsx | 87 +++ .../PaymentOptionsModal/InfoExplainerView.tsx | 9 +- .../PaymentOptionsModal/LoadingView.tsx | 16 +- .../modals/PaymentOptionsModal/ResultView.tsx | 13 +- .../PaymentOptionsModal/ReviewPaymentView.tsx | 189 ++---- .../PaymentOptionsModal/SelectOptionView.tsx | 303 +++------ .../src/modals/PaymentOptionsModal/index.tsx | 263 +++++--- .../src/modals/SessionProposalModal.tsx | 6 +- .../LogList/components/LogFilterSheet.tsx | 11 +- .../src/screens/SecretPhrase/index.tsx | 5 +- .../src/screens/Settings/index.tsx | 9 +- .../src/services/ERC20BalanceService.ts | 14 +- .../rn_cli_wallet/src/store/PaymentStore.ts | 359 ++++++---- .../src/utils/PaymentTransactionUtil.ts | 237 ++++++- .../rn_cli_wallet/src/utils/PaymentUtil.ts | 25 +- wallets/rn_cli_wallet/src/utils/TypesUtil.ts | 1 + 32 files changed, 1937 insertions(+), 685 deletions(-) create mode 100644 wallets/rn_cli_wallet/__tests__/PaymentStore.test.ts create mode 100644 wallets/rn_cli_wallet/src/assets/GasPump.tsx create mode 100644 wallets/rn_cli_wallet/src/assets/Info.tsx create mode 100644 wallets/rn_cli_wallet/src/assets/Pencil.tsx delete mode 100644 wallets/rn_cli_wallet/src/assets/QuestionMark.tsx create mode 100644 wallets/rn_cli_wallet/src/assets/SelectToken.tsx create mode 100644 wallets/rn_cli_wallet/src/components/OptionItem.tsx create mode 100644 wallets/rn_cli_wallet/src/components/Shimmer.tsx create mode 100644 wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/GasFeeView.tsx diff --git a/wallets/rn_cli_wallet/__tests__/PaymentStore.test.ts b/wallets/rn_cli_wallet/__tests__/PaymentStore.test.ts new file mode 100644 index 000000000..3b6e02f42 --- /dev/null +++ b/wallets/rn_cli_wallet/__tests__/PaymentStore.test.ts @@ -0,0 +1,615 @@ +import type { + Action, + PaymentOption, + PaymentOptionsResponse, +} from '@walletconnect/pay'; + +jest.mock('../src/store/LogStore', () => ({ + __esModule: true, + default: { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + serializeError: jest.fn((error: unknown) => + error instanceof Error ? { message: error.message } : String(error), + ), +})); + +jest.mock('../src/store/SettingsStore', () => ({ + __esModule: true, + default: { + state: { + eip155Address: '0xabc', + }, + }, +})); + +jest.mock('../src/utils/WalletKitUtil', () => ({ + __esModule: true, + walletKit: { + pay: { + getRequiredPaymentActions: jest.fn(), + confirmPayment: jest.fn(), + }, + }, +})); + +jest.mock('../src/utils/EIP155WalletUtil', () => ({ + __esModule: true, + eip155Wallets: { + '0xabc': { + _signTypedData: jest.fn(), + connect: jest.fn(), + }, + }, +})); + +jest.mock('../src/utils/PaymentTransactionUtil', () => ({ + __esModule: true, + estimateTransactionFee: jest.fn(), + sendTransactionWithFreshFees: jest.fn(), + waitForTransactionConfirmation: jest.fn(), +})); + +jest.mock('../src/utils/storage', () => ({ + __esModule: true, + storage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +import PaymentStore from '../src/store/PaymentStore'; +import { EIP155_SIGNING_METHODS } from '../src/constants/Eip155'; +import { walletKit } from '../src/utils/WalletKitUtil'; +import { eip155Wallets } from '../src/utils/EIP155WalletUtil'; +import { storage } from '../src/utils/storage'; +import { + estimateTransactionFee, + sendTransactionWithFreshFees, + waitForTransactionConfirmation, +} from '../src/utils/PaymentTransactionUtil'; + +const mockedEstimateTransactionFee = jest.mocked(estimateTransactionFee); +const mockedSendTransactionWithFreshFees = jest.mocked( + sendTransactionWithFreshFees, +); +const mockedWaitForTransactionConfirmation = jest.mocked( + waitForTransactionConfirmation, +); +const mockedGetRequiredPaymentActions = jest.mocked( + walletKit.pay.getRequiredPaymentActions, +); +const mockedConfirmPayment = jest.mocked(walletKit.pay.confirmPayment); +const mockedWallet = eip155Wallets['0xabc']; +const mockedSignTypedData = jest.mocked(mockedWallet._signTypedData); +const mockedStorageGetItem = jest.mocked(storage.getItem); +const mockedStorageSetItem = jest.mocked(storage.setItem); +const mockedStorageRemoveItem = jest.mocked(storage.removeItem); + +function createAction(method: string, params: unknown[] = []): Action { + return { + walletRpc: { + chainId: 'eip155:137', + method, + params: JSON.stringify(params), + }, + }; +} + +function createOption(overrides: Partial = {}): PaymentOption { + return { + id: 'option-1', + account: 'eip155:137:0xabc', + amount: { + unit: 'caip19/eip155:137/erc20:0x1', + value: '10000', + display: { + assetSymbol: 'USDC', + assetName: 'USD Coin', + decimals: 6, + networkName: 'Polygon', + iconUrl: 'https://example.com/usdc.png', + networkIconUrl: 'https://example.com/polygon.png', + }, + }, + etaS: 5, + actions: [], + ...overrides, + }; +} + +function createPaymentOptions( + optionOverrides: Array>, +): PaymentOptionsResponse { + return { + paymentId: 'payment-1', + info: { + status: 'requires_action', + amount: { + unit: 'iso4217/USD', + value: '1', + display: { + assetSymbol: 'USD', + assetName: 'US Dollar', + decimals: 2, + }, + }, + expiresAt: Math.floor(Date.now() / 1000) + 600, + merchant: { + name: 'Cafe Ivan', + iconUrl: 'https://example.com/merchant.png', + }, + }, + options: optionOverrides.map(overrides => createOption(overrides)), + }; +} + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('PaymentStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + PaymentStore.reset(); + + mockedEstimateTransactionFee.mockResolvedValue({ + display: '$0.01', + nativeDisplay: '0.0012 POL', + fiatValue: 0.01, + fiatCurrency: 'USD', + chainId: 'eip155:137', + nativeSymbol: 'POL', + }); + mockedGetRequiredPaymentActions.mockResolvedValue([ + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, [ + '0xabc', + JSON.stringify({ + domain: {}, + types: { + EIP712Domain: [], + PermitTransferFrom: [], + }, + message: {}, + }), + ]), + ]); + mockedConfirmPayment.mockResolvedValue({ + status: 'succeeded', + isFinal: true, + } as any); + mockedSendTransactionWithFreshFees.mockResolvedValue({ + hash: '0xhash', + wait: jest.fn(), + } as any); + mockedWaitForTransactionConfirmation.mockResolvedValue(undefined); + mockedSignTypedData.mockResolvedValue('0xsigned'); + mockedStorageGetItem.mockResolvedValue(undefined); + mockedStorageSetItem.mockResolvedValue(undefined as any); + mockedStorageRemoveItem.mockResolvedValue(undefined as any); + }); + + afterEach(() => { + PaymentStore.reset(); + }); + + it('starts per-option estimation when payment options load and does not fetch required actions', async () => { + const paymentOptions = createPaymentOptions([ + { + id: 'approval-option', + actions: [ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4), + ], + }, + { + id: 'signature-option', + actions: [createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4)], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + + expect( + PaymentStore.state.optionFeeEstimateStatusById['approval-option'], + ).toBe('loading'); + expect(mockedGetRequiredPaymentActions).not.toHaveBeenCalled(); + + await flushPromises(); + + expect(mockedEstimateTransactionFee).toHaveBeenCalledTimes(1); + expect( + PaymentStore.state.optionFeeEstimatesById['approval-option'], + ).toMatchObject({ + display: '$0.01', + nativeDisplay: '0.0012 POL', + fiatValue: 0.01, + fiatCurrency: 'USD', + }); + expect( + PaymentStore.state.optionFeeEstimateStatusById['approval-option'], + ).toBe('ready'); + expect( + PaymentStore.state.optionFeeEstimatesById['signature-option'], + ).toBeUndefined(); + expect( + PaymentStore.state.optionFeeEstimateStatusById['signature-option'], + ).toBe('ready'); + }); + + it('does not fetch required actions when selecting an option', async () => { + const paymentOptions = createPaymentOptions([ + { + id: 'approval-option', + actions: [ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + ], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + + await flushPromises(); + + expect(mockedGetRequiredPaymentActions).not.toHaveBeenCalled(); + expect(PaymentStore.state.selectedOption?.id).toBe('approval-option'); + }); + + it('fetches required actions only when approving the payment', async () => { + const paymentOptions = createPaymentOptions([ + { + id: 'signature-option', + actions: [createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4)], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + + await flushPromises(); + await PaymentStore.approvePayment(); + + expect(mockedGetRequiredPaymentActions).toHaveBeenCalledWith({ + paymentId: 'payment-1', + optionId: 'signature-option', + }); + expect(mockedConfirmPayment).toHaveBeenCalledWith({ + paymentId: 'payment-1', + optionId: 'signature-option', + signatures: ['0xsigned'], + }); + expect(mockedStorageSetItem).toHaveBeenCalledWith( + 'PAY_LAST_TOKEN_UNIT', + 'caip19/eip155:137/erc20:0x1', + ); + expect(PaymentStore.state.resultStatus).toBe('success'); + }); + + it('replaces the stored token unit after a later successful payment', async () => { + const usdcOptions = createPaymentOptions([ + { + id: 'usdc-option', + amount: { + unit: 'caip19/eip155:137/erc20:0xUSDC', + value: '10000', + display: { + assetSymbol: 'USDC', + assetName: 'USD Coin', + decimals: 6, + networkName: 'Polygon', + iconUrl: 'https://example.com/usdc.png', + networkIconUrl: 'https://example.com/polygon.png', + }, + } as PaymentOption['amount'], + actions: [createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4)], + }, + ]); + + PaymentStore.setPaymentOptions(usdcOptions); + PaymentStore.selectOption(usdcOptions.options[0]); + await flushPromises(); + await PaymentStore.approvePayment(); + + const usdtOptions = createPaymentOptions([ + { + id: 'usdt-option', + amount: { + unit: 'caip19/eip155:137/erc20:0xUSDT', + value: '10000', + display: { + assetSymbol: 'USDT', + assetName: 'Tether USD', + decimals: 6, + networkName: 'Polygon', + iconUrl: 'https://example.com/usdt.png', + networkIconUrl: 'https://example.com/polygon.png', + }, + } as PaymentOption['amount'], + actions: [createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4)], + }, + ]); + + PaymentStore.setPaymentOptions(usdtOptions); + PaymentStore.selectOption(usdtOptions.options[0]); + await flushPromises(); + await PaymentStore.approvePayment(); + + expect(mockedStorageSetItem).toHaveBeenNthCalledWith( + 1, + 'PAY_LAST_TOKEN_UNIT', + 'caip19/eip155:137/erc20:0xUSDC', + ); + expect(mockedStorageSetItem).toHaveBeenNthCalledWith( + 2, + 'PAY_LAST_TOKEN_UNIT', + 'caip19/eip155:137/erc20:0xUSDT', + ); + }); + + it('finds preferred option by exact token unit match', () => { + const options = createPaymentOptions([ + { + id: 'usdc', + amount: { + unit: 'caip19/eip155:137/erc20:0xUSDC', + value: '10000', + display: { + assetSymbol: 'USDC', + assetName: 'USD Coin', + decimals: 6, + networkName: 'Polygon', + iconUrl: 'https://example.com/usdc.png', + networkIconUrl: 'https://example.com/polygon.png', + }, + } as PaymentOption['amount'], + }, + { + id: 'usdt', + amount: { + unit: 'caip19/eip155:137/erc20:0xUSDT', + value: '10000', + display: { + assetSymbol: 'USDT', + assetName: 'Tether USD', + decimals: 6, + networkName: 'Polygon', + iconUrl: 'https://example.com/usdt.png', + networkIconUrl: 'https://example.com/polygon.png', + }, + } as PaymentOption['amount'], + }, + ]).options; + + const preferred = PaymentStore.findPreferredOption( + options, + 'caip19/eip155:137/erc20:0xUSDT', + ); + + expect(preferred?.id).toBe('usdt'); + }); + + it('loads and clears the last paid token preference', async () => { + mockedStorageGetItem.mockResolvedValue('caip19/eip155:137/erc20:0xUSDT'); + + const loaded = await PaymentStore.loadLastPaidTokenUnit(); + expect(loaded).toBe('caip19/eip155:137/erc20:0xUSDT'); + + await PaymentStore.clearLastPaidTokenUnit(); + expect(mockedStorageRemoveItem).toHaveBeenCalledWith('PAY_LAST_TOKEN_UNIT'); + }); + + it('keeps the default processing message for single-step typed-data payments', async () => { + mockedSignTypedData.mockImplementation(async () => { + expect(PaymentStore.state.loadingMessage).toBeNull(); + return '0xsigned'; + }); + + const paymentOptions = createPaymentOptions([ + { + id: 'single-step-option', + actions: [createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4)], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + + await flushPromises(); + await PaymentStore.approvePayment(); + + expect(mockedGetRequiredPaymentActions).toHaveBeenCalledWith({ + paymentId: 'payment-1', + optionId: 'single-step-option', + }); + expect(mockedStorageSetItem).toHaveBeenCalledWith( + 'PAY_LAST_TOKEN_UNIT', + 'caip19/eip155:137/erc20:0x1', + ); + expect(PaymentStore.state.resultStatus).toBe('success'); + }); + + it('does not set a finalizing message for multi-step approval flows', async () => { + mockedGetRequiredPaymentActions.mockResolvedValue([ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, [ + '0xabc', + JSON.stringify({ + domain: {}, + types: { + EIP712Domain: [], + PermitTransferFrom: [], + }, + message: {}, + }), + ]), + ]); + + mockedSignTypedData.mockImplementation(async () => { + expect(PaymentStore.state.loadingMessage).toBeNull(); + return '0xsigned'; + }); + + const paymentOptions = createPaymentOptions([ + { + id: 'approval-option', + actions: [ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4), + ], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + + await flushPromises(); + await PaymentStore.approvePayment(); + + expect(PaymentStore.state.resultStatus).toBe('success'); + }); + + it('starts confirming with setup message for approval options before actions fetch resolves', async () => { + const deferredActions = createDeferred(); + mockedGetRequiredPaymentActions.mockImplementationOnce( + () => deferredActions.promise, + ); + + const paymentOptions = createPaymentOptions([ + { + id: 'approval-option', + amount: { + unit: 'caip19/eip155:137/erc20:0x1', + value: '10000', + display: { + assetSymbol: 'USDT', + assetName: 'Tether USD', + decimals: 6, + networkName: 'Polygon', + iconUrl: 'https://example.com/usdt.png', + networkIconUrl: 'https://example.com/polygon.png', + }, + } as PaymentOption['amount'], + actions: [ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4), + ], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + await flushPromises(); + + const approvePromise = PaymentStore.approvePayment(); + + expect(PaymentStore.state.step).toBe('confirming'); + expect(PaymentStore.state.loadingMessage).toBe( + 'Setting up USDT for one-time setup...', + ); + expect(PaymentStore.state.loadingNote).toBe( + 'Future USDT payments will be instant', + ); + + deferredActions.resolve([ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, [ + '0xabc', + JSON.stringify({ + domain: {}, + types: { + EIP712Domain: [], + PermitTransferFrom: [], + }, + message: {}, + }), + ]), + ]); + + await approvePromise; + expect(mockedStorageSetItem).toHaveBeenCalledWith( + 'PAY_LAST_TOKEN_UNIT', + 'caip19/eip155:137/erc20:0x1', + ); + expect(PaymentStore.state.resultStatus).toBe('success'); + }); + + it('allows pay to succeed even if approval fee estimation fails', async () => { + mockedEstimateTransactionFee.mockRejectedValueOnce( + new Error('estimate failed'), + ); + mockedGetRequiredPaymentActions.mockResolvedValue([ + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, [ + '0xabc', + JSON.stringify({ + domain: {}, + types: { + EIP712Domain: [], + ReceiveWithAuthorization: [], + }, + message: {}, + }), + ]), + ]); + + const paymentOptions = createPaymentOptions([ + { + id: 'approval-option', + actions: [ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', data: '0x' }, + ]), + createAction(EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4), + ], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + + await flushPromises(); + + expect( + PaymentStore.state.optionFeeEstimateStatusById['approval-option'], + ).toBe('error'); + + await PaymentStore.approvePayment(); + + expect(mockedGetRequiredPaymentActions).toHaveBeenCalledWith({ + paymentId: 'payment-1', + optionId: 'approval-option', + }); + expect(mockedConfirmPayment).toHaveBeenCalled(); + expect(mockedStorageSetItem).toHaveBeenCalledWith( + 'PAY_LAST_TOKEN_UNIT', + 'caip19/eip155:137/erc20:0x1', + ); + expect(PaymentStore.state.resultStatus).toBe('success'); + }); +}); diff --git a/wallets/rn_cli_wallet/__tests__/PaymentUtil.test.ts b/wallets/rn_cli_wallet/__tests__/PaymentUtil.test.ts index dc4df51a1..eca8c50d9 100644 --- a/wallets/rn_cli_wallet/__tests__/PaymentUtil.test.ts +++ b/wallets/rn_cli_wallet/__tests__/PaymentUtil.test.ts @@ -1,7 +1,7 @@ import type { Action } from '@walletconnect/pay'; import { EIP155_SIGNING_METHODS } from '../src/constants/Eip155'; -import { getPaymentContext } from '../src/utils/PaymentUtil'; +import { getApprovalAction, requiresApproval } from '../src/utils/PaymentUtil'; function createAction(method: string): Action { return { @@ -13,16 +13,12 @@ function createAction(method: string): Action { }; } -describe('getPaymentContext', () => { +describe('Payment approval helpers', () => { it('marks approval as required when eth_sendTransaction is present', () => { const sendTxAction = createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION); - const context = getPaymentContext({ - paymentActions: [sendTxAction], - }); - - expect(context.requiresApproval).toBe(true); - expect(context.approvalAction).toBe(sendTxAction); + expect(requiresApproval([sendTxAction])).toBe(true); + expect(getApprovalAction([sendTxAction])).toBe(sendTxAction); }); it('does not require approval when there is no eth_sendTransaction action', () => { @@ -30,20 +26,33 @@ describe('getPaymentContext', () => { EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, ); - const context = getPaymentContext({ - paymentActions: [typedDataAction], - }); - - expect(context.requiresApproval).toBe(false); - expect(context.approvalAction).toBeNull(); + expect(requiresApproval([typedDataAction])).toBe(false); + expect(getApprovalAction([typedDataAction])).toBeNull(); }); it('does not require approval when actions are missing', () => { - const context = getPaymentContext({ - paymentActions: null, - }); + expect(requiresApproval(null)).toBe(false); + expect(getApprovalAction(null)).toBeNull(); + }); + + it('does not require approval for signature-only option actions', () => { + const typedDataAction = createAction( + EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, + ); + + expect(requiresApproval([typedDataAction])).toBe(false); + expect(getApprovalAction([typedDataAction])).toBeNull(); + }); + + it('finds the approval action when option actions include approval and signature steps', () => { + const sendTxAction = createAction( + EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, + ); + const typedDataAction = createAction( + EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, + ); - expect(context.requiresApproval).toBe(false); - expect(context.approvalAction).toBeNull(); + expect(requiresApproval([sendTxAction, typedDataAction])).toBe(true); + expect(getApprovalAction([sendTxAction, typedDataAction])).toBe(sendTxAction); }); }); diff --git a/wallets/rn_cli_wallet/src/assets/GasPump.tsx b/wallets/rn_cli_wallet/src/assets/GasPump.tsx new file mode 100644 index 000000000..6758d8334 --- /dev/null +++ b/wallets/rn_cli_wallet/src/assets/GasPump.tsx @@ -0,0 +1,12 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; + +const SvgGasPump = (props: SvgProps) => ( + + + +); + +export default SvgGasPump; diff --git a/wallets/rn_cli_wallet/src/assets/Info.tsx b/wallets/rn_cli_wallet/src/assets/Info.tsx new file mode 100644 index 000000000..f0ea54b6d --- /dev/null +++ b/wallets/rn_cli_wallet/src/assets/Info.tsx @@ -0,0 +1,12 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; + +const SvgInfo = (props: SvgProps) => ( + + + +); + +export default SvgInfo; diff --git a/wallets/rn_cli_wallet/src/assets/Pencil.tsx b/wallets/rn_cli_wallet/src/assets/Pencil.tsx new file mode 100644 index 000000000..277c8fc05 --- /dev/null +++ b/wallets/rn_cli_wallet/src/assets/Pencil.tsx @@ -0,0 +1,12 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; + +const SvgPencil = (props: SvgProps) => ( + + + +); + +export default SvgPencil; diff --git a/wallets/rn_cli_wallet/src/assets/QuestionMark.tsx b/wallets/rn_cli_wallet/src/assets/QuestionMark.tsx deleted file mode 100644 index 43f0e2926..000000000 --- a/wallets/rn_cli_wallet/src/assets/QuestionMark.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Svg, { Path, type SvgProps } from 'react-native-svg'; - -const SvgQuestionMark = (props: SvgProps) => ( - - - -); - -export default SvgQuestionMark; diff --git a/wallets/rn_cli_wallet/src/assets/SelectToken.tsx b/wallets/rn_cli_wallet/src/assets/SelectToken.tsx new file mode 100644 index 000000000..252765ab2 --- /dev/null +++ b/wallets/rn_cli_wallet/src/assets/SelectToken.tsx @@ -0,0 +1,12 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; + +const SvgSelectToken = (props: SvgProps) => ( + + + +); + +export default SvgSelectToken; diff --git a/wallets/rn_cli_wallet/src/components/FadeGradient.tsx b/wallets/rn_cli_wallet/src/components/FadeGradient.tsx index 8b5120303..930f1bdca 100644 --- a/wallets/rn_cli_wallet/src/components/FadeGradient.tsx +++ b/wallets/rn_cli_wallet/src/components/FadeGradient.tsx @@ -39,7 +39,13 @@ export function FadeGradient({ > - + void; + onIconRightPress?: () => void; + testID?: string; +} + +function formatOptionAmount(value: string, decimals: number): string { + const amount = formatAmount(value, decimals, 2); + const numericAmount = Number(amount); + + if (!Number.isFinite(numericAmount) || numericAmount >= 0.01) { + return amount; + } + + if (numericAmount > 0 && numericAmount < 10 ** -SMALL_AMOUNT_DECIMALS) { + return `<${(10 ** -SMALL_AMOUNT_DECIMALS).toFixed(SMALL_AMOUNT_DECIMALS)}`; + } + + return numericAmount.toFixed(SMALL_AMOUNT_DECIMALS).replace(/\.?0+$/, ''); +} + +export function OptionItem({ + option, + renderIconRight, + onIconRightPress, + gasCostEstimate, + isEstimatingApprovalGas, + onPress, + testID, +}: OptionItemProps) { + const Theme = useTheme(); + + const amount = formatOptionAmount( + option.amount.value, + option.amount.display.decimals, + ); + + const chainIcon = PresetsUtil.getIconLogoByName( + option.amount.display.networkName, + ); + const tokenIconUrl = option.amount.display.iconUrl?.trim(); + const hasTokenIcon = !!tokenIconUrl; + const mainIconSource = hasTokenIcon ? { uri: tokenIconUrl } : chainIcon; + + return ( + + ); +} + +const styles = StyleSheet.create({ + optionItem: { + height: OPTION_HEIGHT, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: Spacing[5], + borderRadius: BorderRadius[4], + justifyContent: 'space-between', + overflow: 'hidden', + }, + optionItemContent: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'space-between', + }, + optionIconContainer: { + width: Spacing[8], + height: Spacing[8], + }, + optionIcon: { + width: Spacing[8], + height: Spacing[8], + borderRadius: BorderRadius.full, + }, + optionChainIcon: { + height: 18, + width: 18, + position: 'absolute', + borderRadius: BorderRadius.full, + borderWidth: 2, + right: -2, + bottom: -2, + }, + contentLeftContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing[2], + }, + contentRightContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing[4], + }, + iconRightContainer: { + borderWidth: 1, + height: 38, + width: 38, + justifyContent: 'center', + alignItems: 'center', + borderRadius: BorderRadius[3], + }, + gasFeeContainer: { + flexDirection: 'row', + alignItems: 'center', + }, +}); diff --git a/wallets/rn_cli_wallet/src/components/Shimmer.tsx b/wallets/rn_cli_wallet/src/components/Shimmer.tsx new file mode 100644 index 000000000..74fcd45b9 --- /dev/null +++ b/wallets/rn_cli_wallet/src/components/Shimmer.tsx @@ -0,0 +1,122 @@ +import { useTheme } from '@/hooks/useTheme'; +import { memo, useEffect, useState } from 'react'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import Animated, { + interpolate, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +type PercentString = `${number}%`; +type ShimmerDimension = number | PercentString; + +export interface ShimmerProps { + width?: ShimmerDimension; + height?: ShimmerDimension; + duration?: number; + borderRadius?: number; + backgroundColor?: string; + foregroundColor?: string; + highlightWidthRatio?: number; + highlightOpacity?: number; + angle?: number; // in degrees + style?: StyleProp; +} + +function Shimmer_({ + width = 200, + height = 200, + duration = 1000, + borderRadius = 0, + backgroundColor, + foregroundColor, + highlightWidthRatio = 0.3, + highlightOpacity = 0.5, + angle = 20, + style, +}: ShimmerProps) { + const Theme = useTheme(); + + const [measuredWidth, setMeasuredWidth] = useState(null); + const [measuredHeight, setMeasuredHeight] = useState(null); + + const translateX = useSharedValue(0); + + useEffect(() => { + if (!measuredWidth) { + return undefined; + } + translateX.value = 0; + translateX.value = withRepeat(withTiming(1, { duration }), -1, false); + }, [duration, measuredWidth, translateX]); + + const baseColor = backgroundColor ?? Theme['foreground-secondary']; + const highlightColor = foregroundColor ?? Theme['foreground-tertiary']; + + const onLayout = (event: any) => { + const { width: w, height: h } = event.nativeEvent.layout; + // Update measurements whenever they change to adapt to dynamic layout/orientation + if (measuredWidth == null || Math.abs(w - measuredWidth) > 0.5) { + setMeasuredWidth(w); + } + if (measuredHeight == null || Math.abs(h - measuredHeight) > 0.5) { + setMeasuredHeight(h); + } + }; + + // Compute animated translateX only if we have width + const bandWidth = measuredWidth + ? Math.max(8, measuredWidth * highlightWidthRatio) + : 0; + const travel = measuredWidth ? measuredWidth + bandWidth * 2 : 0; + + const animatedStyle = useAnimatedStyle(() => { + if (!measuredWidth) { + return {}; + } + const translateXValue = interpolate( + translateX.value, + [0, 1], + [-bandWidth, travel - bandWidth], + ); + return { + transform: [{ translateX: translateXValue }, { rotate: `${angle}deg` }], + }; + }); + + const containerStyle: ViewStyle = { + width, + height, + borderRadius, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: Theme['border-primary'], + backgroundColor: baseColor, + }; + + const bandStyle: ViewStyle = { + position: 'absolute', + top: measuredHeight ? -measuredHeight * 0.25 : 0, + bottom: measuredHeight ? -measuredHeight * 0.25 : 0, + width: bandWidth, + backgroundColor: highlightColor, + opacity: highlightOpacity, + }; + + return ( + + {measuredWidth && measuredHeight ? ( + + ) : null} + + ); +} + +export const Shimmer = memo(Shimmer_, () => { + return true; +}); diff --git a/wallets/rn_cli_wallet/src/components/Text.tsx b/wallets/rn_cli_wallet/src/components/Text.tsx index 70b6ad70c..dfc1f56a5 100644 --- a/wallets/rn_cli_wallet/src/components/Text.tsx +++ b/wallets/rn_cli_wallet/src/components/Text.tsx @@ -239,6 +239,8 @@ export interface TextProps extends RNTextProps { color?: ThemeKeys; /** Center align the text */ center?: boolean; + /** Underline the text */ + underline?: boolean; /** Children content */ children?: React.ReactNode; } @@ -247,6 +249,7 @@ export function Text({ variant = 'lg-400', color = 'text-primary', center, + underline, style, children, ...props @@ -268,6 +271,7 @@ export function Text({ color: textColor, }, center && styles.center, + underline && styles.underline, style, ]} {...props} @@ -284,4 +288,7 @@ const styles = StyleSheet.create({ center: { textAlign: 'center', }, + underline: { + textDecorationLine: 'underline', + }, }); diff --git a/wallets/rn_cli_wallet/src/constants/Canton.ts b/wallets/rn_cli_wallet/src/constants/Canton.ts index 8998281e0..9f668602a 100644 --- a/wallets/rn_cli_wallet/src/constants/Canton.ts +++ b/wallets/rn_cli_wallet/src/constants/Canton.ts @@ -28,7 +28,10 @@ export const CANTON_TEST_CHAINS = { }, }; -export const CANTON_CHAINS = { ...CANTON_MAINNET_CHAINS, ...CANTON_TEST_CHAINS }; +export const CANTON_CHAINS = { + ...CANTON_MAINNET_CHAINS, + ...CANTON_TEST_CHAINS, +}; export const CANTON_NETWORKS_IMAGES = { 'canton:mainnet': Canton, diff --git a/wallets/rn_cli_wallet/src/hooks/useNfc.ts b/wallets/rn_cli_wallet/src/hooks/useNfc.ts index e1624d0a3..192e2e1b7 100644 --- a/wallets/rn_cli_wallet/src/hooks/useNfc.ts +++ b/wallets/rn_cli_wallet/src/hooks/useNfc.ts @@ -64,7 +64,9 @@ export function useNfc() { }); }, []); - const scanNfcTag = useCallback(async (): Promise => { + const scanNfcTag = useCallback(async (): Promise< + string | null | undefined + > => { pauseForegroundDispatch(); try { await NfcManager.unregisterTagEvent().catch(() => {}); diff --git a/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx b/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx index 976edea0c..7fe77a2df 100644 --- a/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx +++ b/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx @@ -12,6 +12,7 @@ import LogStore from '@/store/LogStore'; import ModalStore from '@/store/ModalStore'; import SettingsStore from '@/store/SettingsStore'; import WalletStore from '@/store/WalletStore'; +import PaymentStore from '@/store/PaymentStore'; import { loadEIP155Wallet } from '@/utils/EIP155WalletUtil'; import { loadTonWallet } from '@/utils/TonWalletUtil'; import { loadTronWallet } from '@/utils/TronWalletUtil'; @@ -139,6 +140,17 @@ export default function ImportWalletModal() { } } + try { + await PaymentStore.clearLastPaidTokenUnit(); + } catch (error: unknown) { + LogStore.warn( + 'Failed to clear last paid token after wallet import', + 'ImportWalletModal', + 'handleImport', + { error: error instanceof Error ? error.message : 'unknown error' }, + ); + } + Toast.show({ type: 'success', text1: `${selectedChain} wallet imported!`, diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx index fece68394..c8f0296af 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx @@ -66,7 +66,7 @@ function getBaseUrl(urlString: string): string { const PREFILL_DATA = { fullName: 'John Doe', dob: '1990-06-15', - pobAddress: 'Buenos Aires' + pobAddress: 'Buenos Aires', }; function buildUrlWithPrefill( diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ExpiryWarningView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ExpiryWarningView.tsx index 6cc6d879b..5502899f5 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ExpiryWarningView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ExpiryWarningView.tsx @@ -50,12 +50,7 @@ export function ExpiryWarningView({ <> - + Your payment expires in{' '} {formatCountdown(remaining)} diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/GasFeeView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/GasFeeView.tsx new file mode 100644 index 000000000..4ac0ed002 --- /dev/null +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/GasFeeView.tsx @@ -0,0 +1,87 @@ +import { View, StyleSheet, Image } from 'react-native'; + +import { Text } from '@/components/Text'; +import { ActionButton } from '@/components/ActionButton'; +import { BorderRadius, Spacing } from '@/utils/ThemeUtil'; +import { useTheme } from '@/hooks/useTheme'; +import GasPump from '@/assets/GasPump'; + +interface GasFeeViewProps { + onDismiss: () => void; + imageSource: string; + tokenName: string; + gasCostEstimate: string; +} + +export function GasFeeView({ + onDismiss, + imageSource, + tokenName, + gasCostEstimate, +}: GasFeeViewProps) { + const Theme = useTheme(); + const tokenNameUppercase = tokenName.toUpperCase(); + + return ( + + {imageSource && ( + + )} + + Why does {tokenNameUppercase} require a gas fee? + + + + The gas fee covers a one-time setup that lets your wallet pay with{' '} + {tokenNameUppercase}. + + + + You only pay it once. Future {tokenNameUppercase} payments from this + wallet skip this step. + + + + + Gas fee: {gasCostEstimate} + + + + + + + Got it! + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingTop: Spacing[2], + paddingHorizontal: Spacing[3], + alignItems: 'center', + rowGap: Spacing[1], + }, + image: { + width: 48, + height: 48, + borderRadius: BorderRadius.full, + marginBottom: Spacing[2], + }, + title: { + marginBottom: Spacing[2], + }, + gasFeeContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing[1], + marginTop: Spacing[7], + marginBottom: Spacing[3], + }, + buttonContainer: { + width: '100%', + }, +}); diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/InfoExplainerView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/InfoExplainerView.tsx index 02cae5bde..b945fa2b9 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/InfoExplainerView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/InfoExplainerView.tsx @@ -11,16 +11,16 @@ interface InfoExplainerViewProps { export function InfoExplainerView({ onDismiss }: InfoExplainerViewProps) { return ( - + Why do we collect personal details? - + To meet compliance requirements, some basic information is collected from WalletConnect Pay users. - + This is typically a one-time step—if you use the same wallet on this network again, you won't need to provide the info again, unless your information changes. @@ -38,6 +38,7 @@ export function InfoExplainerView({ onDismiss }: InfoExplainerViewProps) { const styles = StyleSheet.create({ container: { paddingTop: Spacing[2], + alignItems: 'center', }, title: { marginBottom: Spacing[2], @@ -45,8 +46,8 @@ const styles = StyleSheet.create({ body: { marginBottom: Spacing[2], }, - lastBody: {}, buttonContainer: { + width: '100%', marginTop: Spacing[7], marginBottom: Spacing[2], }, diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx index 38c41e798..a27ca095c 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx @@ -8,6 +8,7 @@ import { Text } from '@/components/Text'; interface LoadingViewProps { message?: string; + note?: string; size?: number; } @@ -21,7 +22,7 @@ const exitingKeyframe = new Keyframe({ 100: { opacity: 0, transform: [{ translateY: -14 }, { scale: 0.92 }] }, }).duration(220); -export function LoadingView({ message, size = 120 }: LoadingViewProps) { +export function LoadingView({ message, note, size = 120 }: LoadingViewProps) { const hasMountedRef = useRef(false); const entering = hasMountedRef.current ? enteringKeyframe : undefined; hasMountedRef.current = true; @@ -48,6 +49,16 @@ export function LoadingView({ message, size = 120 }: LoadingViewProps) { > {message || 'Loading...'} + {note && ( + + {note} + + )} @@ -75,4 +86,7 @@ const styles = StyleSheet.create({ loadingText: { textAlign: 'center', }, + loadingNote: { + marginTop: Spacing[2], + }, }); diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ResultView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ResultView.tsx index 74f5319ff..c2ef2e22e 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ResultView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ResultView.tsx @@ -13,12 +13,19 @@ import { Spacing } from '@/utils/ThemeUtil'; import type { ErrorType } from './utils'; import { getErrorTitle } from './utils'; -const getResultButtonTestId = (isSuccess: boolean, errorType?: ErrorType | null) => +const getResultButtonTestId = ( + isSuccess: boolean, + errorType?: ErrorType | null, +) => `pay-button-result-action-${isSuccess ? 'success' : errorType || 'generic'}`; -const getActionButtonText = (isSuccess: boolean, errorType?: ErrorType | null) => { +const getActionButtonText = ( + isSuccess: boolean, + errorType?: ErrorType | null, +) => { if (isSuccess || errorType === 'insufficient_funds') return 'Got it!'; - if (errorType === 'expired' || errorType === 'cancelled') return 'Scan new QR code'; + if (errorType === 'expired' || errorType === 'cancelled') + return 'Scan new QR code'; return 'Close'; }; diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx index 947a2ba28..ddd6cc830 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx @@ -1,131 +1,113 @@ -import { View, Image, StyleSheet } from 'react-native'; -import type { Action, PaymentInfo, PaymentOption } from '@walletconnect/pay'; +import { View, StyleSheet } from 'react-native'; +import type { PaymentInfo, PaymentOption } from '@walletconnect/pay'; import { useTheme } from '@/hooks/useTheme'; import { ActionButton } from '@/components/ActionButton'; import { Text } from '@/components/Text'; import { MerchantInfo } from './MerchantInfo'; -import { PresetsUtil } from '@/utils/PresetsUtil'; -import { getPaymentContext } from '@/utils/PaymentUtil'; + import { formatAmount, getCurrencySymbol } from './utils'; -import { Spacing, BorderRadius } from '@/utils/ThemeUtil'; +import { Spacing } from '@/utils/ThemeUtil'; +import { OptionItem } from '@/components/OptionItem'; +import Pencil from '@/assets/Pencil'; +import type { TransactionFeeEstimate } from '@/utils/PaymentTransactionUtil'; interface ReviewPaymentViewProps { selectedOption: PaymentOption; info?: PaymentInfo; - paymentActions?: readonly Action[] | null; - approvalGasEstimate?: string | null; + requiresApproval: boolean; + approvalGasEstimate?: TransactionFeeEstimate | null; isEstimatingApprovalGas?: boolean; - isLoadingActions: boolean; - isSigningPayment: boolean; onPay: () => void; + onChangeOption?: () => void; + onGasFeePress?: () => void; +} + +function toDecimalNumber(value: string, decimals: number): number | null { + const formatted = formatAmount(value, decimals); + const amount = Number(formatted); + return Number.isFinite(amount) ? amount : null; } export function ReviewPaymentView({ selectedOption, info, - paymentActions, + requiresApproval, approvalGasEstimate, - isEstimatingApprovalGas = false, - isLoadingActions, - isSigningPayment, + isEstimatingApprovalGas, onPay, + onChangeOption, + onGasFeePress, }: ReviewPaymentViewProps) { const Theme = useTheme(); - const tokenAmount = formatAmount( - selectedOption.amount.value, - selectedOption.amount.display.decimals, - 2, - ); - const payAmount = formatAmount( info?.amount?.value || '0', info?.amount?.display?.decimals || 0, 2, ); - const currencySymbol = getCurrencySymbol(info?.amount?.display?.assetSymbol); + const paymentCurrency = info?.amount?.display?.assetSymbol?.toUpperCase(); + const currencySymbol = getCurrencySymbol(paymentCurrency); - const chainIcon = PresetsUtil.getIconLogoByName( - selectedOption.amount.display.networkName, + const gasCostEstimate = approvalGasEstimate?.display || ''; + const fiatFeeValue = approvalGasEstimate?.fiatValue ?? null; + const basePaymentAmount = toDecimalNumber( + info?.amount?.value || '0', + info?.amount?.display?.decimals || 0, ); - const paymentContext = getPaymentContext({ - paymentActions: paymentActions || null, - }); - const requiresTokenApproval = paymentContext.requiresApproval; - const gasCostEstimate = approvalGasEstimate || 'Network fee set by wallet'; + const canIncludeGasInTotal = + basePaymentAmount !== null && + fiatFeeValue !== null && + approvalGasEstimate?.fiatCurrency === paymentCurrency; + const totalPayAmount = canIncludeGasInTotal + ? (basePaymentAmount + fiatFeeValue).toFixed(2) + : payAmount; return ( <> - - - Pay with - - - - {tokenAmount} {selectedOption.amount.display.assetSymbol} - - - - - - - - - {requiresTokenApproval ? ( - - - One-time fee - - - - {isEstimatingApprovalGas ? 'Loading...' : gasCostEstimate} - - - - ) : null} + + } + onIconRightPress={onChangeOption} + testID={`pay-review-token-${ + selectedOption.amount.display?.networkName?.toLowerCase() || '' + }`} + /> - {`Pay ${currencySymbol}${payAmount}`} + {`Pay ${currencySymbol}${totalPayAmount}`}{' '} + {gasCostEstimate && ( + + (incl. gas fee) + + )} + {requiresApproval && approvalGasEstimate && onGasFeePress && ( + + Why does USDT require a gas fee? + + )} ); @@ -133,42 +115,13 @@ export function ReviewPaymentView({ const styles = StyleSheet.create({ itemContainer: { - marginTop: Spacing[4], - }, - item: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - height: 68, - marginTop: Spacing[2], - paddingHorizontal: Spacing[5], - borderRadius: BorderRadius[4], - }, - itemRight: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing[2], - }, - iconStack: { - width: 32, - height: 32, - }, - tokenIcon: { - width: 32, - height: 32, - borderRadius: BorderRadius.full, - }, - chainBadge: { - width: 16, - height: 16, - position: 'absolute', - borderRadius: BorderRadius.full, - borderWidth: 2, - right: -2, - bottom: -2, + marginTop: Spacing[5], + marginBottom: Spacing[2], }, buttonContainer: { marginTop: Spacing[5], - marginBottom: Spacing[2], + }, + feeText: { + marginTop: Spacing[3], }, }); diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx index d1d0bfcc3..b096b813e 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx @@ -1,172 +1,43 @@ import { View, Image, StyleSheet, ScrollView } from 'react-native'; import type { PaymentInfo, PaymentOption } from '@walletconnect/pay'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolateColor, -} from 'react-native-reanimated'; - import { useTheme } from '@/hooks/useTheme'; -import { ActionButton } from '@/components/ActionButton'; import { FadeGradient } from '@/components/FadeGradient'; -import { MerchantInfo } from './MerchantInfo'; -import { PresetsUtil } from '@/utils/PresetsUtil'; -import { useCallback, useEffect } from 'react'; -import { Spacing, BorderRadius, FontFamily } from '@/utils/ThemeUtil'; +import { Spacing, BorderRadius } from '@/utils/ThemeUtil'; import { Text } from '@/components/Text'; -import { Button } from '@/components/Button'; -import { formatAmount } from './utils'; +import { formatAmount, getCurrencySymbol } from './utils'; import type { PaymentOptionWithCollectData } from '@/utils/TypesUtil'; +import { OptionItem, OPTION_HEIGHT } from '@/components/OptionItem'; +import Info from '@/assets/Info'; +import SvgSelectToken from '@/assets/SelectToken'; +import type { TransactionFeeEstimate } from '@/utils/PaymentTransactionUtil'; const OPTION_GAP = 8; const MAX_VISIBLE_OPTIONS = 4; -const OPTION_HEIGHT = 64; - -// ----- Option Item Component ----- - -interface OptionItemProps { - option: PaymentOption; - index: number; - isSelected: boolean; - hasCollectData: boolean; - onSelect: (option: PaymentOption) => void; -} - -const ANIMATION_DURATION = 250; - -function OptionItem({ - option, - index, - isSelected, - hasCollectData, - onSelect, -}: OptionItemProps) { - const Theme = useTheme(); - const progress = useSharedValue(isSelected ? 1 : 0); - - useEffect(() => { - progress.value = withTiming(isSelected ? 1 : 0, { - duration: ANIMATION_DURATION, - }); - }, [isSelected, progress]); - - const animatedCardStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor( - progress.value, - [0, 1], - [ - Theme['foreground-primary'], - Theme['foreground-accent-primary-10-solid'], - ], - ), - borderColor: interpolateColor( - progress.value, - [0, 1], - [Theme['foreground-primary'], Theme['border-accent-primary']], - ), - })); - - const animatedPillStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor( - progress.value, - [0, 1], - [ - Theme['foreground-tertiary'], - Theme['foreground-accent-primary-90-solid'], - ], - ), - })); - - const animatedPillTextStyle = useAnimatedStyle(() => ({ - color: interpolateColor( - progress.value, - [0, 1], - [Theme['text-primary'], Theme['text-invert']], - ), - })); - - const amount = formatAmount( - option.amount.value, - option.amount.display.decimals, - 2, - ); - - const chainIcon = PresetsUtil.getIconLogoByName( - option.amount.display.networkName, - ); - - return ( - - ); -} - -// ----- Select Option View ----- interface SelectOptionViewProps { options: PaymentOption[]; selectedOption: PaymentOption | null; - isSigningPayment: boolean; - onSelectOption: (option: PaymentOption) => void; - onContinue: () => void; + onOptionPress: (option: PaymentOption) => void; + onInfoPress: () => void; info?: PaymentInfo; collectDataCompletedIds: string[]; + optionFeeEstimatesById: Record; + optionFeeEstimateStatusById: Record< + string, + 'idle' | 'loading' | 'ready' | 'error' + >; } export function SelectOptionView({ options, - selectedOption, - isSigningPayment, - onSelectOption, - onContinue, + onOptionPress, + onInfoPress, info, collectDataCompletedIds, + optionFeeEstimatesById, + optionFeeEstimateStatusById, }: SelectOptionViewProps) { + const Theme = useTheme(); const visibleCount = options.length; const scrollable = visibleCount > MAX_VISIBLE_OPTIONS; const listMaxHeight = @@ -174,16 +45,21 @@ export function SelectOptionView({ (MAX_VISIBLE_OPTIONS - 1) * OPTION_GAP + Spacing[4] * 2; - const handleSelectOption = useCallback( - (option: PaymentOption) => { - onSelectOption(option); - }, - [onSelectOption], + const amount = formatAmount( + info?.amount?.value || '0', + info?.amount?.display?.decimals || 0, + 2, ); + const currencySymbol = getCurrencySymbol(info?.amount?.display?.assetSymbol); return ( <> - + + + + Select a token to pay with + + {scrollable && ( @@ -198,38 +74,57 @@ export function SelectOptionView({ showsVerticalScrollIndicator={false} nestedScrollEnabled > - {options.map((option, index) => ( - - ))} + {options.map((option, index) => { + const hasCollectData = + !!(option as PaymentOptionWithCollectData).collectData?.url && + !collectDataCompletedIds.includes(option.id); + + return ( + + } + onIconRightPress={hasCollectData ? onInfoPress : undefined} + onPress={() => onOptionPress(option)} + /> + ); + })} {scrollable && } - - - - Continue - + + + Pay {currencySymbol} + {amount} to {info?.merchant?.name} + + {info?.merchant?.iconUrl ? ( + + ) : null} ); } const styles = StyleSheet.create({ + header: { + gap: Spacing[4], + alignItems: 'center', + }, optionsList: { marginTop: Spacing[3], flexGrow: 0, @@ -238,57 +133,17 @@ const styles = StyleSheet.create({ gap: OPTION_GAP, paddingVertical: Spacing[4], }, - optionItem: { + bottomContainer: { flexDirection: 'row', - alignItems: 'center', - padding: Spacing[5], - borderRadius: BorderRadius[4], - justifyContent: 'space-between', - overflow: 'hidden', - }, - optionItemOverlay: { - borderRadius: BorderRadius[4], - borderWidth: 1, - }, - optionItemContent: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing[2], - flex: 1, - }, - collectDataPill: { - paddingHorizontal: Spacing[2], - paddingVertical: 6, - borderRadius: BorderRadius[2], - marginLeft: 'auto', - }, - pillText: { - fontSize: 14, - fontFamily: FontFamily.medium, - fontWeight: '500', - letterSpacing: -0.14, - }, - optionIconContainer: { - width: Spacing[8], - height: Spacing[8], - }, - optionIcon: { - width: Spacing[8], - height: Spacing[8], - borderRadius: BorderRadius.full, - }, - optionChainIcon: { - height: 18, - width: 18, - position: 'absolute', - borderRadius: BorderRadius.full, - borderWidth: 2, - right: -2, - bottom: -2, - }, - buttonContainer: { marginTop: Spacing[5], marginBottom: Spacing[2], + alignItems: 'center', + justifyContent: 'center', gap: Spacing[2], }, + merchantIcon: { + width: 20, + height: 20, + borderRadius: BorderRadius[1], + }, }); diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx index a979da576..cca3faff3 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect } from 'react'; -import { StyleSheet } from 'react-native'; import { useSnapshot } from 'valtio'; import { useNavigation } from '@react-navigation/native'; @@ -8,10 +7,6 @@ import ModalStore from '@/store/ModalStore'; import PaymentStore from '@/store/PaymentStore'; import type { PaymentOption } from '@walletconnect/pay'; import type { PaymentOptionWithCollectData } from '@/utils/TypesUtil'; -import { useTheme } from '@/hooks/useTheme'; -import { Button } from '@/components/Button'; -import { BorderRadius } from '@/utils/ThemeUtil'; -import QuestionMark from '@/assets/QuestionMark'; import { LoadingView } from './LoadingView'; import { CollectDataWebView } from './CollectDataWebView'; @@ -22,10 +17,11 @@ import { ExpiryWarningView } from './ExpiryWarningView'; import { ResultView } from './ResultView'; import { ViewWrapper } from './ViewWrapper'; import { detectErrorType, getErrorMessage } from './utils'; +import { GasFeeView } from './GasFeeView'; +import { requiresApproval } from '@/utils/PaymentUtil'; export default function PaymentOptionsModal() { const snap = useSnapshot(PaymentStore.state); - const Theme = useTheme(); const navigation = useNavigation(); const selectedOptionCollectDataUrl = ( @@ -35,7 +31,7 @@ export default function PaymentOptionsModal() { useEffect(() => { let isActive = true; - if (snap.step === 'loading') { + const resolveLoadingStep = async () => { if (snap.errorMessage) { LogStore.error( 'Payment failed with initial error', @@ -49,39 +45,98 @@ export default function PaymentOptionsModal() { message: getErrorMessage(errorType, snap.errorMessage), errorType, }); - } else if (snap.paymentOptions) { - if ( - !snap.paymentOptions.options || - snap.paymentOptions.options.length === 0 - ) { - LogStore.warn( - 'No payment options available', - 'PaymentOptionsModal', - 'useEffect', - { paymentId: snap.paymentOptions.paymentId }, - ); - PaymentStore.setResult({ - status: 'error', - errorType: 'insufficient_funds', - message: getErrorMessage('insufficient_funds'), - }); - } else { - const options = snap.paymentOptions.options; - const firstOption = options[0] as PaymentOptionWithCollectData; - const singleOptionWithoutCollectData = - options.length === 1 && !firstOption.collectData?.url; - - if (singleOptionWithoutCollectData) { - PaymentStore.selectOption(firstOption as PaymentOption); - if (isActive) { - PaymentStore.setStep('review'); - } - PaymentStore.fetchPaymentActions(firstOption as PaymentOption); + return; + } + + if (!snap.paymentOptions) { + return; + } + + if ( + !snap.paymentOptions.options || + snap.paymentOptions.options.length === 0 + ) { + LogStore.warn( + 'No payment options available', + 'PaymentOptionsModal', + 'useEffect', + { paymentId: snap.paymentOptions.paymentId }, + ); + PaymentStore.setResult({ + status: 'error', + errorType: 'insufficient_funds', + message: getErrorMessage('insufficient_funds'), + }); + return; + } + + const options = snap.paymentOptions.options as PaymentOption[]; + const firstOption = options[0] as PaymentOptionWithCollectData; + + if (options.length === 1) { + PaymentStore.selectOption(firstOption as PaymentOption); + if (isActive) { + if (firstOption.collectData?.url) { + PaymentStore.setStep('collectData'); } else { - PaymentStore.setStep('selectOption'); + PaymentStore.setStep('review'); } } + return; + } + + try { + const lastPaidTokenUnit = await PaymentStore.loadLastPaidTokenUnit(); + if (!isActive) return; + + const preferredOption = PaymentStore.findPreferredOption( + options, + lastPaidTokenUnit, + ) as PaymentOptionWithCollectData | null; + + if (!preferredOption) { + PaymentStore.setStep('selectOption'); + return; + } + + const needsCollectData = !!preferredOption.collectData?.url; + if (needsCollectData) { + PaymentStore.selectOption(preferredOption as PaymentOption); + PaymentStore.setStep('selectOption'); + return; + } + + PaymentStore.selectOption(preferredOption as PaymentOption); + PaymentStore.setStep('review'); + } catch (error) { + LogStore.warn( + 'Failed to load last paid token', + 'PaymentOptionsModal', + 'useEffect', + { + error: + error instanceof Error ? error.message : 'unknown storage error', + }, + ); + PaymentStore.setStep('selectOption'); } + }; + + if (snap.step === 'loading') { + resolveLoadingStep().catch(error => { + LogStore.error( + 'Failed to resolve payment loading step', + 'PaymentOptionsModal', + 'useEffect', + { + error: + error instanceof Error + ? error.message + : 'unknown loading step error', + }, + ); + PaymentStore.setStep('selectOption'); + }); } return () => { @@ -117,6 +172,10 @@ export default function PaymentOptionsModal() { navigation.navigate('Scan'); }, [navigation]); + const handleInfoPress = useCallback(() => { + PaymentStore.setStep('infoExplainer'); + }, []); + const handleExpiryComplete = useCallback(() => { PaymentStore.setStep('review'); }, []); @@ -138,6 +197,9 @@ export default function PaymentOptionsModal() { case 'collectData': PaymentStore.setStep('selectOption'); break; + case 'gasFee': + PaymentStore.setStep('review'); + break; case 'review': PaymentStore.setStep('selectOption'); break; @@ -151,23 +213,24 @@ export default function PaymentOptionsModal() { const onSelectOption = useCallback((option: PaymentOption) => { PaymentStore.selectOption(option); - PaymentStore.fetchPaymentActions(option); }, []); - const handleContinue = useCallback(() => { - const { selectedOption, collectDataCompletedIds } = PaymentStore.state; - if (!selectedOption) return; + const handleSelectOption = useCallback( + (option: PaymentOption) => { + onSelectOption(option); + const { collectDataCompletedIds } = PaymentStore.state; - const option = selectedOption as PaymentOptionWithCollectData; - const needsCollectData = !!option.collectData?.url; - const alreadyCompleted = collectDataCompletedIds.includes(option.id); + const needsCollectData = !!option.collectData?.url; + const alreadyCompleted = collectDataCompletedIds.includes(option.id); - if (needsCollectData && !alreadyCompleted) { - PaymentStore.setStep('collectData'); - } else { - PaymentStore.setStep('review'); - } - }, []); + if (needsCollectData && !alreadyCompleted) { + PaymentStore.setStep('collectData'); + } else { + PaymentStore.setStep('review'); + } + }, + [onSelectOption], + ); useEffect(() => { if (snap.step === 'selectOption') { @@ -209,6 +272,21 @@ export default function PaymentOptionsModal() { /> ); + case 'gasFee': + return ( + PaymentStore.setStep('review')} + imageSource={snap.selectedOption?.amount.display?.iconUrl || ''} + tokenName={snap.selectedOption?.amount.display?.assetSymbol || ''} + gasCostEstimate={ + snap.selectedOption + ? snap.optionFeeEstimatesById[snap.selectedOption.id] + ?.display || '' + : '' + } + /> + ); + case 'collectData': return ( ); - case 'review': - return snap.selectedOption ? ( + case 'review': { + if (!snap.selectedOption) { + return null; + } + + const selectedOption = snap.selectedOption as PaymentOption; + + return ( PaymentStore.approvePayment()} + onGasFeePress={() => PaymentStore.setStep('gasFee')} + onChangeOption={ + snap.paymentOptions?.options?.length && + snap.paymentOptions?.options?.length > 1 + ? () => PaymentStore.setStep('selectOption') + : undefined + } /> - ) : null; + ); + } case 'confirming': return ( ); @@ -279,22 +375,21 @@ export default function PaymentOptionsModal() { }, [ snap.step, snap.selectedOption, - snap.isLoadingActions, snap.resultStatus, snap.resultErrorType, snap.resultMessage, snap.loadingMessage, + snap.loadingNote, snap.paymentOptions, - snap.paymentActions, - snap.approvalGasEstimate, - snap.isEstimatingApprovalGas, + snap.optionFeeEstimatesById, + snap.optionFeeEstimateStatusById, snap.collectDataCompletedIds, snap.expiresAt, selectedOptionCollectDataUrl, handleWebViewComplete, handleWebViewError, - handleContinue, - onSelectOption, + handleSelectOption, + handleInfoPress, onClose, onScanQR, handleExpiryComplete, @@ -302,29 +397,17 @@ export default function PaymentOptionsModal() { ]); const paymentOptionsCount = snap.paymentOptions?.options?.length ?? 0; - const isCollectDataNeeded = snap.paymentOptions?.collectData?.url; const showBackButton = snap.step === 'collectData' || snap.step === 'infoExplainer' || - (snap.step === 'review' && paymentOptionsCount > 1); + snap.step === 'gasFee' || + (snap.step === 'review' && + paymentOptionsCount > 1 && + (snap.previousStep === 'selectOption' || + snap.previousStep === 'collectData')); const isWebView = snap.step === 'collectData' && !!selectedOptionCollectDataUrl; - const headerLeftContent = - snap.step === 'selectOption' && isCollectDataNeeded ? ( - - ) : undefined; - return ( {renderContent()} ); } - -const styles = StyleSheet.create({ - questionButton: { - width: 38, - height: 38, - borderRadius: BorderRadius[3], - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx b/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx index 1cc93ed8e..fe827fba9 100644 --- a/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx +++ b/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx @@ -20,7 +20,11 @@ import { TON_CHAINS, TON_SIGNING_METHODS } from '@/constants/Ton'; import { getWallet, tonAddresses } from '@/utils/TonWalletUtil'; import { tronAddresses } from '@/utils/TronWalletUtil'; import { TRON_CHAINS, TRON_SIGNING_METHODS } from '@/constants/Tron'; -import { CANTON_CHAINS, CANTON_SIGNING_METHODS, CANTON_EVENTS } from '@/constants/Canton'; +import { + CANTON_CHAINS, + CANTON_SIGNING_METHODS, + CANTON_EVENTS, +} from '@/constants/Canton'; import { cantonAddresses } from '@/utils/CantonWalletUtil'; import { AccordionCard } from '@/components/AccordionCard'; import { AppInfoCard } from '@/components/AppInfoCard'; diff --git a/wallets/rn_cli_wallet/src/screens/LogList/components/LogFilterSheet.tsx b/wallets/rn_cli_wallet/src/screens/LogList/components/LogFilterSheet.tsx index 8b24768f1..1d2de8f88 100644 --- a/wallets/rn_cli_wallet/src/screens/LogList/components/LogFilterSheet.tsx +++ b/wallets/rn_cli_wallet/src/screens/LogList/components/LogFilterSheet.tsx @@ -39,9 +39,7 @@ export function LogFilterSheet({ statusBarTranslucent style={styles.modal} > - + @@ -54,11 +52,7 @@ export function LogFilterSheet({ { borderColor: Theme['border-secondary'] }, ]} > - + @@ -70,6 +64,7 @@ export function LogFilterSheet({ onPress={() => onSelect(option.value)} style={[ styles.item, + //eslint-disable-next-line react-native/no-inline-styles { backgroundColor: isSelected ? Theme['foreground-accent-primary-10'] diff --git a/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx b/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx index 68edbb66b..f71aba344 100644 --- a/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx @@ -114,9 +114,8 @@ function SecretSection({ } export default function SecretPhrase() { - const { eip155Address, suiWallet, tonWallet, tronWallet, cantonWallet } = useSnapshot( - SettingsStore.state, - ); + const { eip155Address, suiWallet, tonWallet, tronWallet, cantonWallet } = + useSnapshot(SettingsStore.state); const Theme = useTheme(); // Get EVM mnemonic diff --git a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx index 7632b3a67..17caa9af9 100644 --- a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx @@ -97,7 +97,9 @@ export default function Settings() { copyToClipboard(clientId)} /> - navigation.navigate('Logs')} - /> + navigation.navigate('Logs')} /> ); diff --git a/wallets/rn_cli_wallet/src/services/ERC20BalanceService.ts b/wallets/rn_cli_wallet/src/services/ERC20BalanceService.ts index ef6f229f1..e34b35f92 100644 --- a/wallets/rn_cli_wallet/src/services/ERC20BalanceService.ts +++ b/wallets/rn_cli_wallet/src/services/ERC20BalanceService.ts @@ -3,7 +3,9 @@ import Config from 'react-native-config'; import { TokenBalance } from '@/utils/BalanceTypes'; import LogStore, { serializeError } from '@/store/LogStore'; -const ERC20_BALANCE_OF_ABI = ['function balanceOf(address) view returns (uint256)']; +const ERC20_BALANCE_OF_ABI = [ + 'function balanceOf(address) view returns (uint256)', +]; interface ERC20TokenConfig { name: string; @@ -57,7 +59,11 @@ async function fetchSingleERC20Balance( try { const provider = new providers.JsonRpcProvider(rpcUrl); - const contract = new Contract(token.address, ERC20_BALANCE_OF_ABI, provider); + const contract = new Contract( + token.address, + ERC20_BALANCE_OF_ABI, + provider, + ); const rawBalance = await contract.balanceOf(walletAddress); const numeric = utils.formatUnits(rawBalance, token.decimals); @@ -89,7 +95,9 @@ export async function fetchERC20Balances( walletAddress: string, ): Promise { const calls = ERC20_TOKENS.flatMap(token => - token.chainIds.map(chainId => fetchSingleERC20Balance(walletAddress, token, chainId)), + token.chainIds.map(chainId => + fetchSingleERC20Balance(walletAddress, token, chainId), + ), ); const results = await Promise.allSettled(calls); diff --git a/wallets/rn_cli_wallet/src/store/PaymentStore.ts b/wallets/rn_cli_wallet/src/store/PaymentStore.ts index e45e836e6..91160bf47 100644 --- a/wallets/rn_cli_wallet/src/store/PaymentStore.ts +++ b/wallets/rn_cli_wallet/src/store/PaymentStore.ts @@ -11,6 +11,7 @@ import LogStore, { serializeError } from '@/store/LogStore'; import SettingsStore from '@/store/SettingsStore'; import { walletKit } from '@/utils/WalletKitUtil'; import { eip155Wallets } from '@/utils/EIP155WalletUtil'; +import { storage } from '@/utils/storage'; import type { Step } from '@/utils/TypesUtil'; import { detectErrorType, @@ -24,44 +25,47 @@ import { sendTransactionWithFreshFees, waitForTransactionConfirmation, } from '@/utils/PaymentTransactionUtil'; -import { getPaymentContext } from '@/utils/PaymentUtil'; +import type { TransactionFeeEstimate } from '@/utils/PaymentTransactionUtil'; +import { getApprovalAction } from '@/utils/PaymentUtil'; + +type OptionFeeEstimateStatus = 'idle' | 'loading' | 'ready' | 'error'; interface PaymentState { paymentOptions: PaymentOptionsResponse | null; loadingMessage: string | null; + loadingNote: string | null; errorMessage: string | null; step: Step; + previousStep: Step | null; resultStatus: 'success' | 'error'; resultMessage: string; resultErrorType: ErrorType | null; selectedOption: PaymentOption | null; - paymentActions: Action[] | null; - isLoadingActions: boolean; - isEstimatingApprovalGas: boolean; - actionsError: string | null; - approvalGasEstimate: string | null; + optionFeeEstimatesById: Record; + optionFeeEstimateStatusById: Record; collectDataCompletedIds: string[]; expiresAt: number | null; } const PAY_EXPIRY_GUARD_MS = 10_000; const FAILED_CONFIRMATION_MESSAGE = 'The payment could not be confirmed.'; +const PAY_LAST_TOKEN_UNIT_KEY = 'PAY_LAST_TOKEN_UNIT'; +const DEFAULT_FIAT_CURRENCY = 'USD'; function createInitialState(): PaymentState { return { paymentOptions: null, loadingMessage: null, + loadingNote: null, errorMessage: null, step: 'loading', + previousStep: null, resultStatus: 'success', resultMessage: '', resultErrorType: null, selectedOption: null, - paymentActions: null, - isLoadingActions: false, - isEstimatingApprovalGas: false, - actionsError: null, - approvalGasEstimate: null, + optionFeeEstimatesById: {}, + optionFeeEstimateStatusById: {}, collectDataCompletedIds: [], expiresAt: null, }; @@ -70,6 +74,7 @@ function createInitialState(): PaymentState { const state = proxy(createInitialState()); let expiryTimerId: ReturnType | null = null; let paymentActionsRequestSeq = 0; +let optionFeeEstimateRequestSeq = 0; function isPaymentExpiredLocally(expiresAt: number | null): boolean { if (!expiresAt) return false; @@ -126,10 +131,15 @@ function setPaymentResultFromConfirmStatus({ return; } - LogStore.warn('Unhandled final payment status', 'PaymentStore', 'approvePayment', { - status: confirmResult.status, - isFinal: confirmResult.isFinal, - }); + LogStore.warn( + 'Unhandled final payment status', + 'PaymentStore', + 'approvePayment', + { + status: confirmResult.status, + isFinal: confirmResult.isFinal, + }, + ); PaymentStore.setResult({ status: 'error', @@ -147,6 +157,8 @@ const PaymentStore = { errorMessage?: string; }) { PaymentStore.clearExpiryTimer(); + paymentActionsRequestSeq += 1; + optionFeeEstimateRequestSeq += 1; Object.assign(state, createInitialState()); if (params.paymentOptions) { state.paymentOptions = ref(params.paymentOptions); @@ -158,8 +170,11 @@ const PaymentStore = { setPaymentOptions(options: PaymentOptionsResponse) { state.paymentOptions = ref(options); state.loadingMessage = null; + state.loadingNote = null; state.errorMessage = null; state.resultErrorType = null; + state.optionFeeEstimatesById = {}; + state.optionFeeEstimateStatusById = {}; const expiresAt = options.info?.expiresAt; if (typeof expiresAt === 'number' && expiresAt > 0) { @@ -169,12 +184,15 @@ const PaymentStore = { PaymentStore.clearExpiryTimer(); state.expiresAt = null; } + + PaymentStore.preloadOptionFeeEstimates(options); }, setError(errorMessage: string) { const errorType = detectErrorType(errorMessage); state.errorMessage = errorMessage; state.loadingMessage = null; + state.loadingNote = null; state.resultStatus = 'error'; state.resultMessage = getErrorMessage(errorType, errorMessage); state.resultErrorType = errorType; @@ -183,10 +201,16 @@ const PaymentStore = { reset() { PaymentStore.clearExpiryTimer(); + paymentActionsRequestSeq += 1; + optionFeeEstimateRequestSeq += 1; Object.assign(state, createInitialState()); }, setStep(step: Step) { + if (state.step === step) { + return; + } + state.previousStep = state.step; state.step = step; }, @@ -200,23 +224,16 @@ const PaymentStore = { state.resultErrorType = payload.errorType ?? null; state.errorMessage = null; state.loadingMessage = null; + state.loadingNote = null; state.step = 'result'; }, selectOption(option: PaymentOption) { state.selectedOption = ref(option); - state.paymentActions = null; - state.actionsError = null; - state.approvalGasEstimate = null; - state.isEstimatingApprovalGas = false; }, clearSelectedOption() { state.selectedOption = null; - state.paymentActions = null; - state.actionsError = null; - state.approvalGasEstimate = null; - state.isEstimatingApprovalGas = false; }, markCollectDataCompleted(optionId: string) { @@ -229,18 +246,32 @@ const PaymentStore = { return state.collectDataCompletedIds.includes(optionId); }, - setPaymentActions(actions: Action[]) { - state.paymentActions = ref(actions); - state.approvalGasEstimate = null; - state.isEstimatingApprovalGas = false; + getOptionFeeEstimate(optionId: string): TransactionFeeEstimate | null { + return state.optionFeeEstimatesById[optionId] ?? null; + }, + + getOptionFeeEstimateStatus(optionId: string): OptionFeeEstimateStatus { + return state.optionFeeEstimateStatusById[optionId] ?? 'idle'; }, - setLoadingActions(loading: boolean) { - state.isLoadingActions = loading; + async loadLastPaidTokenUnit(): Promise { + return storage.getItem(PAY_LAST_TOKEN_UNIT_KEY); }, - setActionsError(error: string | null) { - state.actionsError = error; + async saveLastPaidTokenUnit(unit: string): Promise { + await storage.setItem(PAY_LAST_TOKEN_UNIT_KEY, unit); + }, + + async clearLastPaidTokenUnit(): Promise { + await storage.removeItem(PAY_LAST_TOKEN_UNIT_KEY); + }, + + findPreferredOption( + options: readonly PaymentOption[], + tokenUnit?: string, + ): PaymentOption | null { + if (!tokenUnit) return null; + return options.find(option => option.amount.unit === tokenUnit) ?? null; }, startExpiryTimer(expiresAt: number) { @@ -255,6 +286,7 @@ const PaymentStore = { 'review', 'collectData', 'infoExplainer', + 'gasFee', ]; if (delay <= 0) { @@ -278,28 +310,85 @@ const PaymentStore = { } }, - async fetchPaymentActions(option: PaymentOption) { + async preloadOptionFeeEstimates(options: PaymentOptionsResponse) { + const requestSeq = ++optionFeeEstimateRequestSeq; + const paymentId = options.paymentId; + + await Promise.allSettled( + options.options.map(async option => { + const approvalAction = getApprovalAction(option.actions); + + if (!approvalAction) { + state.optionFeeEstimateStatusById[option.id] = 'ready'; + return; + } + + state.optionFeeEstimateStatusById[option.id] = 'loading'; + + try { + const estimate = await estimateTransactionFee(approvalAction, { + currency: + options.info?.amount?.display?.assetSymbol ?? + DEFAULT_FIAT_CURRENCY, + }); + + if ( + requestSeq !== optionFeeEstimateRequestSeq || + state.paymentOptions?.paymentId !== paymentId + ) { + return; + } + + if (estimate) { + state.optionFeeEstimatesById[option.id] = estimate; + } + state.optionFeeEstimateStatusById[option.id] = 'ready'; + + LogStore.log( + 'Option approval gas estimate resolved', + 'PaymentStore', + 'preloadOptionFeeEstimates', + { + optionId: option.id, + chainId: approvalAction.walletRpc?.chainId, + estimate, + }, + ); + } catch (error) { + if ( + requestSeq !== optionFeeEstimateRequestSeq || + state.paymentOptions?.paymentId !== paymentId + ) { + return; + } + + state.optionFeeEstimateStatusById[option.id] = 'error'; + + LogStore.warn( + 'Failed to estimate option approval gas fee', + 'PaymentStore', + 'preloadOptionFeeEstimates', + { + optionId: option.id, + chainId: approvalAction.walletRpc?.chainId, + error: serializeError(error), + }, + ); + } + }), + ); + }, + + async fetchPaymentActions(option: PaymentOption): Promise { const payClient = walletKit?.pay; if (!payClient || !state.paymentOptions) { - LogStore.error( - 'Pay SDK not initialized', - 'PaymentStore', - 'fetchPaymentActions', - ); - state.actionsError = 'Pay SDK not initialized'; - return; + const errorMessage = 'Pay SDK not initialized'; + LogStore.error(errorMessage, 'PaymentStore', 'fetchPaymentActions'); + throw new Error(errorMessage); } - state.isLoadingActions = true; - state.actionsError = null; - state.approvalGasEstimate = null; - state.isEstimatingApprovalGas = false; const requestSeq = ++paymentActionsRequestSeq; - const isStaleRequest = () => - requestSeq !== paymentActionsRequestSeq || - state.selectedOption?.id !== option.id; - try { LogStore.log( 'Getting required payment actions', @@ -320,70 +409,32 @@ const PaymentStore = { { actionsCount: actions.length }, ); - if (isStaleRequest()) { + if ( + requestSeq !== paymentActionsRequestSeq || + state.selectedOption?.id !== option.id + ) { LogStore.warn( 'Skipping stale payment actions response', 'PaymentStore', 'fetchPaymentActions', { optionId: option.id }, ); - return; + return []; } - const paymentContext = getPaymentContext({ - paymentActions: actions, - }); - state.paymentActions = ref(actions); - state.isLoadingActions = false; - - LogStore.log('Resolved payment context', 'PaymentStore', 'fetchPaymentActions', { - optionId: option.id, - requiresApproval: paymentContext.requiresApproval, - }); - - if (paymentContext.approvalAction) { - state.isEstimatingApprovalGas = true; - try { - const estimate = await estimateTransactionFee(paymentContext.approvalAction); - if (!isStaleRequest()) { - state.approvalGasEstimate = estimate; - } - LogStore.log( - 'Approval gas estimate resolved', - 'PaymentStore', - 'fetchPaymentActions', - { - optionId: option.id, - chainId: paymentContext.approvalAction.walletRpc?.chainId, - estimate, - }, - ); - } catch (error) { - LogStore.warn( - 'Failed to estimate approval gas fee', - 'PaymentStore', - 'fetchPaymentActions', - { - optionId: option.id, - chainId: paymentContext.approvalAction.walletRpc?.chainId, - error: serializeError(error), - }, - ); - } finally { - if (!isStaleRequest()) { - state.isEstimatingApprovalGas = false; - } - } - } + return actions; } catch (error: any) { - if (isStaleRequest()) { + if ( + requestSeq !== paymentActionsRequestSeq || + state.selectedOption?.id !== option.id + ) { LogStore.warn( 'Skipping stale payment actions error', 'PaymentStore', 'fetchPaymentActions', { optionId: option.id, error: error?.message }, ); - return; + return []; } LogStore.error( @@ -393,15 +444,7 @@ const PaymentStore = { { error: error?.message }, ); const errorMessage = error?.message || 'Failed to get payment actions'; - const errorType = detectErrorType(errorMessage); - state.resultStatus = 'error'; - state.resultMessage = getErrorMessage(errorType, errorMessage); - state.resultErrorType = errorType; - state.step = 'result'; - } finally { - if (requestSeq === paymentActionsRequestSeq && state.isLoadingActions) { - state.isLoadingActions = false; - } + throw error instanceof Error ? error : new Error(errorMessage); } }, @@ -415,19 +458,13 @@ const PaymentStore = { return; } - const { - paymentActions, - selectedOption, - paymentOptions, - expiresAt, - } = state; - if (!paymentActions?.length || !selectedOption || !paymentOptions) { + const { selectedOption, paymentOptions, expiresAt } = state; + if (!selectedOption || !paymentOptions) { LogStore.warn( 'Cannot approve payment - missing required state', 'PaymentStore', 'approvePayment', { - hasPaymentActions: !!paymentActions?.length, hasSelectedOption: !!selectedOption, hasPaymentData: !!paymentOptions, }, @@ -455,9 +492,19 @@ const PaymentStore = { return; } + const tokenSymbol = selectedOption.amount.display.assetSymbol || 'token'; + const selectedOptionApprovalAction = getApprovalAction( + selectedOption.actions, + ); + state.step = 'confirming'; - state.actionsError = null; - state.loadingMessage = null; + if (selectedOptionApprovalAction) { + state.loadingMessage = `Setting up ${tokenSymbol} for one-time setup...`; + state.loadingNote = `Future ${tokenSymbol} payments will be instant`; + } else { + state.loadingMessage = null; + state.loadingNote = null; + } try { const payClient = walletKit?.pay; @@ -470,12 +517,15 @@ const PaymentStore = { throw new Error('Wallet not found for selected EIP155 account'); } - const tokenSymbol = selectedOption.amount.display.assetSymbol || 'token'; const signatures: string[] = []; + const paymentActions = await PaymentStore.fetchPaymentActions( + selectedOption, + ); + if (!paymentActions.length) { + throw new Error('No payment actions returned for the selected option'); + } const totalActions = paymentActions.length; - const paymentContext = getPaymentContext({ - paymentActions, - }); + const approvalAction = getApprovalAction(paymentActions); for (const [index, action] of paymentActions.entries()) { const stepLabel = `${index + 1}/${totalActions}`; @@ -485,31 +535,34 @@ const PaymentStore = { throw new Error(`Payment action ${stepLabel} is missing walletRpc`); } - if ( - paymentContext.approvalAction && - action === paymentContext.approvalAction - ) { - state.loadingMessage = `Setting up ${tokenSymbol} for the first time...`; - } else if ( - method === EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA || - method === EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V3 || - method === EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4 - ) { - state.loadingMessage = 'Finalizing your payment...'; + if (approvalAction && action === approvalAction) { + state.loadingMessage = `Setting up ${tokenSymbol} for one-time setup...`; + state.loadingNote = `Future ${tokenSymbol} payments will be instant`; + } else { + state.loadingMessage = null; + state.loadingNote = null; } - LogStore.log('Executing payment action', 'PaymentStore', 'approvePayment', { - step: stepLabel, - method, - }); + LogStore.log( + 'Executing payment action', + 'PaymentStore', + 'approvePayment', + { + step: stepLabel, + method, + }, + ); const { params, chainId } = action.walletRpc; let parsedParams: unknown; try { - parsedParams = typeof params === 'string' ? JSON.parse(params) : params; + parsedParams = + typeof params === 'string' ? JSON.parse(params) : params; } catch (error) { throw new Error( - `Failed to parse params for ${method} (${stepLabel}): ${error instanceof Error ? error.message : String(error)}`, + `Failed to parse params for ${method} (${stepLabel}): ${ + error instanceof Error ? error.message : String(error) + }`, ); } @@ -523,7 +576,9 @@ const PaymentStore = { case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION: { const txPayload = parsedParams[0]; if (!txPayload || typeof txPayload !== 'object') { - throw new Error(`Invalid tx payload for ${method} (${stepLabel})`); + throw new Error( + `Invalid tx payload for ${method} (${stepLabel})`, + ); } const tx = await sendTransactionWithFreshFees({ @@ -564,18 +619,27 @@ const PaymentStore = { case EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4: { let typedData: unknown = parsedParams[1]; try { - if (typeof typedData === 'string') typedData = JSON.parse(typedData); + if (typeof typedData === 'string') + typedData = JSON.parse(typedData); } catch (error) { throw new Error( - `Failed to parse typed-data for ${method} (${stepLabel}): ${error instanceof Error ? error.message : String(error)}`, + `Failed to parse typed-data for ${method} (${stepLabel}): ${ + error instanceof Error ? error.message : String(error) + }`, ); } if (!typedData || typeof typedData !== 'object') { - throw new Error(`Invalid typed-data for ${method} (${stepLabel})`); + throw new Error( + `Invalid typed-data for ${method} (${stepLabel})`, + ); } - const { domain, types, message: messageData } = typedData as { + const { + domain, + types, + message: messageData, + } = typedData as { domain: Record; types: Record>>; message: Record; @@ -627,6 +691,19 @@ const PaymentStore = { selectedOption, paymentOptions, }); + + if (confirmResult.status === 'succeeded') { + try { + await PaymentStore.saveLastPaidTokenUnit(selectedOption.amount.unit); + } catch (error) { + LogStore.warn( + 'Failed to persist last paid token', + 'PaymentStore', + 'approvePayment', + { error: serializeError(error) }, + ); + } + } } catch (error: unknown) { LogStore.error( 'Error executing payment actions', diff --git a/wallets/rn_cli_wallet/src/utils/PaymentTransactionUtil.ts b/wallets/rn_cli_wallet/src/utils/PaymentTransactionUtil.ts index 05ee9e826..355b44d99 100644 --- a/wallets/rn_cli_wallet/src/utils/PaymentTransactionUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/PaymentTransactionUtil.ts @@ -7,8 +7,14 @@ import { PresetsUtil } from '@/utils/PresetsUtil'; const POLYGON_MIN_PRIORITY_FEE_WEI = BigNumber.from('30000000000'); // 30 gwei const WALLETCONNECT_RPC_BASE_URL = 'https://rpc.walletconnect.org/v1/'; +const WALLETCONNECT_FUNGIBLE_PRICE_URL = + 'https://rpc.walletconnect.org/v1/fungible/price'; +const NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; const TX_CONFIRMATION_TIMEOUT_MS = 120_000; const GAS_ESTIMATION_RPC_TIMEOUT_MS = 15_000; +const PRICE_ESTIMATION_TIMEOUT_MS = 10_000; +const PRICE_CACHE_TTL_MS = 60_000; +const DEFAULT_FIAT_CURRENCY = 'USD'; const NATIVE_SYMBOL_BY_CHAIN_ID: Record = { 'eip155:1': 'ETH', @@ -35,10 +41,38 @@ const NATIVE_SYMBOL_BY_CHAIN_ID: Record = { 'eip155:143': 'MON', }; +const SUPPORTED_FIAT_CURRENCIES = new Set(['USD', 'EUR']); + +type FungiblePriceResponse = { + fungibles?: Array<{ + address?: string; + price?: number; + symbol?: string; + }>; +}; + +type CachedPrice = { + price: number; + expiresAt: number; +}; + +const nativePriceCache = new Map(); +const nativePriceRequestCache = new Map< + string, + Promise<{ price: number; currency: string } | null> +>(); + +export type TransactionFeeEstimate = { + display: string; + nativeDisplay: string; + fiatValue: number | null; + fiatCurrency: string | null; + chainId: string; + nativeSymbol: string; +}; + export type TransactionWallet = { - connect: ( - provider: providers.JsonRpcProvider, - ) => + connect: (provider: providers.JsonRpcProvider) => | providers.JsonRpcSigner | { sendTransaction: ( @@ -70,7 +104,9 @@ function getWalletConnectRpcUrl(chainId: string): string | null { return null; } - return `${WALLETCONNECT_RPC_BASE_URL}?chainId=${encodeURIComponent(chainId)}&projectId=${encodeURIComponent(projectId)}`; + return `${WALLETCONNECT_RPC_BASE_URL}?chainId=${encodeURIComponent( + chainId, + )}&projectId=${encodeURIComponent(projectId)}`; } function getHighestBigNumber( @@ -91,6 +127,108 @@ function toBigNumber(value: unknown): BigNumber | null { } } +function normalizeFiatCurrency(currency?: string): string { + const normalized = currency?.trim().toUpperCase(); + if (normalized && SUPPORTED_FIAT_CURRENCIES.has(normalized)) { + return normalized; + } + return DEFAULT_FIAT_CURRENCY; +} + +function getNativeFungibleAddress(chainId: string): string { + return `${chainId}:${NATIVE_TOKEN_ADDRESS}`; +} + +async function fetchNativeTokenPrice({ + chainId, + currency, +}: { + chainId: string; + currency?: string; +}): Promise<{ price: number; currency: string } | null> { + const projectId = Config.ENV_PROJECT_ID?.trim(); + if (!projectId) { + return null; + } + + const fiatCurrency = normalizeFiatCurrency(currency); + const cacheKey = `${fiatCurrency}:${chainId}`; + const cachedPrice = nativePriceCache.get(cacheKey); + if (cachedPrice && cachedPrice.expiresAt > Date.now()) { + return { price: cachedPrice.price, currency: fiatCurrency }; + } + + const pendingRequest = nativePriceRequestCache.get(cacheKey); + if (pendingRequest) { + return pendingRequest; + } + + const nativeAddress = getNativeFungibleAddress(chainId); + + const request = (async () => { + try { + const response = await withTimeout( + fetch(WALLETCONNECT_FUNGIBLE_PRICE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + projectId, + currency: fiatCurrency.toLowerCase(), + addresses: [nativeAddress], + }), + }), + PRICE_ESTIMATION_TIMEOUT_MS, + `fungible price timed out after ${PRICE_ESTIMATION_TIMEOUT_MS}ms`, + ); + + if (!response.ok) { + LogStore.warn( + 'Native token price request failed', + 'PaymentTransactionUtil', + 'fetchNativeTokenPrice', + { chainId, currency: fiatCurrency, status: response.status }, + ); + return null; + } + + const data = (await response.json()) as FungiblePriceResponse; + const fungible = data.fungibles?.find( + item => item.address?.toLowerCase() === nativeAddress.toLowerCase(), + ); + const price = fungible?.price; + if (!Number.isFinite(price) || !price || price <= 0) { + return null; + } + + nativePriceCache.set(cacheKey, { + price, + expiresAt: Date.now() + PRICE_CACHE_TTL_MS, + }); + + return { price, currency: fiatCurrency }; + } catch (error) { + LogStore.warn( + 'Failed to fetch native token price', + 'PaymentTransactionUtil', + 'fetchNativeTokenPrice', + { + chainId, + currency: fiatCurrency, + error: serializeError(error), + }, + ); + return null; + } + })(); + + nativePriceRequestCache.set(cacheKey, request); + return request.finally(() => { + nativePriceRequestCache.delete(cacheKey); + }); +} + export function createPayRpcProvider( chainId: string, ): providers.StaticJsonRpcProvider { @@ -201,11 +339,13 @@ function serializeTxRequestForLog(tx: providers.TransactionRequest) { maxFeePerGas: asString(tx.maxFeePerGas), maxPriorityFeePerGas: asString(tx.maxPriorityFeePerGas), dataLength: - typeof tx.data === 'string' ? Math.max(0, (tx.data.length - 2) / 2) : null, + typeof tx.data === 'string' + ? Math.max(0, (tx.data.length - 2) / 2) + : null, }; } -function formatGasEstimate({ +function formatNativeGasEstimate({ totalFeeWei, chainId, }: { @@ -220,10 +360,64 @@ function formatGasEstimate({ } if (feeValue >= 0.01) { - return `~${feeValue.toFixed(4)} ${symbol}`; + return `${feeValue.toFixed(4)} ${symbol}`; + } + + return `${feeValue.toFixed(6)} ${symbol}`; +} + +function formatFiatGasEstimate({ + fiatValue, + currency, +}: { + fiatValue: number; + currency: string; +}): string { + const symbol = currency === 'EUR' ? '€' : '$'; + + if (!Number.isFinite(fiatValue) || fiatValue <= 0) { + return `${symbol}0.00`; + } + + if (fiatValue < 0.01) { + return `<${symbol}0.01`; } - return `~${feeValue.toFixed(6)} ${symbol}`; + return `${symbol}${fiatValue.toFixed(2)}`; +} + +function buildGasEstimate({ + totalFeeWei, + chainId, + nativeTokenPrice, +}: { + totalFeeWei: BigNumber; + chainId: string; + nativeTokenPrice: { price: number; currency: string } | null; +}): TransactionFeeEstimate { + const nativeSymbol = NATIVE_SYMBOL_BY_CHAIN_ID[chainId] || 'ETH'; + const nativeDisplay = formatNativeGasEstimate({ totalFeeWei, chainId }); + const nativeValue = Number(utils.formatEther(totalFeeWei)); + const fiatCurrency = nativeTokenPrice?.currency ?? null; + const fiatValue = + nativeTokenPrice && Number.isFinite(nativeValue) && nativeValue > 0 + ? nativeValue * nativeTokenPrice.price + : null; + + return { + display: + fiatValue !== null + ? formatFiatGasEstimate({ + fiatValue, + currency: fiatCurrency ?? DEFAULT_FIAT_CURRENCY, + }) + : nativeDisplay, + nativeDisplay, + fiatValue, + fiatCurrency, + chainId, + nativeSymbol, + }; } function withTimeout( @@ -248,7 +442,8 @@ function withTimeout( export async function estimateTransactionFee( action: Action, -): Promise { + options: { currency?: string } = {}, +): Promise { const { walletRpc } = action; if (!walletRpc?.params) { return null; @@ -306,7 +501,16 @@ export async function estimateTransactionFee( } const totalFeeWei = gasLimit.mul(feePerGas); - return formatGasEstimate({ totalFeeWei, chainId: walletRpc.chainId }); + const nativeTokenPrice = await fetchNativeTokenPrice({ + chainId: walletRpc.chainId, + currency: options.currency, + }); + + return buildGasEstimate({ + totalFeeWei, + chainId: walletRpc.chainId, + nativeTokenPrice, + }); } export async function sendTransactionWithFreshFees({ @@ -344,15 +548,10 @@ export async function sendTransactionWithFreshFees({ ); } - LogStore.log( - 'Submitting transaction', - 'PaymentTransactionUtil', - logContext, - { - chainId, - tx: serializeTxRequestForLog(txRequest), - }, - ); + LogStore.log('Submitting transaction', 'PaymentTransactionUtil', logContext, { + chainId, + tx: serializeTxRequestForLog(txRequest), + }); try { return await connectedWallet.sendTransaction(txRequest); diff --git a/wallets/rn_cli_wallet/src/utils/PaymentUtil.ts b/wallets/rn_cli_wallet/src/utils/PaymentUtil.ts index 05355e3e1..3dfaa7c9e 100644 --- a/wallets/rn_cli_wallet/src/utils/PaymentUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/PaymentUtil.ts @@ -2,29 +2,18 @@ import type { Action } from '@walletconnect/pay'; import { EIP155_SIGNING_METHODS } from '@/constants/Eip155'; -export type PaymentContext = { - approvalAction: Action | null; - requiresApproval: boolean; -}; - -function getApprovalAction(actions: readonly Action[] | null): Action | null { +export function getApprovalAction( + actions: readonly Action[] | null, +): Action | null { return ( actions?.find( action => - action.walletRpc?.method === EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, + action.walletRpc?.method === + EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, ) || null ); } -export function getPaymentContext({ - paymentActions, -}: { - paymentActions: readonly Action[] | null; -}): PaymentContext { - const approvalAction = getApprovalAction(paymentActions); - - return { - approvalAction, - requiresApproval: !!approvalAction, - }; +export function requiresApproval(actions: readonly Action[] | null): boolean { + return !!getApprovalAction(actions); } diff --git a/wallets/rn_cli_wallet/src/utils/TypesUtil.ts b/wallets/rn_cli_wallet/src/utils/TypesUtil.ts index 95040148e..7b8ad4748 100644 --- a/wallets/rn_cli_wallet/src/utils/TypesUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/TypesUtil.ts @@ -96,6 +96,7 @@ export type Step = | 'loading' | 'selectOption' | 'infoExplainer' + | 'gasFee' | 'collectData' | 'review' | 'confirming' From e1750116e75f59e813af5e20ed2d0194713c8408 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:09:56 -0300 Subject: [PATCH 02/15] fix(rn_cli_wallet): correct gas fee labeling on payment review screen Gate the "(incl. gas fee)" badge on whether the fee was actually combined into the total, and use the selected option's token symbol in the gas-fee explainer link instead of a hardcoded "USDT". Also extracts OptionFeeEstimateStatus into TypesUtil and drops the unused selectedOption prop on SelectOptionView. Co-Authored-By: Claude Opus 4.7 --- .../modals/PaymentOptionsModal/ReviewPaymentView.tsx | 5 +++-- .../modals/PaymentOptionsModal/SelectOptionView.tsx | 11 +++++------ .../src/modals/PaymentOptionsModal/index.tsx | 1 - wallets/rn_cli_wallet/src/store/PaymentStore.ts | 4 +--- wallets/rn_cli_wallet/src/utils/TypesUtil.ts | 2 ++ 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx index ddd6cc830..930cdf5e7 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx @@ -90,7 +90,7 @@ export function ReviewPaymentView({ accessibilityLabel={`Pay ${currencySymbol}${totalPayAmount}`} > {`Pay ${currencySymbol}${totalPayAmount}`}{' '} - {gasCostEstimate && ( + {canIncludeGasInTotal && ( (incl. gas fee) @@ -105,7 +105,8 @@ export function ReviewPaymentView({ style={styles.feeText} onPress={onGasFeePress} > - Why does USDT require a gas fee? + Why does {selectedOption.amount.display.assetSymbol?.toUpperCase()}{' '} + require a gas fee? )} diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx index b096b813e..e7e4148b4 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx @@ -5,7 +5,10 @@ import { FadeGradient } from '@/components/FadeGradient'; import { Spacing, BorderRadius } from '@/utils/ThemeUtil'; import { Text } from '@/components/Text'; import { formatAmount, getCurrencySymbol } from './utils'; -import type { PaymentOptionWithCollectData } from '@/utils/TypesUtil'; +import type { + OptionFeeEstimateStatus, + PaymentOptionWithCollectData, +} from '@/utils/TypesUtil'; import { OptionItem, OPTION_HEIGHT } from '@/components/OptionItem'; import Info from '@/assets/Info'; import SvgSelectToken from '@/assets/SelectToken'; @@ -16,16 +19,12 @@ const MAX_VISIBLE_OPTIONS = 4; interface SelectOptionViewProps { options: PaymentOption[]; - selectedOption: PaymentOption | null; onOptionPress: (option: PaymentOption) => void; onInfoPress: () => void; info?: PaymentInfo; collectDataCompletedIds: string[]; optionFeeEstimatesById: Record; - optionFeeEstimateStatusById: Record< - string, - 'idle' | 'loading' | 'ready' | 'error' - >; + optionFeeEstimateStatusById: Record; } export function SelectOptionView({ diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx index cca3faff3..e92e53d24 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx @@ -301,7 +301,6 @@ export default function PaymentOptionsModal() { Date: Wed, 29 Apr 2026 12:39:00 -0300 Subject: [PATCH 03/15] fix(rn_cli_wallet): prevent loading message clipping when note is shown The message container had a fixed minHeight of 48 with overflow:hidden, but its child uses absoluteFill, so it couldn't grow to fit a wrapped message plus the note. Bump the base minHeight and apply a taller variant when a note is present. Co-Authored-By: Claude Opus 4.7 --- .../src/modals/PaymentOptionsModal/LoadingView.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx index a27ca095c..2b47af9c3 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx @@ -32,7 +32,9 @@ export function LoadingView({ message, note, size = 120 }: LoadingViewProps) { return ( - + Date: Wed, 29 Apr 2026 13:00:23 -0300 Subject: [PATCH 04/15] chore: changed show fee button condition --- .../src/modals/PaymentOptionsModal/ReviewPaymentView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx index 930cdf5e7..261350ee4 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx @@ -96,7 +96,7 @@ export function ReviewPaymentView({ )} - {requiresApproval && approvalGasEstimate && onGasFeePress && ( + {requiresApproval && onGasFeePress && ( Date: Thu, 30 Apr 2026 13:29:53 +0200 Subject: [PATCH 05/15] chore: added custom id for info required icon and point maestro tests to new branch --- .github/workflows/ci_e2e_walletkit.yaml | 4 ++-- wallets/rn_cli_wallet/src/components/OptionItem.tsx | 4 +++- .../src/modals/PaymentOptionsModal/SelectOptionView.tsx | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci_e2e_walletkit.yaml b/.github/workflows/ci_e2e_walletkit.yaml index f5f0b1382..6faef6ca9 100644 --- a/.github/workflows/ci_e2e_walletkit.yaml +++ b/.github/workflows/ci_e2e_walletkit.yaml @@ -144,7 +144,7 @@ jobs: run: xcrun simctl install "$DEVICE_ID" "$APP_PATH" - name: Copy shared Pay test flows - uses: WalletConnect/actions/maestro/pay-tests@0c9875ae33fe85fa6a4110dc46c82876d91a551d + uses: WalletConnect/actions/maestro/pay-tests@fix-maestro-tests-pr480 - name: Install Maestro uses: WalletConnect/actions/maestro/setup@0c9875ae33fe85fa6a4110dc46c82876d91a551d @@ -323,7 +323,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Copy shared Pay test flows - uses: WalletConnect/actions/maestro/pay-tests@0c9875ae33fe85fa6a4110dc46c82876d91a551d + uses: WalletConnect/actions/maestro/pay-tests@fix-maestro-tests-pr480 - name: Install Maestro uses: WalletConnect/actions/maestro/setup@0c9875ae33fe85fa6a4110dc46c82876d91a551d diff --git a/wallets/rn_cli_wallet/src/components/OptionItem.tsx b/wallets/rn_cli_wallet/src/components/OptionItem.tsx index 39fbc9088..c426d7f1b 100644 --- a/wallets/rn_cli_wallet/src/components/OptionItem.tsx +++ b/wallets/rn_cli_wallet/src/components/OptionItem.tsx @@ -20,6 +20,7 @@ interface OptionItemProps { onPress?: (option: PaymentOption) => void; onIconRightPress?: () => void; testID?: string; + rightIconTestID?: string; } function formatOptionAmount(value: string, decimals: number): string { @@ -45,6 +46,7 @@ export function OptionItem({ isEstimatingApprovalGas, onPress, testID, + rightIconTestID, }: OptionItemProps) { const Theme = useTheme(); @@ -109,7 +111,7 @@ export function OptionItem({ )} {renderIconRight && onIconRightPress && ( } + rightIconTestID="pay-option-info" onIconRightPress={hasCollectData ? onInfoPress : undefined} onPress={() => onOptionPress(option)} /> From 9cab272b5b39bb3f2a554aeaf2cb7925e14b14d7 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 4 May 2026 09:49:24 +0200 Subject: [PATCH 06/15] chore: added select option header test id --- wallets/rn_cli_wallet/src/components/OptionItem.tsx | 3 --- .../modals/PaymentOptionsModal/SelectOptionView.tsx | 10 +++++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/wallets/rn_cli_wallet/src/components/OptionItem.tsx b/wallets/rn_cli_wallet/src/components/OptionItem.tsx index c426d7f1b..45c141582 100644 --- a/wallets/rn_cli_wallet/src/components/OptionItem.tsx +++ b/wallets/rn_cli_wallet/src/components/OptionItem.tsx @@ -20,7 +20,6 @@ interface OptionItemProps { onPress?: (option: PaymentOption) => void; onIconRightPress?: () => void; testID?: string; - rightIconTestID?: string; } function formatOptionAmount(value: string, decimals: number): string { @@ -46,7 +45,6 @@ export function OptionItem({ isEstimatingApprovalGas, onPress, testID, - rightIconTestID, }: OptionItemProps) { const Theme = useTheme(); @@ -111,7 +109,6 @@ export function OptionItem({ )} {renderIconRight && onIconRightPress && (