diff --git a/.github/actions/walletkit-build-and-maestro/action.yml b/.github/actions/walletkit-build-and-maestro/action.yml index 10eed310c..a0826bc4b 100644 --- a/.github/actions/walletkit-build-and-maestro/action.yml +++ b/.github/actions/walletkit-build-and-maestro/action.yml @@ -73,7 +73,7 @@ inputs: maestro-actions-ref: description: 'Pinned ref of WalletConnect/actions for maestro/pay-tests and maestro/setup.' required: false - default: '9d229682828dea4e0f31504466b6a2a545412cf9' + default: 'e055fc0dffe1b83e08629c22be7038b298c4416e' apple-key-id: description: 'Apple App Store Connect API key id (iOS only).' required: false @@ -381,10 +381,10 @@ runs: # --- Common: Maestro setup + run --- - name: Copy shared Pay test flows - uses: WalletConnect/actions/maestro/pay-tests@9d229682828dea4e0f31504466b6a2a545412cf9 + uses: WalletConnect/actions/maestro/pay-tests@e055fc0dffe1b83e08629c22be7038b298c4416e - name: Install Maestro - uses: WalletConnect/actions/maestro/setup@9d229682828dea4e0f31504466b6a2a545412cf9 + uses: WalletConnect/actions/maestro/setup@e055fc0dffe1b83e08629c22be7038b298c4416e - name: Run Maestro tests (iOS) if: inputs.platform == 'ios' 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..cc8869413 --- /dev/null +++ b/wallets/rn_cli_wallet/__tests__/PaymentStore.test.ts @@ -0,0 +1,671 @@ +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('keeps the default processing message for single-step send-transaction payments', async () => { + const deferredActions = createDeferred(); + mockedGetRequiredPaymentActions.mockImplementationOnce( + () => deferredActions.promise, + ); + mockedSendTransactionWithFreshFees.mockImplementation(async () => { + expect(PaymentStore.state.loadingMessage).toBeNull(); + expect(PaymentStore.state.loadingNote).toBeNull(); + return { + hash: '0xhash', + wait: jest.fn(), + } as any; + }); + + const paymentOptions = createPaymentOptions([ + { + id: 'single-step-sendtx-option', + actions: [ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', value: '0x1' }, + ]), + ], + }, + ]); + + PaymentStore.setPaymentOptions(paymentOptions); + PaymentStore.selectOption(paymentOptions.options[0]); + await flushPromises(); + + const approvePromise = PaymentStore.approvePayment(); + + expect(PaymentStore.state.step).toBe('confirming'); + expect(PaymentStore.state.loadingMessage).toBeNull(); + expect(PaymentStore.state.loadingNote).toBeNull(); + + deferredActions.resolve([ + createAction(EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, [ + { from: '0xabc', to: '0xdef', value: '0x1' }, + ]), + ]); + + await approvePromise; + + expect(mockedConfirmPayment).toHaveBeenCalledWith({ + paymentId: 'payment-1', + optionId: 'single-step-sendtx-option', + signatures: ['0xhash'], + }); + 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(mockedConfirmPayment).toHaveBeenCalledWith({ + paymentId: 'payment-1', + optionId: 'approval-option', + signatures: ['0xhash', '0xsigned'], + }); + 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..5b1bd126a 100644 --- a/wallets/rn_cli_wallet/__tests__/PaymentUtil.test.ts +++ b/wallets/rn_cli_wallet/__tests__/PaymentUtil.test.ts @@ -1,7 +1,11 @@ import type { Action } from '@walletconnect/pay'; import { EIP155_SIGNING_METHODS } from '../src/constants/Eip155'; -import { getPaymentContext } from '../src/utils/PaymentUtil'; +import { + getApprovalAction, + requiresApproval, + shouldShowSetupLoader, +} from '../src/utils/PaymentUtil'; function createAction(method: string): Action { return { @@ -13,16 +17,15 @@ 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], - }); + const sendTxAction = createAction( + EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION, + ); - expect(context.requiresApproval).toBe(true); - expect(context.approvalAction).toBe(sendTxAction); + expect(requiresApproval([sendTxAction])).toBe(true); + expect(getApprovalAction([sendTxAction])).toBe(sendTxAction); + expect(shouldShowSetupLoader([sendTxAction])).toBe(false); }); it('does not require approval when there is no eth_sendTransaction action', () => { @@ -30,20 +33,39 @@ 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(); + expect(shouldShowSetupLoader([typedDataAction])).toBe(false); }); it('does not require approval when actions are missing', () => { - const context = getPaymentContext({ - paymentActions: null, - }); + expect(requiresApproval(null)).toBe(false); + expect(getApprovalAction(null)).toBeNull(); + expect(shouldShowSetupLoader(null)).toBe(false); + }); + + it('does not require approval for signature-only option actions', () => { + const typedDataAction = createAction( + EIP155_SIGNING_METHODS.ETH_SIGN_TYPED_DATA_V4, + ); - expect(context.requiresApproval).toBe(false); - expect(context.approvalAction).toBeNull(); + expect(requiresApproval([typedDataAction])).toBe(false); + expect(getApprovalAction([typedDataAction])).toBeNull(); + expect(shouldShowSetupLoader([typedDataAction])).toBe(false); + }); + + 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(requiresApproval([sendTxAction, typedDataAction])).toBe(true); + expect(getApprovalAction([sendTxAction, typedDataAction])).toBe( + sendTxAction, + ); + expect(shouldShowSetupLoader([sendTxAction, typedDataAction])).toBe(true); }); }); 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 dc2d3fec5..b5cd1bbd1 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/LoadingView.tsx @@ -9,6 +9,7 @@ import { Text } from '@/components/Text'; interface LoadingViewProps { message?: string; + note?: string; size?: number; } @@ -24,7 +25,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 = arePayModalAnimationsEnabled && hasMountedRef.current @@ -37,7 +38,9 @@ export function LoadingView({ message, size = 120 }: LoadingViewProps) { return ( - + {message || 'Loading...'} + {note && ( + + {note} + + )} @@ -68,10 +81,13 @@ const styles = StyleSheet.create({ }, messageContainer: { marginTop: Spacing[4], - minHeight: 48, + minHeight: 64, width: '100%', overflow: 'hidden', }, + messageContainerWithNote: { + minHeight: 110, + }, messageSlot: { ...StyleSheet.absoluteFill, alignItems: 'center', @@ -81,4 +97,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..261350ee4 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ReviewPaymentView.tsx @@ -1,131 +1,114 @@ -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}`}{' '} + {canIncludeGasInTotal && ( + + (incl. gas fee) + + )} + {requiresApproval && onGasFeePress && ( + + Why does {selectedOption.amount.display.assetSymbol?.toUpperCase()}{' '} + require a gas fee? + + )} ); @@ -133,42 +116,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..9680faa5f 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/SelectOptionView.tsx @@ -1,172 +1,42 @@ 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 type { PaymentOptionWithCollectData } from '@/utils/TypesUtil'; +import { formatAmount, getCurrencySymbol } from './utils'; +import type { + OptionFeeEstimateStatus, + 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; } 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 +44,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 +73,62 @@ 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 +137,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..e92e53d24 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 +374,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 +396,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 9c638fddb..43e4bd4e2 100644 --- a/wallets/rn_cli_wallet/src/store/PaymentStore.ts +++ b/wallets/rn_cli_wallet/src/store/PaymentStore.ts @@ -11,7 +11,8 @@ import LogStore, { serializeError } from '@/store/LogStore'; import SettingsStore from '@/store/SettingsStore'; import { walletKit } from '@/utils/WalletKitUtil'; import { eip155Wallets } from '@/utils/EIP155WalletUtil'; -import type { Step } from '@/utils/TypesUtil'; +import { storage } from '@/utils/storage'; +import type { OptionFeeEstimateStatus, Step } from '@/utils/TypesUtil'; import { detectErrorType, getErrorMessage, @@ -24,44 +25,45 @@ import { sendTransactionWithFreshFees, waitForTransactionConfirmation, } from '@/utils/PaymentTransactionUtil'; -import { getPaymentContext } from '@/utils/PaymentUtil'; +import type { TransactionFeeEstimate } from '@/utils/PaymentTransactionUtil'; +import { getApprovalAction, shouldShowSetupLoader } from '@/utils/PaymentUtil'; 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 +72,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 +129,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 +155,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 +168,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 +182,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 +199,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 +222,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 +244,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; }, - setLoadingActions(loading: boolean) { - state.isLoadingActions = loading; + getOptionFeeEstimateStatus(optionId: string): OptionFeeEstimateStatus { + return state.optionFeeEstimateStatusById[optionId] ?? 'idle'; }, - setActionsError(error: string | null) { - state.actionsError = error; + async loadLastPaidTokenUnit(): Promise { + return storage.getItem(PAY_LAST_TOKEN_UNIT_KEY); + }, + + 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 +284,7 @@ const PaymentStore = { 'review', 'collectData', 'infoExplainer', + 'gasFee', ]; if (delay <= 0) { @@ -278,28 +308,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 +407,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 +442,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 +456,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 +490,19 @@ const PaymentStore = { return; } + const tokenSymbol = selectedOption.amount.display.assetSymbol || 'token'; + const showInitialSetupLoader = shouldShowSetupLoader( + selectedOption.actions, + ); + state.step = 'confirming'; - state.actionsError = null; - state.loadingMessage = null; + if (showInitialSetupLoader) { + 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 +515,16 @@ 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); + const showSetupLoader = shouldShowSetupLoader(paymentActions); for (const [index, action] of paymentActions.entries()) { const stepLabel = `${index + 1}/${totalActions}`; @@ -485,31 +534,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 (showSetupLoader && 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 +575,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({ @@ -565,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; @@ -628,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..436b25793 100644 --- a/wallets/rn_cli_wallet/src/utils/PaymentUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/PaymentUtil.ts @@ -2,29 +2,24 @@ 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); +export function requiresApproval(actions: readonly Action[] | null): boolean { + return !!getApprovalAction(actions); +} - return { - approvalAction, - requiresApproval: !!approvalAction, - }; +export function shouldShowSetupLoader( + actions: readonly Action[] | null, +): boolean { + return (actions?.length ?? 0) > 1 && requiresApproval(actions); } diff --git a/wallets/rn_cli_wallet/src/utils/TypesUtil.ts b/wallets/rn_cli_wallet/src/utils/TypesUtil.ts index 95040148e..697ecb3f9 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' @@ -107,3 +108,5 @@ import type { PaymentOption, CollectDataAction } from '@walletconnect/pay'; export interface PaymentOptionWithCollectData extends PaymentOption { collectData?: CollectDataAction; } + +export type OptionFeeEstimateStatus = 'idle' | 'loading' | 'ready' | 'error';