diff --git a/.changeset/cyan-melons-float.md b/.changeset/cyan-melons-float.md new file mode 100644 index 000000000..7ab400959 --- /dev/null +++ b/.changeset/cyan-melons-float.md @@ -0,0 +1,18 @@ +--- +'@reown/appkit-scaffold-react-native': patch +'@reown/appkit-ethers5-react-native': patch +'@reown/appkit-common-react-native': patch +'@reown/appkit-ethers-react-native': patch +'@reown/appkit-wallet-react-native': patch +'@reown/appkit-wagmi-react-native': patch +'@reown/appkit-core-react-native': patch +'@reown/appkit-ui-react-native': patch +'@reown/appkit-auth-ethers-react-native': patch +'@reown/appkit-auth-wagmi-react-native': patch +'@reown/appkit-coinbase-ethers-react-native': patch +'@reown/appkit-coinbase-wagmi-react-native': patch +'@reown/appkit-scaffold-utils-react-native': patch +'@reown/appkit-siwe-react-native': patch +--- + +fix: set loading when account data is being synced in appkit-wagmi diff --git a/.changeset/fair-ravens-shop.md b/.changeset/fair-ravens-shop.md new file mode 100644 index 000000000..7a258c588 --- /dev/null +++ b/.changeset/fair-ravens-shop.md @@ -0,0 +1,18 @@ +--- +'@reown/appkit-scaffold-react-native': minor +'@reown/appkit-ethers5-react-native': minor +'@reown/appkit-common-react-native': minor +'@reown/appkit-ethers-react-native': minor +'@reown/appkit-wallet-react-native': minor +'@reown/appkit-wagmi-react-native': minor +'@reown/appkit-core-react-native': minor +'@reown/appkit-ui-react-native': minor +'@reown/appkit-auth-ethers-react-native': minor +'@reown/appkit-auth-wagmi-react-native': minor +'@reown/appkit-coinbase-ethers-react-native': minor +'@reown/appkit-coinbase-wagmi-react-native': minor +'@reown/appkit-scaffold-utils-react-native': minor +'@reown/appkit-siwe-react-native': minor +--- + +feat: swaps feature diff --git a/.changeset/late-cycles-lick.md b/.changeset/late-cycles-lick.md new file mode 100644 index 000000000..0de79bcf3 --- /dev/null +++ b/.changeset/late-cycles-lick.md @@ -0,0 +1,18 @@ +--- +'@reown/appkit-scaffold-react-native': minor +'@reown/appkit-ethers5-react-native': minor +'@reown/appkit-common-react-native': minor +'@reown/appkit-ethers-react-native': minor +'@reown/appkit-wallet-react-native': minor +'@reown/appkit-wagmi-react-native': minor +'@reown/appkit-core-react-native': minor +'@reown/appkit-ui-react-native': minor +'@reown/appkit-auth-ethers-react-native': minor +'@reown/appkit-auth-wagmi-react-native': minor +'@reown/appkit-coinbase-ethers-react-native': minor +'@reown/appkit-coinbase-wagmi-react-native': minor +'@reown/appkit-scaffold-utils-react-native': minor +'@reown/appkit-siwe-react-native': minor +--- + +feat: added ability to change themeMode and override accent color diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index 91fe32bf9..64bb4382d 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -32,18 +32,17 @@ jobs: commit: 'chore: version packages' publish: yarn run changeset:publish version: yarn run changeset:version - createGithubReleases: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Create Github Release - if: steps.changesets.outputs.published == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION=$(node -pe "require('./package.json').version") - gh release create "v$VERSION" --generate-notes --target main + # - name: Create Github Release + # if: steps.changesets.outputs.published == 'true' + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # run: | + # VERSION=$(node -pe "require('./package.json').version") + # gh release create "v$VERSION" --generate-notes --target main - name: Publish NPM pre-release if: steps.changesets.outputs.published != 'true' diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 264779f01..672675e69 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -15,6 +15,7 @@ import { } from '@reown/appkit-wagmi-react-native'; import { authConnector } from '@reown/appkit-auth-wagmi-react-native'; +import { Text } from '@reown/appkit-ui-react-native'; import { siweConfig } from './src/utils/SiweUtils'; @@ -76,7 +77,8 @@ createAppKit({ features: { email: true, socials: ['x', 'discord', 'apple'], - emailShowWallets: true + emailShowWallets: true, + swaps: true } }); @@ -88,6 +90,9 @@ export default function Native() { + + AppKit for React Native + { + describe('isReownName', () => { + test('returns true for names ending with legacy suffix', () => { + const legacyName = `testname${ConstantsUtil.WC_NAME_SUFFIX_LEGACY}`; + expect(NamesUtil.isReownName(legacyName)).toBe(true); + }); + + test('returns true for names ending with current suffix', () => { + const currentName = `testname${ConstantsUtil.WC_NAME_SUFFIX}`; + expect(NamesUtil.isReownName(currentName)).toBe(true); + }); + + test('returns false for names not ending with either suffix', () => { + expect(NamesUtil.isReownName('testname')).toBe(false); + expect(NamesUtil.isReownName('testname.com')).toBe(false); + }); + + test('returns false for empty string', () => { + expect(NamesUtil.isReownName('')).toBe(false); + }); + }); +}); diff --git a/packages/common/src/__tests__/NumberUtil.test.ts b/packages/common/src/__tests__/NumberUtil.test.ts new file mode 100644 index 000000000..9061debd4 --- /dev/null +++ b/packages/common/src/__tests__/NumberUtil.test.ts @@ -0,0 +1,46 @@ +import { NumberUtil } from '../utils/NumberUtil'; + +// -- Tests -------------------------------------------------------------------- +describe('NumberUtil', () => { + it('should return isGreaterThan as expected', () => { + const isGreaterThan = NumberUtil.bigNumber('6.348').isGreaterThan('0'); + expect(isGreaterThan).toBe(true); + }); +}); + +describe('NumberUtil.parseLocalStringToNumber', () => { + it('should return 0 when value is undefined', () => { + const result = NumberUtil.parseLocalStringToNumber(undefined); + expect(result).toBe(0); + }); + + it('should return the number when value is a string', () => { + const result = NumberUtil.parseLocalStringToNumber('123.45'); + expect(result).toBe(123.45); + }); + + it('should return the number when value is a string with a lot of decimals', () => { + const result = NumberUtil.parseLocalStringToNumber('123.4567890123456789'); + expect(result).toBe(123.4567890123456789); + }); + + it('should return the number when value is a string with zero and a lot of decimals', () => { + const result = NumberUtil.parseLocalStringToNumber('0.000000000000000001'); + expect(result).toBe(0.000000000000000001); + }); + + it('should return the number when value is a string with a negative sign', () => { + const result = NumberUtil.parseLocalStringToNumber('-123.45'); + expect(result).toBe(-123.45); + }); + + it('should return the number when value is a string with commas', () => { + const result = NumberUtil.parseLocalStringToNumber('123,456.78'); + expect(result).toBe(123456.78); + }); + + it('should return the number when value is a string with a lot of commas', () => { + const result = NumberUtil.parseLocalStringToNumber('123,456,789.123,456,789'); + expect(result).toBe(123456789.123456789); + }); +}); diff --git a/packages/common/src/utils/NumberUtil.ts b/packages/common/src/utils/NumberUtil.ts index 760071bad..c539cd35e 100644 --- a/packages/common/src/utils/NumberUtil.ts +++ b/packages/common/src/utils/NumberUtil.ts @@ -2,6 +2,10 @@ import * as BigNumber from 'bignumber.js'; export const NumberUtil = { bigNumber(value: BigNumber.BigNumber.Value) { + if (typeof value === 'string') { + return new BigNumber.BigNumber(value.replace(/,/g, '')); + } + return new BigNumber.BigNumber(value); }, @@ -16,8 +20,8 @@ export const NumberUtil = { return BigNumber.BigNumber(0); } - const aBigNumber = new BigNumber.BigNumber(a); - const bBigNumber = new BigNumber.BigNumber(b); + const aBigNumber = new BigNumber.BigNumber(typeof a === 'string' ? a.replace(/,/gu, '') : a); + const bBigNumber = new BigNumber.BigNumber(typeof b === 'string' ? b.replace(/,/gu, '') : b); return aBigNumber.multipliedBy(bBigNumber); }, @@ -27,5 +31,42 @@ export const NumberUtil = { number.toString().length >= threshold ? Number(number).toFixed(fixed) : number; return roundedNumber; + }, + + /** + * Format the given number or string to human readable numbers with the given number of decimals + * @param value - The value to format. It could be a number or string. If it's a string, it will be parsed to a float then formatted. + * @param decimals - number of decimals after dot + * @returns + */ + formatNumberToLocalString(value: string | number | undefined, decimals = 2) { + if (value === undefined) { + return '0.00'; + } + + if (typeof value === 'number') { + return value.toLocaleString('en-US', { + maximumFractionDigits: decimals, + minimumFractionDigits: decimals + }); + } + + return parseFloat(value).toLocaleString('en-US', { + maximumFractionDigits: decimals, + minimumFractionDigits: decimals + }); + }, + /** + * Parse a formatted local string back to a number + * @param value - The formatted string to parse + * @returns + */ + parseLocalStringToNumber(value: string | undefined) { + if (value === undefined) { + return 0; + } + + // Remove any commas used as thousand separators and parse the float + return parseFloat(value.replace(/,/gu, '')); } }; diff --git a/packages/common/src/utils/TypeUtil.ts b/packages/common/src/utils/TypeUtil.ts index 208e8fee2..a25bdcd66 100644 --- a/packages/common/src/utils/TypeUtil.ts +++ b/packages/common/src/utils/TypeUtil.ts @@ -87,3 +87,9 @@ export interface TransactionQuantity { } export type SocialProvider = 'apple' | 'x' | 'discord' | 'farcaster'; + +export type ThemeMode = 'dark' | 'light'; + +export interface ThemeVariables { + accent?: string; +} diff --git a/packages/core/src/__tests__/controllers/ThemeController.test.ts b/packages/core/src/__tests__/controllers/ThemeController.test.ts index 456db8f31..7285c8d77 100644 --- a/packages/core/src/__tests__/controllers/ThemeController.test.ts +++ b/packages/core/src/__tests__/controllers/ThemeController.test.ts @@ -4,7 +4,7 @@ import { ThemeController } from '../../index'; describe('ThemeController', () => { it('should have valid default state', () => { expect(ThemeController.state).toEqual({ - themeMode: 'dark', + themeMode: undefined, themeVariables: {} }); }); diff --git a/packages/core/src/controllers/ApiController.ts b/packages/core/src/controllers/ApiController.ts index db9766252..aaafc1621 100644 --- a/packages/core/src/controllers/ApiController.ts +++ b/packages/core/src/controllers/ApiController.ts @@ -72,7 +72,7 @@ export const ApiController = { 'x-sdk-type': sdkType, 'x-sdk-version': sdkVersion, 'User-Agent': ApiUtil.getUserAgent(), - 'Origin': ApiUtil.getOrigin() + 'origin': ApiUtil.getOrigin() }; }, diff --git a/packages/core/src/controllers/BlockchainApiController.ts b/packages/core/src/controllers/BlockchainApiController.ts index ddf7eb854..2b982d408 100644 --- a/packages/core/src/controllers/BlockchainApiController.ts +++ b/packages/core/src/controllers/BlockchainApiController.ts @@ -6,19 +6,40 @@ import type { BlockchainApiBalanceResponse, BlockchainApiGasPriceRequest, BlockchainApiGasPriceResponse, + BlockchainApiGenerateApproveCalldataRequest, + BlockchainApiGenerateApproveCalldataResponse, + BlockchainApiGenerateSwapCalldataRequest, + BlockchainApiGenerateSwapCalldataResponse, BlockchainApiIdentityRequest, BlockchainApiIdentityResponse, BlockchainApiLookupEnsName, + BlockchainApiSwapAllowanceRequest, + BlockchainApiSwapAllowanceResponse, + BlockchainApiSwapQuoteRequest, + BlockchainApiSwapQuoteResponse, + BlockchainApiSwapTokensRequest, + BlockchainApiSwapTokensResponse, BlockchainApiTokenPriceRequest, BlockchainApiTokenPriceResponse, BlockchainApiTransactionsRequest, BlockchainApiTransactionsResponse } from '../utils/TypeUtil'; import { OptionsController } from './OptionsController'; +import { ConstantsUtil } from '../utils/ConstantsUtil'; // -- Helpers ------------------------------------------- // const baseUrl = CoreHelperUtil.getBlockchainApiUrl(); +const getHeaders = () => { + const { sdkType, sdkVersion } = OptionsController.state; + + return { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + }; +}; + // -- Types --------------------------------------------- // export interface BlockchainApiControllerState { clientId: string | null; @@ -40,7 +61,8 @@ export const BlockchainApiController = { path: `/v1/identity/${address}`, params: { projectId: OptionsController.state.projectId - } + }, + headers: getHeaders() }); }, @@ -54,6 +76,7 @@ export const BlockchainApiController = { }: BlockchainApiTransactionsRequest) { return state.api.get({ path: `/v1/account/${account}/history`, + headers: getHeaders(), params: { projectId, cursor, @@ -72,22 +95,26 @@ export const BlockchainApiController = { currency: 'usd', addresses }, - headers: { - 'Content-Type': 'application/json' - } + headers: getHeaders() }); }, - fetchGasPrice({ projectId, chainId }: BlockchainApiGasPriceRequest) { - const { sdkType, sdkVersion } = OptionsController.state; + fetchSwapAllowance({ projectId, tokenAddress, userAddress }: BlockchainApiSwapAllowanceRequest) { + return state.api.get({ + path: `/v1/convert/allowance`, + params: { + projectId, + tokenAddress, + userAddress + }, + headers: getHeaders() + }); + }, + fetchGasPrice({ projectId, chainId }: BlockchainApiGasPriceRequest) { return state.api.get({ path: `/v1/convert/gas-price`, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { projectId, chainId @@ -95,15 +122,84 @@ export const BlockchainApiController = { }); }, - async getBalance(address: string, chainId?: string, forceUpdate?: string) { - const { sdkType, sdkVersion } = OptionsController.state; + fetchSwapQuote({ + projectId, + amount, + userAddress, + from, + to, + gasPrice + }: BlockchainApiSwapQuoteRequest) { + return state.api.get({ + path: `/v1/convert/quotes`, + headers: getHeaders(), + params: { + projectId, + amount, + userAddress, + from, + to, + gasPrice + } + }); + }, + + fetchSwapTokens({ projectId, chainId }: BlockchainApiSwapTokensRequest) { + return state.api.get({ + path: `/v1/convert/tokens`, + headers: getHeaders(), + params: { + projectId, + chainId + } + }); + }, + + generateSwapCalldata({ + amount, + from, + projectId, + to, + userAddress + }: BlockchainApiGenerateSwapCalldataRequest) { + return state.api.post({ + path: '/v1/convert/build-transaction', + headers: getHeaders(), + body: { + amount, + eip155: { + slippage: ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE + }, + from, + projectId, + to, + userAddress + } + }); + }, + generateApproveCalldata({ + from, + projectId, + to, + userAddress + }: BlockchainApiGenerateApproveCalldataRequest) { + return state.api.get({ + path: `/v1/convert/build-approve`, + headers: getHeaders(), + params: { + projectId, + userAddress, + from, + to + } + }); + }, + + async getBalance(address: string, chainId?: string, forceUpdate?: string) { return state.api.get({ path: `/v1/account/${address}/balance`, - headers: { - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { currency: 'usd', projectId: OptionsController.state.projectId, @@ -116,6 +212,7 @@ export const BlockchainApiController = { async lookupEnsName(name: string) { return state.api.get({ path: `/v1/profile/account/${name}`, + headers: getHeaders(), params: { projectId: OptionsController.state.projectId, apiVersion: '2' diff --git a/packages/core/src/controllers/ConnectionController.ts b/packages/core/src/controllers/ConnectionController.ts index f0c7c8758..d36182b74 100644 --- a/packages/core/src/controllers/ConnectionController.ts +++ b/packages/core/src/controllers/ConnectionController.ts @@ -5,11 +5,11 @@ import { CoreHelperUtil } from '../utils/CoreHelperUtil'; import { StorageUtil } from '../utils/StorageUtil'; import type { Connector, + EstimateGasTransactionArgs, SendTransactionArgs, WcWallet, WriteContractArgs } from '../utils/TypeUtil'; -import { RouterController } from './RouterController'; import { ConnectorController } from './ConnectorController'; // -- Types --------------------------------------------- // @@ -31,6 +31,7 @@ export interface ConnectionControllerClient { parseUnits: (value: string, decimals: number) => bigint; formatUnits: (value: bigint, decimals: number) => string; writeContract: (args: WriteContractArgs) => Promise<`0x${string}` | null>; + estimateGas: (args: EstimateGasTransactionArgs) => Promise; disconnect: () => Promise; getEnsAddress: (value: string) => Promise; getEnsAvatar: (value: string) => Promise; @@ -159,6 +160,9 @@ export const ConnectionController = { return this._getClient().sendTransaction(args); }, + async estimateGas(args: EstimateGasTransactionArgs) { + return this._getClient()?.estimateGas(args); + }, async writeContract(args: WriteContractArgs) { return this._getClient().writeContract(args); }, @@ -191,6 +195,6 @@ export const ConnectionController = { await this._getClient().disconnect(); this.resetWcConnection(); // remove transactions - RouterController.reset('Connect'); + // RouterController.reset('Connect'); } }; diff --git a/packages/core/src/controllers/NetworkController.ts b/packages/core/src/controllers/NetworkController.ts index 8d62235f3..f1023c952 100644 --- a/packages/core/src/controllers/NetworkController.ts +++ b/packages/core/src/controllers/NetworkController.ts @@ -2,6 +2,7 @@ import { proxy, ref } from 'valtio'; import type { CaipNetwork, CaipNetworkId } from '../utils/TypeUtil'; import { PublicStateController } from './PublicStateController'; import { NetworkUtil } from '@reown/appkit-common-react-native'; +import { ConstantsUtil } from '../utils/ConstantsUtil'; // -- Types --------------------------------------------- // export interface NetworkControllerClient { @@ -95,6 +96,13 @@ export const NetworkController = { ); }, + getActiveNetworkTokenAddress() { + const chainId = this.state.caipNetwork?.id || 'eip155:1'; + const address = ConstantsUtil.NATIVE_TOKEN_ADDRESS; + + return `${chainId}:${address}`; + }, + async switchActiveNetwork(network: NetworkControllerState['caipNetwork']) { await this._getClient().switchCaipNetwork(network); state.caipNetwork = network; diff --git a/packages/core/src/controllers/RouterController.ts b/packages/core/src/controllers/RouterController.ts index e84195189..4865a8e79 100644 --- a/packages/core/src/controllers/RouterController.ts +++ b/packages/core/src/controllers/RouterController.ts @@ -1,5 +1,5 @@ import { proxy } from 'valtio'; -import type { WcWallet, CaipNetwork, Connector } from '../utils/TypeUtil'; +import type { WcWallet, CaipNetwork, Connector, SwapInputTarget } from '../utils/TypeUtil'; // -- Types --------------------------------------------- // type TransactionAction = { @@ -29,7 +29,11 @@ export interface RouterControllerState { | 'GetWallet' | 'Networks' | 'SwitchNetwork' + | 'Swap' + | 'SwapSelectToken' + | 'SwapPreview' | 'Transactions' + | 'UnsupportedChain' | 'UpdateEmailPrimaryOtp' | 'UpdateEmailSecondaryOtp' | 'UpdateEmailWallet' @@ -49,6 +53,7 @@ export interface RouterControllerState { network?: CaipNetwork; email?: string; newEmail?: string; + swapTarget?: SwapInputTarget; }; transactionStack: TransactionAction[]; } diff --git a/packages/core/src/controllers/SnackController.ts b/packages/core/src/controllers/SnackController.ts index 8a0d91c98..e9d8078fe 100644 --- a/packages/core/src/controllers/SnackController.ts +++ b/packages/core/src/controllers/SnackController.ts @@ -9,7 +9,7 @@ interface Message { export interface SnackControllerState { message: string; - variant: 'error' | 'success'; + variant: 'error' | 'success' | 'loading'; open: boolean; long: boolean; } @@ -38,6 +38,12 @@ export const SnackController = { state.open = true; }, + showLoading(message: SnackControllerState['message']) { + state.message = message; + state.variant = 'loading'; + state.open = true; + }, + showInternalError(error: Message) { const { debug } = OptionsController.state; diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 35ef82461..ce2601af3 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -1,5 +1,6 @@ import { subscribeKey as subKey } from 'valtio/utils'; import { proxy, subscribe as sub } from 'valtio'; +import { NumberUtil } from '@reown/appkit-common-react-native'; import { ConstantsUtil } from '../utils/ConstantsUtil'; import { SwapApiUtil } from '../utils/SwapApiUtil'; @@ -7,40 +8,139 @@ import { NetworkController } from './NetworkController'; import { BlockchainApiController } from './BlockchainApiController'; import { OptionsController } from './OptionsController'; import { SwapCalculationUtil } from '../utils/SwapCalculationUtil'; +import { SnackController } from './SnackController'; +import { RouterController } from './RouterController'; +import type { SwapInputTarget, SwapTokenWithBalance } from '../utils/TypeUtil'; +import { ConnectorController } from './ConnectorController'; +import { AccountController } from './AccountController'; +import { CoreHelperUtil } from '../utils/CoreHelperUtil'; +import { ConnectionController } from './ConnectionController'; +import { TransactionsController } from './TransactionsController'; +import { EventsController } from './EventsController'; // -- Constants ---------------------------------------- // export const INITIAL_GAS_LIMIT = 150000; export const TO_AMOUNT_DECIMALS = 6; // -- Types --------------------------------------------- // +type TransactionParams = { + data: string; + to: string; + gas: bigint; + gasPrice: bigint; + value: bigint; + toAmount: string; +}; + +class TransactionError extends Error { + shortMessage?: string; + + constructor(message?: string, shortMessage?: string) { + super(message); + this.name = 'TransactionError'; + this.shortMessage = shortMessage; + } +} export interface SwapControllerState { + // Loading states + initializing: boolean; + initialized: boolean; + loadingPrices: boolean; + loadingQuote?: boolean; + loadingApprovalTransaction?: boolean; + loadingBuildTransaction?: boolean; + loadingTransaction?: boolean; + + // Error states + fetchError: boolean; + + // Approval & Swap transaction states + approvalTransaction: TransactionParams | undefined; + swapTransaction: TransactionParams | undefined; + transactionError?: string; + // Input values + sourceToken?: SwapTokenWithBalance; + sourceTokenAmount: string; + sourceTokenPriceInUSD: number; + toToken?: SwapTokenWithBalance; + toTokenAmount: string; + toTokenPriceInUSD: number; networkPrice: string; + networkBalanceInUSD: string; networkTokenSymbol: string; + inputError: string | undefined; + + // Request values + slippage: number; // Tokens + tokens?: SwapTokenWithBalance[]; + suggestedTokens?: SwapTokenWithBalance[]; + popularTokens?: SwapTokenWithBalance[]; + foundTokens?: SwapTokenWithBalance[]; + myTokensWithBalance?: SwapTokenWithBalance[]; tokensPriceMap: Record; // Calculations gasFee: string; gasPriceInUSD?: number; + priceImpact: number | undefined; + maxSlippage: number | undefined; + providerFee: string | undefined; } type StateKey = keyof SwapControllerState; // -- State --------------------------------------------- // const initialState: SwapControllerState = { + // Loading states + initializing: false, + initialized: false, + loadingPrices: false, + loadingQuote: false, + loadingApprovalTransaction: false, + loadingBuildTransaction: false, + loadingTransaction: false, + + // Error states + fetchError: false, + + // Approval & Swap transaction states + approvalTransaction: undefined, + swapTransaction: undefined, + transactionError: undefined, + // Input values + sourceToken: undefined, + sourceTokenAmount: '', + sourceTokenPriceInUSD: 0, + toToken: undefined, + toTokenAmount: '', + toTokenPriceInUSD: 0, networkPrice: '0', + networkBalanceInUSD: '0', networkTokenSymbol: '', + inputError: undefined, + + // Request values + slippage: ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE, // Tokens + tokens: undefined, + popularTokens: undefined, + suggestedTokens: undefined, + foundTokens: undefined, + myTokensWithBalance: undefined, tokensPriceMap: {}, // Calculations gasFee: '0', - gasPriceInUSD: 0 + gasPriceInUSD: 0, + priceImpact: undefined, + maxSlippage: undefined, + providerFee: undefined }; const state = proxy(initialState); @@ -58,21 +158,207 @@ export const SwapController = { }, getParams() { - const caipNetwork = NetworkController.state.caipNetwork; - const networkAddress = `${caipNetwork?.id}:${ConstantsUtil.NATIVE_TOKEN_ADDRESS}`; + const caipAddress = AccountController.state.caipAddress; + const address = CoreHelperUtil.getPlainAddress(caipAddress); + const networkAddress = NetworkController.getActiveNetworkTokenAddress(); + const type = ConnectorController.state.connectedConnector; + + if (!address) { + throw new Error('No address found to swap the tokens from.'); + } + + const invalidToToken = !state.toToken?.address || !state.toToken?.decimals; + const invalidSourceToken = + !state.sourceToken?.address || + !state.sourceToken?.decimals || + state.sourceToken.address === state.toToken?.address || + !NumberUtil.bigNumber(state.sourceTokenAmount).isGreaterThan(0); + const invalidSourceTokenAmount = !state.sourceTokenAmount; return { - networkAddress + networkAddress, + fromAddress: address, + fromCaipAddress: caipAddress, + sourceTokenAddress: state.sourceToken?.address, + toTokenAddress: state.toToken?.address, + toTokenAmount: state.toTokenAmount, + toTokenDecimals: state.toToken?.decimals, + sourceTokenAmount: state.sourceTokenAmount, + sourceTokenDecimals: state.sourceToken?.decimals, + invalidToToken, + invalidSourceToken, + invalidSourceTokenAmount, + availableToSwap: + caipAddress && !invalidToToken && !invalidSourceToken && !invalidSourceTokenAmount, + isAuthConnector: type === 'AUTH' }; }, + switchTokens() { + if (state.initializing || !state.initialized) { + return; + } + + let newSourceToken = state.toToken ? { ...state.toToken } : undefined; + const sourceTokenWithBalance = state.myTokensWithBalance?.find( + token => token.address === newSourceToken?.address + ); + + if (sourceTokenWithBalance) { + newSourceToken = sourceTokenWithBalance; + } + + const newToToken = state.sourceToken ? { ...state.sourceToken } : undefined; + const newSourceTokenAmount = + newSourceToken && state.toTokenAmount === '' ? '1' : state.toTokenAmount; + + this.setSourceToken(newSourceToken); + this.setToToken(newToToken); + + this.setSourceTokenAmount(newSourceTokenAmount); + this.setToTokenAmount(''); + this.swapTokens(); + }, + resetState() { + state.myTokensWithBalance = initialState.myTokensWithBalance; state.tokensPriceMap = initialState.tokensPriceMap; + state.initialized = initialState.initialized; + state.sourceToken = initialState.sourceToken; + state.sourceTokenAmount = initialState.sourceTokenAmount; + state.sourceTokenPriceInUSD = initialState.sourceTokenPriceInUSD; + state.toToken = initialState.toToken; + state.toTokenAmount = initialState.toTokenAmount; + state.toTokenPriceInUSD = initialState.toTokenPriceInUSD; state.networkPrice = initialState.networkPrice; state.networkTokenSymbol = initialState.networkTokenSymbol; + state.networkBalanceInUSD = initialState.networkBalanceInUSD; + state.inputError = initialState.inputError; + }, + + async fetchTokens() { + const { networkAddress } = this.getParams(); + + await this.getTokenList(); + await this.getNetworkTokenPrice(); + await this.getMyTokensWithBalance(); + + const networkToken = state.tokens?.find(token => token.address === networkAddress); + + if (networkToken) { + state.networkTokenSymbol = networkToken.symbol; + } + + const sourceToken = + state.myTokensWithBalance?.find(token => token.address.startsWith(networkAddress)) || + state.myTokensWithBalance?.[0]; + + this.setSourceToken(sourceToken); + this.setSourceTokenAmount('1'); + }, + + async getTokenList() { + const tokens = await SwapApiUtil.getTokenList(); + + state.tokens = tokens; + state.popularTokens = tokens.sort((aTokenInfo, bTokenInfo) => { + if (aTokenInfo.symbol < bTokenInfo.symbol) { + return -1; + } + if (aTokenInfo.symbol > bTokenInfo.symbol) { + return 1; + } + + return 0; + }); + state.suggestedTokens = tokens.filter(token => { + if (ConstantsUtil.SWAP_SUGGESTED_TOKENS.includes(token.symbol)) { + return true; + } + + return false; + }, {}); + }, + + async getMyTokensWithBalance(forceUpdate?: string) { + const balances = await SwapApiUtil.getMyTokensWithBalance(forceUpdate); + + if (!balances) { + return; + } + + await this.getInitialGasPrice(); + this.setBalances(balances); + }, + + getFilteredPopularTokens() { + return state.popularTokens?.filter( + token => !state.myTokensWithBalance?.some(t => t.address === token.address) + ); + }, + + setSourceToken(sourceToken: SwapTokenWithBalance | undefined) { + if (!sourceToken) { + state.sourceToken = sourceToken; + state.sourceTokenAmount = ''; + state.sourceTokenPriceInUSD = 0; + + return; + } + + state.sourceToken = sourceToken; + this.setTokenPrice(sourceToken.address, 'sourceToken'); + }, + + setSourceTokenAmount(amount: string) { + state.sourceTokenAmount = amount; + + if (amount === '') { + state.toTokenAmount = ''; + } + }, + + async initializeState() { + if (state.initializing) { + return; + } + + state.initializing = true; + if (!state.initialized) { + try { + await this.fetchTokens(); + state.initialized = true; + } catch (error) { + state.initialized = false; + SnackController.showError('Failed to initialize swap'); + RouterController.goBack(); + } + } + state.initializing = false; + }, + + async getAddressPrice(address: string) { + const existPrice = state.tokensPriceMap[address]; + + if (existPrice) { + return existPrice; + } + + const response = await BlockchainApiController.fetchTokenPrice({ + projectId: OptionsController.state.projectId, + addresses: [address] + }); + const fungibles = response?.fungibles || []; + const allTokens = [...(state.tokens || []), ...(state.myTokensWithBalance || [])]; + const symbol = allTokens?.find(token => token.address === address)?.symbol; + const price = fungibles.find(p => p.symbol.toLowerCase() === symbol?.toLowerCase())?.price || 0; + const priceAsFloat = parseFloat(price.toString()); + + state.tokensPriceMap[address] = priceAsFloat; + + return priceAsFloat; }, - //this async getNetworkTokenPrice() { const { networkAddress } = this.getParams(); @@ -80,6 +366,7 @@ export const SwapController = { projectId: OptionsController.state.projectId, addresses: [networkAddress] }); + const token = response?.fungibles?.[0]; const price = token?.price.toString() || '0'; state.tokensPriceMap[networkAddress] = parseFloat(price); @@ -87,7 +374,6 @@ export const SwapController = { state.networkPrice = price; }, - //this async getInitialGasPrice() { const res = await SwapApiUtil.fetchGasPrice(); @@ -104,5 +390,472 @@ export const SwapController = { state.gasPriceInUSD = gasPrice; return { gasPrice: gasFee, gasPriceInUSD: state.gasPriceInUSD }; + }, + + getProviderFeePrice() { + return SwapCalculationUtil.getProviderFeePrice( + state.sourceTokenAmount, + state.sourceTokenPriceInUSD + ); + }, + + setBalances(balances: SwapTokenWithBalance[]) { + const { networkAddress } = this.getParams(); + const caipNetwork = NetworkController.state.caipNetwork; + + if (!caipNetwork) { + return; + } + + const networkToken = balances.find(token => token.address === networkAddress); + + balances.forEach(token => { + state.tokensPriceMap[token.address] = token.price || 0; + }); + + state.myTokensWithBalance = balances.filter(token => token.address?.startsWith(caipNetwork.id)); + + state.networkBalanceInUSD = networkToken + ? NumberUtil.multiply(networkToken.quantity.numeric, networkToken.price).toString() + : '0'; + }, + + setToToken(toToken: SwapTokenWithBalance | undefined) { + if (!toToken) { + state.toToken = toToken; + state.toTokenAmount = ''; + state.toTokenPriceInUSD = 0; + + return; + } + + state.toToken = toToken; + this.setTokenPrice(toToken.address, 'toToken'); + }, + + setToTokenAmount(amount: string) { + state.toTokenAmount = amount + ? NumberUtil.formatNumberToLocalString(amount, TO_AMOUNT_DECIMALS) + : ''; + }, + + async setTokenPrice(address: string, target: SwapInputTarget) { + let price = state.tokensPriceMap[address] || 0; + + if (!price) { + state.loadingPrices = true; + price = await this.getAddressPrice(address); + } + + if (target === 'sourceToken') { + state.sourceTokenPriceInUSD = price; + } else if (target === 'toToken') { + state.toTokenPriceInUSD = price; + } + + if (state.loadingPrices) { + state.loadingPrices = false; + } + + if (this.getParams().availableToSwap) { + this.swapTokens(); + } + }, + + // -- Swap ---------------------------------------------- // + async swapTokens() { + const address = AccountController.state.address as `${string}:${string}:${string}`; + const sourceToken = state.sourceToken; + const toToken = state.toToken; + const haveSourceTokenAmount = NumberUtil.bigNumber(state.sourceTokenAmount).isGreaterThan(0); + + if (!toToken || !sourceToken || state.loadingPrices || !haveSourceTokenAmount) { + return; + } + + state.loadingQuote = true; + + const amountDecimal = NumberUtil.bigNumber(state.sourceTokenAmount) + .multipliedBy(10 ** sourceToken.decimals) + .integerValue(); + + const quoteResponse = await BlockchainApiController.fetchSwapQuote({ + userAddress: address, + projectId: OptionsController.state.projectId, + from: sourceToken.address, + to: toToken.address, + gasPrice: state.gasFee, + amount: amountDecimal.toString() + }); + + state.loadingQuote = false; + + const quoteToAmount = quoteResponse?.quotes?.[0]?.toAmount; + + if (!quoteToAmount) { + return; + } + + const toTokenAmount = NumberUtil.bigNumber(quoteToAmount) + .dividedBy(10 ** toToken.decimals) + .toString(); + + this.setToTokenAmount(toTokenAmount); + + const isInsufficientToken = this.hasInsufficientToken( + state.sourceTokenAmount, + sourceToken.address + ); + + if (isInsufficientToken) { + state.inputError = 'Insufficient balance'; + } else { + state.inputError = undefined; + this.setTransactionDetails(); + } + }, + + // -- Create Transactions -------------------------------------- // + async getTransaction() { + const { fromCaipAddress, availableToSwap } = this.getParams(); + const sourceToken = state.sourceToken; + const toToken = state.toToken; + + if (!fromCaipAddress || !availableToSwap || !sourceToken || !toToken || state.loadingQuote) { + return undefined; + } + + try { + state.loadingBuildTransaction = true; + const hasAllowance = await SwapApiUtil.fetchSwapAllowance({ + userAddress: fromCaipAddress, + tokenAddress: sourceToken.address, + sourceTokenAmount: state.sourceTokenAmount, + sourceTokenDecimals: sourceToken.decimals + }); + + let transaction: TransactionParams | undefined; + + if (hasAllowance) { + transaction = await this.createSwapTransaction(); + } else { + transaction = await this.createAllowanceTransaction(); + } + + state.loadingBuildTransaction = false; + state.fetchError = false; + + return transaction; + } catch (error) { + RouterController.goBack(); + SnackController.showError('Failed to check allowance'); + state.loadingBuildTransaction = false; + state.approvalTransaction = undefined; + state.swapTransaction = undefined; + state.fetchError = true; + + return undefined; + } + }, + + async createAllowanceTransaction() { + const { fromCaipAddress, fromAddress, sourceTokenAddress, toTokenAddress } = this.getParams(); + + if (!fromCaipAddress || !toTokenAddress) { + return undefined; + } + + if (!sourceTokenAddress) { + throw new Error('createAllowanceTransaction - No source token address found.'); + } + + try { + const response = await BlockchainApiController.generateApproveCalldata({ + projectId: OptionsController.state.projectId, + from: sourceTokenAddress, + to: toTokenAddress, + userAddress: fromCaipAddress + }); + + if (!response) { + throw new Error('createAllowanceTransaction - No response from generateApproveCalldata'); + } + const gasLimit = await ConnectionController.estimateGas({ + address: fromAddress as `0x${string}`, + to: CoreHelperUtil.getPlainAddress(response.tx.to) as `0x${string}`, + data: response.tx.data + }); + + const transaction = { + data: response.tx.data, + to: CoreHelperUtil.getPlainAddress(response.tx.from) as `0x${string}`, + gas: gasLimit, + gasPrice: BigInt(response.tx.eip155.gasPrice), + value: BigInt(response.tx.value), + toAmount: state.toTokenAmount + }; + + state.swapTransaction = undefined; + state.approvalTransaction = { + data: transaction.data, + to: transaction.to, + gas: transaction.gas ?? BigInt(0), + gasPrice: transaction.gasPrice, + value: transaction.value, + toAmount: transaction.toAmount + }; + + return { + data: transaction.data, + to: transaction.to, + gas: transaction.gas ?? BigInt(0), + gasPrice: transaction.gasPrice, + value: transaction.value, + toAmount: transaction.toAmount + }; + } catch (error) { + RouterController.goBack(); + SnackController.showError('Failed to create approval transaction'); + state.approvalTransaction = undefined; + state.swapTransaction = undefined; + state.fetchError = true; + + return undefined; + } + }, + + async createSwapTransaction() { + const { networkAddress, fromCaipAddress, sourceTokenAmount } = this.getParams(); + const sourceToken = state.sourceToken; + const toToken = state.toToken; + + if (!fromCaipAddress || !sourceTokenAmount || !sourceToken || !toToken) { + return undefined; + } + + const amount = ConnectionController.parseUnits( + sourceTokenAmount, + sourceToken.decimals + )?.toString(); + + try { + const response = await BlockchainApiController.generateSwapCalldata({ + projectId: OptionsController.state.projectId, + userAddress: fromCaipAddress, + from: sourceToken.address, + to: toToken.address, + amount: amount as string + }); + + if (!response) { + throw new Error('createSwapTransaction - No response from generateSwapCalldata'); + } + + const isSourceNetworkToken = sourceToken.address === networkAddress; + + const gas = BigInt(response.tx.eip155.gas); + const gasPrice = BigInt(response.tx.eip155.gasPrice); + + const transaction = { + data: response.tx.data, + to: CoreHelperUtil.getPlainAddress(response.tx.to) as `0x${string}`, + gas, + gasPrice, + value: isSourceNetworkToken ? BigInt(amount ?? '0') : BigInt('0'), + toAmount: state.toTokenAmount + }; + + state.gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(state.networkPrice, gas, gasPrice); + + state.approvalTransaction = undefined; + state.swapTransaction = transaction; + + return transaction; + } catch (error) { + RouterController.goBack(); + SnackController.showError('Failed to create transaction'); + state.approvalTransaction = undefined; + state.swapTransaction = undefined; + state.fetchError = true; + + return undefined; + } + }, + + async sendTransactionForApproval(data: TransactionParams) { + const { fromAddress, isAuthConnector } = this.getParams(); + + state.loadingApprovalTransaction = true; + const approveLimitMessage = `Approve limit increase in your wallet`; + + if (isAuthConnector) { + RouterController.pushTransactionStack({ + view: null, + goBack: true, + onSuccess() { + SnackController.showLoading(approveLimitMessage); + } + }); + } else { + SnackController.showLoading(approveLimitMessage); + } + + try { + await ConnectionController.sendTransaction({ + address: fromAddress as `0x${string}`, + to: data.to as `0x${string}`, + data: data.data as `0x${string}`, + value: BigInt(data.value), + gasPrice: BigInt(data.gasPrice), + chainNamespace: 'eip155' + }); + + await this.swapTokens(); + await this.getTransaction(); + state.approvalTransaction = undefined; + state.loadingApprovalTransaction = false; + } catch (err) { + const error = err as TransactionError; + state.transactionError = error?.shortMessage as unknown as string; + state.loadingApprovalTransaction = false; + SnackController.showError(error?.shortMessage ?? 'Transaction error'); + } + }, + + async sendTransactionForSwap(data: TransactionParams | undefined) { + if (!data) { + return undefined; + } + + const { fromAddress, toTokenAmount, isAuthConnector } = this.getParams(); + + state.loadingTransaction = true; + + const snackbarPendingMessage = `Swapping ${state.sourceToken + ?.symbol} to ${NumberUtil.formatNumberToLocalString(toTokenAmount, 3)} ${state.toToken + ?.symbol}`; + const snackbarSuccessMessage = `Swapped ${state.sourceToken + ?.symbol} to ${NumberUtil.formatNumberToLocalString(toTokenAmount, 3)} ${state.toToken + ?.symbol}`; + + if (isAuthConnector) { + RouterController.pushTransactionStack({ + view: 'Account', + goBack: false, + onSuccess() { + SnackController.showLoading(snackbarPendingMessage); + SwapController.resetState(); + } + }); + } else { + SnackController.showLoading('Confirm transaction in your wallet'); + } + + try { + const forceUpdateAddresses = [state.sourceToken?.address, state.toToken?.address].join(','); + const transactionHash = await ConnectionController.sendTransaction({ + address: fromAddress as `0x${string}`, + to: data.to as `0x${string}`, + data: data.data as `0x${string}`, + gas: data.gas, + gasPrice: BigInt(data.gasPrice), + value: data.value, + chainNamespace: 'eip155' + }); + + state.loadingTransaction = false; + SnackController.showSuccess(snackbarSuccessMessage); + EventsController.sendEvent({ + type: 'track', + event: 'SWAP_SUCCESS', + properties: { + network: NetworkController.state.caipNetwork?.id || '', + swapFromToken: this.state.sourceToken?.symbol || '', + swapToToken: this.state.toToken?.symbol || '', + swapFromAmount: this.state.sourceTokenAmount || '', + swapToAmount: this.state.toTokenAmount || '', + isSmartAccount: AccountController.state.preferredAccountType === 'smartAccount' + } + }); + SwapController.resetState(); + + if (!isAuthConnector) { + RouterController.replace('AccountDefault'); + } + + SwapController.getMyTokensWithBalance(forceUpdateAddresses); + AccountController.fetchTokenBalance(); + + setTimeout(() => { + TransactionsController.fetchTransactions(AccountController.state.address, true); + }, 5000); + + return transactionHash; + } catch (err) { + const error = err as TransactionError; + state.transactionError = error?.shortMessage; + state.loadingTransaction = false; + SnackController.showError(error?.shortMessage ?? 'Transaction error'); + EventsController.sendEvent({ + type: 'track', + event: 'SWAP_ERROR', + properties: { + message: error?.shortMessage ?? error?.message ?? 'Unknown', + network: NetworkController.state.caipNetwork?.id || '', + swapFromToken: this.state.sourceToken?.symbol || '', + swapToToken: this.state.toToken?.symbol || '', + swapFromAmount: this.state.sourceTokenAmount || '', + swapToAmount: this.state.toTokenAmount || '', + isSmartAccount: AccountController.state.preferredAccountType === 'smartAccount' + } + }); + + return undefined; + } + }, + + // -- Checks -------------------------------------------- // + hasInsufficientToken(sourceTokenAmount: string, sourceTokenAddress: string) { + const isInsufficientSourceTokenForSwap = SwapCalculationUtil.isInsufficientSourceTokenForSwap( + sourceTokenAmount, + sourceTokenAddress, + state.myTokensWithBalance + ); + + let insufficientNetworkTokenForGas = true; + if (AccountController.state.preferredAccountType === 'smartAccount') { + // Smart Accounts may pay gas in any ERC20 token + insufficientNetworkTokenForGas = false; + } else { + insufficientNetworkTokenForGas = SwapCalculationUtil.isInsufficientNetworkTokenForGas( + state.networkBalanceInUSD, + state.gasPriceInUSD + ); + } + + return insufficientNetworkTokenForGas || isInsufficientSourceTokenForSwap; + }, + + // -- Calculations -------------------------------------- // + setTransactionDetails() { + const { toTokenAddress, toTokenDecimals } = this.getParams(); + + if (!toTokenAddress || !toTokenDecimals) { + return; + } + + state.gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD( + state.networkPrice, + BigInt(state.gasFee), + BigInt(INITIAL_GAS_LIMIT) + ); + state.priceImpact = SwapCalculationUtil.getPriceImpact({ + sourceTokenAmount: state.sourceTokenAmount, + sourceTokenPriceInUSD: state.sourceTokenPriceInUSD, + toTokenPriceInUSD: state.toTokenPriceInUSD, + toTokenAmount: state.toTokenAmount + }); + state.maxSlippage = SwapCalculationUtil.getMaxSlippage(state.slippage, state.toTokenAmount); + state.providerFee = SwapCalculationUtil.getProviderFee(state.sourceTokenAmount); } }; diff --git a/packages/core/src/controllers/ThemeController.ts b/packages/core/src/controllers/ThemeController.ts index 53871dbb7..f3453b009 100644 --- a/packages/core/src/controllers/ThemeController.ts +++ b/packages/core/src/controllers/ThemeController.ts @@ -1,15 +1,15 @@ import { proxy, subscribe as sub } from 'valtio'; -import type { ThemeMode, ThemeVariables } from '../utils/TypeUtil'; +import type { ThemeMode, ThemeVariables } from '@reown/appkit-common-react-native'; // -- Types --------------------------------------------- // export interface ThemeControllerState { - themeMode: ThemeMode; + themeMode?: ThemeMode; themeVariables: ThemeVariables; } // -- State --------------------------------------------- // const state = proxy({ - themeMode: 'dark', + themeMode: undefined, themeVariables: {} }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 236c2789e..2a311bd73 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,10 +61,12 @@ export { WebviewController, type WebviewControllerState } from './controllers/We // -- Utils ------------------------------------------------------------------- export { ApiUtil } from './utils/ApiUtil'; export { AssetUtil } from './utils/AssetUtil'; +export { ConnectionUtil } from './utils/ConnectionUtil'; export { ConstantsUtil } from './utils/ConstantsUtil'; export { CoreHelperUtil } from './utils/CoreHelperUtil'; export { StorageUtil } from './utils/StorageUtil'; export { EventUtil } from './utils/EventUtil'; export { RouterUtil } from './utils/RouterUtil'; +export { NetworkUtil } from './utils/NetworkUtil'; export type * from './utils/TypeUtil'; diff --git a/packages/core/src/utils/ConnectionUtil.ts b/packages/core/src/utils/ConnectionUtil.ts new file mode 100644 index 000000000..0803b6998 --- /dev/null +++ b/packages/core/src/utils/ConnectionUtil.ts @@ -0,0 +1,27 @@ +import { AccountController } from '../controllers/AccountController'; +import { ConnectionController } from '../controllers/ConnectionController'; +import { EventsController } from '../controllers/EventsController'; +import { ModalController } from '../controllers/ModalController'; +import { RouterController } from '../controllers/RouterController'; +import { TransactionsController } from '../controllers/TransactionsController'; + +export const ConnectionUtil = { + async disconnect() { + try { + await ConnectionController.disconnect(); + ModalController.close(); + AccountController.setIsConnected(false); + RouterController.reset('Connect'); + TransactionsController.resetTransactions(); + EventsController.sendEvent({ + type: 'track', + event: 'DISCONNECT_SUCCESS' + }); + } catch (error) { + EventsController.sendEvent({ + type: 'track', + event: 'DISCONNECT_ERROR' + }); + } + } +}; diff --git a/packages/core/src/utils/ConstantsUtil.ts b/packages/core/src/utils/ConstantsUtil.ts index e580d1077..d802a5e56 100644 --- a/packages/core/src/utils/ConstantsUtil.ts +++ b/packages/core/src/utils/ConstantsUtil.ts @@ -1,6 +1,7 @@ import type { Features } from './TypeUtil'; const defaultFeatures: Features = { + swaps: true, email: true, emailShowWallets: true, socials: ['x', 'discord', 'apple'] @@ -19,5 +20,124 @@ export const ConstantsUtil = { NATIVE_TOKEN_ADDRESS: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + SWAP_SUGGESTED_TOKENS: [ + 'ETH', + 'UNI', + '1INCH', + 'AAVE', + 'SOL', + 'ADA', + 'AVAX', + 'DOT', + 'LINK', + 'NITRO', + 'GAIA', + 'MILK', + 'TRX', + 'NEAR', + 'GNO', + 'WBTC', + 'DAI', + 'WETH', + 'USDC', + 'USDT', + 'ARB', + 'BAL', + 'BICO', + 'CRV', + 'ENS', + 'MATIC', + 'OP' + ], + + SWAP_POPULAR_TOKENS: [ + 'ETH', + 'UNI', + '1INCH', + 'AAVE', + 'SOL', + 'ADA', + 'AVAX', + 'DOT', + 'LINK', + 'NITRO', + 'GAIA', + 'MILK', + 'TRX', + 'NEAR', + 'GNO', + 'WBTC', + 'DAI', + 'WETH', + 'USDC', + 'USDT', + 'ARB', + 'BAL', + 'BICO', + 'CRV', + 'ENS', + 'MATIC', + 'OP', + 'METAL', + 'DAI', + 'CHAMP', + 'WOLF', + 'SALE', + 'BAL', + 'BUSD', + 'MUST', + 'BTCpx', + 'ROUTE', + 'HEX', + 'WELT', + 'amDAI', + 'VSQ', + 'VISION', + 'AURUM', + 'pSP', + 'SNX', + 'VC', + 'LINK', + 'CHP', + 'amUSDT', + 'SPHERE', + 'FOX', + 'GIDDY', + 'GFC', + 'OMEN', + 'OX_OLD', + 'DE', + 'WNT' + ], + + SWAP_SUPPORTED_NETWORKS: [ + // Ethereum' + 'eip155:1', + // Arbitrum One' + 'eip155:42161', + // Optimism' + 'eip155:10', + // ZKSync Era' + 'eip155:324', + // Base' + 'eip155:8453', + // BNB Smart Chain' + 'eip155:56', + // Polygon' + 'eip155:137', + // Gnosis' + 'eip155:100', + // Avalanche' + 'eip155:43114', + // Fantom' + 'eip155:250', + // Klaytn' + 'eip155:8217', + // Aurora + 'eip155:1313161554' + ], + + CONVERT_SLIPPAGE_TOLERANCE: 1, + DEFAULT_FEATURES: defaultFeatures }; diff --git a/packages/core/src/utils/CoreHelperUtil.ts b/packages/core/src/utils/CoreHelperUtil.ts index 99eb1255b..c362fadba 100644 --- a/packages/core/src/utils/CoreHelperUtil.ts +++ b/packages/core/src/utils/CoreHelperUtil.ts @@ -4,7 +4,7 @@ import { Linking, Platform } from 'react-native'; import { ConstantsUtil as CommonConstants, type Balance } from '@reown/appkit-common-react-native'; import { ConstantsUtil } from './ConstantsUtil'; -import type { CaipAddress, DataWallet, LinkingRecord } from './TypeUtil'; +import type { CaipAddress, CaipNetwork, DataWallet, LinkingRecord } from './TypeUtil'; // -- Helpers ----------------------------------------------------------------- async function isAppInstalledIos(deepLink?: string): Promise { @@ -57,20 +57,6 @@ export const CoreHelperUtil = { }); }, - debounce(func: (...args: any[]) => unknown, timeout = 500) { - let timer: ReturnType | undefined; - - return (...args: unknown[]) => { - function next() { - func(...args); - } - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(next, timeout); - }; - }, - isHttpUrl(url: string) { return url.startsWith('http://') || url.startsWith('https://'); }, @@ -282,5 +268,24 @@ export const CoreHelperUtil = { const [dollars, pennies] = roundedNumber.split('.'); return { dollars, pennies }; + }, + + sortNetworks( + approvedCaipNetworkIds: `${string}:${string}`[] | undefined, + requestedCaipNetworks: CaipNetwork[] = [] + ) { + const approvedIds = approvedCaipNetworkIds; + const requested = [...requestedCaipNetworks]; + + if (approvedIds?.length) { + requested?.sort((a, b) => { + if (approvedIds.includes(a.id) && !approvedIds.includes(b.id)) return -1; + if (approvedIds.includes(b.id) && !approvedIds.includes(a.id)) return 1; + + return 0; + }); + } + + return requested; } }; diff --git a/packages/core/src/utils/NetworkUtil.ts b/packages/core/src/utils/NetworkUtil.ts new file mode 100644 index 000000000..4ab2b5b48 --- /dev/null +++ b/packages/core/src/utils/NetworkUtil.ts @@ -0,0 +1,33 @@ +import { RouterUtil } from './RouterUtil'; +import { RouterController } from '../controllers/RouterController'; +import { NetworkController } from '../controllers/NetworkController'; +import { AccountController } from '../controllers/AccountController'; +import { ConnectorController } from '../controllers/ConnectorController'; +import { SwapController } from '../controllers/SwapController'; +import type { CaipNetwork } from '../utils/TypeUtil'; + +export const NetworkUtil = { + async handleNetworkSwitch(network: CaipNetwork) { + const { isConnected } = AccountController.state; + const { caipNetwork, approvedCaipNetworkIds, supportsAllNetworks } = NetworkController.state; + const isAuthConnected = ConnectorController.state.connectedConnector === 'AUTH'; + let eventData = null; + + if (isConnected && caipNetwork?.id !== network.id) { + if (approvedCaipNetworkIds?.includes(network.id) && !isAuthConnected) { + await NetworkController.switchActiveNetwork(network); + RouterUtil.navigateAfterNetworkSwitch(['ConnectingSiwe']); + eventData = { type: 'SWITCH_NETWORK', networkId: network.id }; + } else if (supportsAllNetworks || isAuthConnected) { + RouterController.push('SwitchNetwork', { network }); + } + } else if (!isConnected) { + NetworkController.setCaipNetwork(network); + RouterController.push('Connect'); + } + + SwapController.resetState(); + + return eventData; + } +}; diff --git a/packages/core/src/utils/RouterUtil.ts b/packages/core/src/utils/RouterUtil.ts index 340b7b3d7..c0e61b629 100644 --- a/packages/core/src/utils/RouterUtil.ts +++ b/packages/core/src/utils/RouterUtil.ts @@ -8,7 +8,10 @@ export const RouterUtil = { } const { history } = RouterController.state; - const networkSelectIndex = history.findIndex(name => name === 'Networks'); + const networkSelectIndex = history.findIndex( + name => name === 'Networks' || name === 'UnsupportedChain' + ); + if (networkSelectIndex >= 1) { RouterController.goBackToIndex(networkSelectIndex - 1); } else { diff --git a/packages/core/src/utils/SwapApiUtil.ts b/packages/core/src/utils/SwapApiUtil.ts index 3c47f65c9..be33994bc 100644 --- a/packages/core/src/utils/SwapApiUtil.ts +++ b/packages/core/src/utils/SwapApiUtil.ts @@ -1,8 +1,97 @@ import { BlockchainApiController } from '../controllers/BlockchainApiController'; import { OptionsController } from '../controllers/OptionsController'; import { NetworkController } from '../controllers/NetworkController'; +import type { + BlockchainApiBalanceResponse, + BlockchainApiSwapAllowanceRequest, + SwapTokenWithBalance +} from './TypeUtil'; +import { AccountController } from '../controllers/AccountController'; +import { ConnectionController } from '../controllers/ConnectionController'; export const SwapApiUtil = { + async getTokenList() { + const response = await BlockchainApiController.fetchSwapTokens({ + projectId: OptionsController.state.projectId, + chainId: NetworkController.state.caipNetwork?.id + }); + const tokens = + response?.tokens?.map( + token => + ({ + ...token, + eip2612: false, + quantity: { + decimals: '0', + numeric: '0' + }, + price: 0, + value: 0 + }) as SwapTokenWithBalance + ) || []; + + return tokens; + }, + + async fetchSwapAllowance({ + tokenAddress, + userAddress, + sourceTokenAmount, + sourceTokenDecimals + }: Pick & { + sourceTokenAmount: string; + sourceTokenDecimals: number; + }) { + const projectId = OptionsController.state.projectId; + + const response = await BlockchainApiController.fetchSwapAllowance({ + projectId, + tokenAddress, + userAddress + }); + + if (response?.allowance && sourceTokenAmount && sourceTokenDecimals) { + const parsedValue = + ConnectionController.parseUnits(sourceTokenAmount, sourceTokenDecimals) || 0; + const hasAllowance = BigInt(response.allowance) >= parsedValue; + + return hasAllowance; + } + + return false; + }, + + async getMyTokensWithBalance(forceUpdate?: string) { + const address = AccountController.state.address; + const chainId = NetworkController.state.caipNetwork?.id; + + if (!address) { + return []; + } + + const response = await BlockchainApiController.getBalance(address, chainId, forceUpdate); + const balances = response?.balances.filter(balance => balance.quantity.decimals !== '0'); + + AccountController.setTokenBalance(balances); + + return this.mapBalancesToSwapTokens(balances); + }, + + mapBalancesToSwapTokens(balances?: BlockchainApiBalanceResponse['balances']) { + return ( + balances?.map( + token => + ({ + ...token, + address: token?.address || NetworkController.getActiveNetworkTokenAddress(), + decimals: parseInt(token.quantity.decimals, 10), + logoUri: token.iconUrl, + eip2612: false + }) as SwapTokenWithBalance + ) || [] + ); + }, + async fetchGasPrice() { const projectId = OptionsController.state.projectId; const caipNetwork = NetworkController.state.caipNetwork; diff --git a/packages/core/src/utils/SwapCalculationUtil.ts b/packages/core/src/utils/SwapCalculationUtil.ts index b946c6562..9fa64e136 100644 --- a/packages/core/src/utils/SwapCalculationUtil.ts +++ b/packages/core/src/utils/SwapCalculationUtil.ts @@ -1,6 +1,7 @@ // -- Types --------------------------------------------- // import { NumberUtil } from '@reown/appkit-common-react-native'; +import type { SwapTokenWithBalance } from './TypeUtil'; // -- Util ---------------------------------------- // export const SwapCalculationUtil = { @@ -17,5 +18,129 @@ export const SwapCalculationUtil = { const gasCostInUSD = networkPriceInUSD.multipliedBy(totalGasCostInEther); return gasCostInUSD.toNumber(); + }, + + getPriceImpact({ + sourceTokenAmount, + sourceTokenPriceInUSD, + toTokenPriceInUSD, + toTokenAmount + }: { + sourceTokenAmount: string; + sourceTokenPriceInUSD: number; + toTokenPriceInUSD: number; + toTokenAmount: string; + }) { + const inputValue = NumberUtil.bigNumber(sourceTokenAmount).multipliedBy(sourceTokenPriceInUSD); + const outputValue = NumberUtil.bigNumber(toTokenAmount).multipliedBy(toTokenPriceInUSD); + const priceImpact = inputValue.minus(outputValue).dividedBy(inputValue).multipliedBy(100); + + return priceImpact.toNumber(); + }, + + getMaxSlippage(slippage: number, toTokenAmount: string) { + const slippageToleranceDecimal = NumberUtil.bigNumber(slippage).dividedBy(100); + const maxSlippageAmount = NumberUtil.multiply(toTokenAmount, slippageToleranceDecimal); + + return maxSlippageAmount.toNumber(); + }, + + getProviderFee(sourceTokenAmount: string, feePercentage = 0.0085) { + const providerFee = NumberUtil.bigNumber(sourceTokenAmount).multipliedBy(feePercentage); + + return providerFee.toString(); + }, + + getProviderFeePrice( + sourceTokenAmount: string, + sourceTokenPriceInUSD: number, + feePercentage = 0.0085 + ) { + const providerFee = SwapCalculationUtil.getProviderFee(sourceTokenAmount, feePercentage); + const providerFeePrice = NumberUtil.bigNumber(providerFee).multipliedBy(sourceTokenPriceInUSD); + + return providerFeePrice.toNumber(); + }, + + isInsufficientNetworkTokenForGas(networkBalanceInUSD: string, gasPriceInUSD: number | undefined) { + const gasPrice = gasPriceInUSD ?? '0'; + + if (NumberUtil.bigNumber(networkBalanceInUSD).isZero()) { + return true; + } + + return NumberUtil.bigNumber(NumberUtil.bigNumber(gasPrice)).isGreaterThan(networkBalanceInUSD); + }, + + isInsufficientSourceTokenForSwap( + sourceTokenAmount: string, + sourceTokenAddress: string, + balance: SwapTokenWithBalance[] | undefined + ) { + const sourceTokenBalance = balance?.find(token => token.address === sourceTokenAddress) + ?.quantity?.numeric; + + const isInSufficientBalance = NumberUtil.bigNumber(sourceTokenBalance ?? '0').isLessThan( + sourceTokenAmount + ); + + return isInSufficientBalance; + }, + + getToTokenAmount({ + sourceToken, + toToken, + sourceTokenPrice, + toTokenPrice, + sourceTokenAmount + }: { + sourceToken: SwapTokenWithBalance | undefined; + toToken: SwapTokenWithBalance | undefined; + sourceTokenPrice: number; + toTokenPrice: number; + sourceTokenAmount: string; + }) { + if (sourceTokenAmount === '0') { + return '0'; + } + + if (!sourceToken || !toToken) { + return '0'; + } + + const sourceTokenDecimals = sourceToken.decimals; + const sourceTokenPriceInUSD = sourceTokenPrice; + const toTokenDecimals = toToken.decimals; + const toTokenPriceInUSD = toTokenPrice; + + if (toTokenPriceInUSD <= 0) { + return '0'; + } + + // Calculate the provider fee (0.85% of the source token amount) + const providerFee = NumberUtil.bigNumber(sourceTokenAmount).multipliedBy(0.0085); + + // Adjust the source token amount by subtracting the provider fee + const adjustedSourceTokenAmount = NumberUtil.bigNumber(sourceTokenAmount).minus(providerFee); + + // Proceed with conversion using the adjusted source token amount + const sourceAmountInSmallestUnit = adjustedSourceTokenAmount.multipliedBy( + NumberUtil.bigNumber(10).pow(sourceTokenDecimals) + ); + + const priceRatio = NumberUtil.bigNumber(sourceTokenPriceInUSD).dividedBy(toTokenPriceInUSD); + + const decimalDifference = sourceTokenDecimals - toTokenDecimals; + const toTokenAmountInSmallestUnit = sourceAmountInSmallestUnit + .multipliedBy(priceRatio) + .dividedBy(NumberUtil.bigNumber(10).pow(decimalDifference)); + + const toTokenAmount = toTokenAmountInSmallestUnit.dividedBy( + NumberUtil.bigNumber(10).pow(toTokenDecimals) + ); + + const amount = toTokenAmount.toFixed(toTokenDecimals).toString(); + + return amount; } }; diff --git a/packages/core/src/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index 0cd0e363c..41c6e6261 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -1,5 +1,10 @@ import { type EventEmitter } from 'events'; -import type { Balance, SocialProvider, Transaction } from '@reown/appkit-common-react-native'; +import type { + Balance, + SocialProvider, + ThemeMode, + Transaction +} from '@reown/appkit-common-react-native'; export interface BaseError { message?: string; @@ -66,6 +71,11 @@ export type SdkVersion = type EnabledSocials = Exclude; export type Features = { + /** + * @description Enable or disable the swaps feature. Enabled by default. + * @type {boolean} + */ + swaps?: boolean; /** * @description Enable or disable the email feature. Enabled by default. * @type {boolean} @@ -135,14 +145,6 @@ export type RequestCache = | 'only-if-cached' | 'reload'; -// -- ThemeController Types --------------------------------------------------- - -export type ThemeMode = 'dark' | 'light'; - -export interface ThemeVariables { - accent?: string; -} - // -- BlockchainApiController Types --------------------------------------------- export interface BlockchainApiIdentityRequest { address: string; @@ -171,6 +173,56 @@ export interface BlockchainApiTransactionsResponse { next: string | null; } +export interface BlockchainApiSwapAllowanceResponse { + allowance: string; +} + +export interface BlockchainApiGenerateSwapCalldataRequest { + projectId: string; + userAddress: string; + from: string; + to: string; + amount: string; + eip155?: { + slippage: string; + permit?: string; + }; +} + +export interface BlockchainApiGenerateSwapCalldataResponse { + tx: { + from: CaipAddress; + to: CaipAddress; + data: `0x${string}`; + amount: string; + eip155: { + gas: string; + gasPrice: string; + }; + }; +} + +export interface BlockchainApiGenerateApproveCalldataRequest { + projectId: string; + userAddress: string; + from: string; + to: string; + amount?: number; +} + +export interface BlockchainApiGenerateApproveCalldataResponse { + tx: { + from: CaipAddress; + to: CaipAddress; + data: `0x${string}`; + value: string; + eip155: { + gas: number; + gasPrice: string; + }; + }; +} + export interface BlockchainApiTokenPriceRequest { projectId: string; currency?: 'usd' | 'eur' | 'gbp' | 'aud' | 'cad' | 'inr' | 'jpy' | 'btc' | 'eth'; @@ -186,6 +238,12 @@ export interface BlockchainApiTokenPriceResponse { }[]; } +export interface BlockchainApiSwapAllowanceRequest { + projectId: string; + tokenAddress: string; + userAddress: string; +} + export interface BlockchainApiGasPriceRequest { projectId: string; chainId: string; @@ -221,6 +279,35 @@ export interface BlockchainApiLookupEnsName { }[]; } +export interface BlockchainApiSwapQuoteRequest { + projectId: string; + chainId?: string; + amount: string; + userAddress: string; + from: string; + to: string; + gasPrice: string; +} + +export interface BlockchainApiSwapQuoteResponse { + quotes: { + id: string | null; + fromAmount: string; + fromAccount: string; + toAmount: string; + toAccount: string; + }[]; +} + +export interface BlockchainApiSwapTokensRequest { + projectId: string; + chainId?: string; +} + +export interface BlockchainApiSwapTokensResponse { + tokens: SwapToken[]; +} + // -- OptionsController Types --------------------------------------------------- export interface Token { address: string; @@ -443,6 +530,51 @@ export type Event = network: string; }; } + | { + type: 'track'; + event: 'OPEN_SWAP'; + properties: { + isSmartAccount: boolean; + network: string; + }; + } + | { + type: 'track'; + event: 'INITIATE_SWAP'; + properties: { + isSmartAccount: boolean; + network: string; + swapFromToken: string; + swapToToken: string; + swapFromAmount: string; + swapToAmount: string; + }; + } + | { + type: 'track'; + event: 'SWAP_SUCCESS'; + properties: { + isSmartAccount: boolean; + network: string; + swapFromToken: string; + swapToToken: string; + swapFromAmount: string; + swapToAmount: string; + }; + } + | { + type: 'track'; + event: 'SWAP_ERROR'; + properties: { + isSmartAccount: boolean; + network: string; + swapFromToken: string; + swapToToken: string; + swapFromAmount: string; + swapToAmount: string; + message: string; + }; + } | { type: 'track'; event: 'SEND_INITIATED'; @@ -518,6 +650,12 @@ export type Event = }; // -- Send Controller Types ------------------------------------- +export type EstimateGasTransactionArgs = { + chainNamespace?: 'eip155'; + address: `0x${string}`; + to: `0x${string}`; + data: `0x${string}`; +}; export interface SendTransactionArgs { to: `0x${string}`; @@ -526,6 +664,7 @@ export interface SendTransactionArgs { gas?: bigint; gasPrice: bigint; address: `0x${string}`; + chainNamespace?: 'eip155'; } export interface WriteContractArgs { @@ -537,6 +676,27 @@ export interface WriteContractArgs { abi: any; } +// -- Swap Controller Types ------------------------------------- +export type SwapToken = { + name: string; + symbol: string; + address: CaipAddress; + decimals: number; + logoUri: string; + eip2612?: boolean; +}; + +export type SwapTokenWithBalance = SwapToken & { + quantity: { + decimals: string; + numeric: string; + }; + price: number; + value: number; +}; + +export type SwapInputTarget = 'sourceToken' | 'toToken'; + // -- Email Types ------------------------------------------------ /** * Matches type defined for packages/wallet/src/AppKitFrameProvider.ts @@ -552,7 +712,6 @@ export interface AppKitFrameProvider { getSecureSiteURL(): string; getSecureSiteDashboardURL(): string; getSecureSiteIconURL(): string; - getSecureSiteHeaders(): Record; getEmail(): string | undefined; getUsername(): string | undefined; getLastUsedChainId(): Promise; diff --git a/packages/ethers/src/client.ts b/packages/ethers/src/client.ts index 0e564e183..846fd8a26 100644 --- a/packages/ethers/src/client.ts +++ b/packages/ethers/src/client.ts @@ -25,7 +25,8 @@ import { type Token, AppKitScaffold, type WriteContractArgs, - type AppKitFrameAccountType + type AppKitFrameAccountType, + type EstimateGasTransactionArgs } from '@reown/appkit-scaffold-react-native'; import { erc20ABI, ErrorUtil, NamesUtil, NetworkUtil } from '@reown/appkit-common-react-native'; import { @@ -287,6 +288,45 @@ export class AppKit extends AppKitScaffold { return signature as `0x${string}`; }, + estimateGas: async ({ + address, + to, + data, + chainNamespace + }: EstimateGasTransactionArgs): Promise => { + const caipNetwork = this.getCaipNetwork(); + const provider = EthersStoreUtil.state.provider; + + if (!provider) { + throw new Error('Provider is undefined'); + } + + try { + if (!provider) { + throw new Error('estimateGas - provider is undefined'); + } + if (!address) { + throw new Error('estimateGas - address is undefined'); + } + if (chainNamespace && chainNamespace !== 'eip155') { + throw new Error('estimateGas - chainNamespace is not eip155'); + } + + const txParams = { + from: address, + to, + data, + type: 0 + }; + const browserProvider = new BrowserProvider(provider, Number(caipNetwork?.id)); + const signer = new JsonRpcSigner(browserProvider, address); + + return await signer.estimateGas(txParams); + } catch (error) { + throw new Error('Ethers: estimateGas - Estimate gas failed'); + } + }, + parseUnits: (value: string, decimals: number) => parseUnits(value, decimals), formatUnits: (value: bigint, decimals: number) => formatUnits(value, decimals), @@ -972,6 +1012,7 @@ export class AppKit extends AppKitScaffold { authProvider.setOnTimeout(async () => { this.handleAlertError(ErrorUtil.ALERT_ERRORS.SOCIALS_TIMEOUT); + this.setLoading(false); }); } diff --git a/packages/ethers5/src/client.ts b/packages/ethers5/src/client.ts index 23f42933c..083096c2f 100644 --- a/packages/ethers5/src/client.ts +++ b/packages/ethers5/src/client.ts @@ -6,6 +6,7 @@ import { type CaipNetworkId, type ConnectionControllerClient, type Connector, + type EstimateGasTransactionArgs, type LibraryOptions, type NetworkControllerClient, type PublicStateControllerState, @@ -275,6 +276,45 @@ export class AppKit extends AppKitScaffold { return signature as `0x${string}`; }, + estimateGas: async ({ + address, + to, + data, + chainNamespace + }: EstimateGasTransactionArgs): Promise => { + const networkId = EthersStoreUtil.state.chainId; + const provider = EthersStoreUtil.state.provider; + + if (!provider) { + throw new Error('Provider is undefined'); + } + + try { + if (!provider) { + throw new Error('estimateGas - provider is undefined'); + } + if (!address) { + throw new Error('estimateGas - address is undefined'); + } + if (chainNamespace && chainNamespace !== 'eip155') { + throw new Error('estimateGas - chainNamespace is not eip155'); + } + + const txParams = { + from: address, + to, + data, + type: 0 + }; + const browserProvider = new ethers.providers.Web3Provider(provider, networkId); + const signer = browserProvider.getSigner(address); + + return (await signer.estimateGas(txParams)).toBigInt(); + } catch (error) { + throw new Error('Ethers: estimateGas - Estimate gas failed'); + } + }, + parseUnits: (value: string, decimals: number) => ethers.utils.parseUnits(value, decimals).toBigInt(), @@ -949,6 +989,7 @@ export class AppKit extends AppKitScaffold { authProvider.setOnTimeout(async () => { this.handleAlertError(ErrorUtil.ALERT_ERRORS.SOCIALS_TIMEOUT); + this.setLoading(false); }); } diff --git a/packages/scaffold/src/client.ts b/packages/scaffold/src/client.ts index 80d682707..a5a3fc9e4 100644 --- a/packages/scaffold/src/client.ts +++ b/packages/scaffold/src/client.ts @@ -10,8 +10,6 @@ import type { EventsControllerState, PublicStateControllerState, ThemeControllerState, - ThemeMode, - ThemeVariables, Connector, ConnectedWalletInfo, Features @@ -33,7 +31,12 @@ import { ThemeController, TransactionsController } from '@reown/appkit-core-react-native'; -import { ConstantsUtil, ErrorUtil } from '@reown/appkit-common-react-native'; +import { + ConstantsUtil, + ErrorUtil, + type ThemeMode, + type ThemeVariables +} from '@reown/appkit-common-react-native'; // -- Types --------------------------------------------------------------------- export interface LibraryOptions { @@ -61,7 +64,7 @@ export interface ScaffoldOptions extends LibraryOptions { } export interface OpenOptions { - view: 'Account' | 'Connect' | 'Networks'; + view: 'Account' | 'Connect' | 'Networks' | 'Swap'; } // -- Client -------------------------------------------------------------------- diff --git a/packages/scaffold/src/hooks/useDebounceCallback.ts b/packages/scaffold/src/hooks/useDebounceCallback.ts index 70be71f31..caf8ed594 100644 --- a/packages/scaffold/src/hooks/useDebounceCallback.ts +++ b/packages/scaffold/src/hooks/useDebounceCallback.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; interface Props { - callback: ((args: any) => any) | ((args: any) => Promise); + callback: ((args?: any) => any) | ((args?: any) => Promise); delay?: number; } @@ -14,7 +14,7 @@ export function useDebounceCallback({ callback, delay = 250 }: Props) { }, [callback]); const debouncedCallback = useCallback( - (args: any) => { + (args?: any) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } diff --git a/packages/scaffold/src/modal/w3m-account-button/index.tsx b/packages/scaffold/src/modal/w3m-account-button/index.tsx index 329ffb3b2..8bb37376d 100644 --- a/packages/scaffold/src/modal/w3m-account-button/index.tsx +++ b/packages/scaffold/src/modal/w3m-account-button/index.tsx @@ -4,10 +4,11 @@ import { CoreHelperUtil, NetworkController, ModalController, - AssetUtil + AssetUtil, + ThemeController } from '@reown/appkit-core-react-native'; -import { AccountButton as AccountButtonUI } from '@reown/appkit-ui-react-native'; +import { AccountButton as AccountButtonUI, ThemeProvider } from '@reown/appkit-ui-react-native'; import { ApiController } from '@reown/appkit-core-react-native'; import type { StyleProp, ViewStyle } from 'react-native'; @@ -27,22 +28,25 @@ export function AccountButton({ balance, disabled, style, testID }: AccountButto profileName } = useSnapshot(AccountController.state); const { caipNetwork } = useSnapshot(NetworkController.state); + const { themeMode, themeVariables } = useSnapshot(ThemeController.state); const networkImage = AssetUtil.getNetworkImage(caipNetwork); const showBalance = balance === 'show'; return ( - ModalController.open()} - address={address} - profileName={profileName} - networkSrc={networkImage} - imageHeaders={ApiController._getApiHeaders()} - avatarSrc={profileImage} - disabled={disabled} - style={style} - balance={showBalance ? CoreHelperUtil.formatBalance(balanceVal, balanceSymbol) : ''} - testID={testID} - /> + + ModalController.open()} + address={address} + profileName={profileName} + networkSrc={networkImage} + imageHeaders={ApiController._getApiHeaders()} + avatarSrc={profileImage} + disabled={disabled} + style={style} + balance={showBalance ? CoreHelperUtil.formatBalance(balanceVal, balanceSymbol) : ''} + testID={testID} + /> + ); } diff --git a/packages/scaffold/src/modal/w3m-connect-button/index.tsx b/packages/scaffold/src/modal/w3m-connect-button/index.tsx index bd699abf2..98f0c0e10 100644 --- a/packages/scaffold/src/modal/w3m-connect-button/index.tsx +++ b/packages/scaffold/src/modal/w3m-connect-button/index.tsx @@ -1,7 +1,8 @@ import { useSnapshot } from 'valtio'; -import { ModalController } from '@reown/appkit-core-react-native'; +import { ModalController, ThemeController } from '@reown/appkit-core-react-native'; import { ConnectButton as ConnectButtonUI, + ThemeProvider, type ConnectButtonProps as ConnectButtonUIProps } from '@reown/appkit-ui-react-native'; @@ -23,17 +24,20 @@ export function ConnectButton({ testID }: ConnectButtonProps) { const { open, loading } = useSnapshot(ModalController.state); + const { themeMode, themeVariables } = useSnapshot(ThemeController.state); return ( - ModalController.open()} - size={size} - loading={loading || open} - style={style} - testID={testID} - disabled={disabled} - > - {loading || open ? loadingLabel : label} - + + ModalController.open()} + size={size} + loading={loading || open} + style={style} + testID={testID} + disabled={disabled} + > + {loading || open ? loadingLabel : label} + + ); } diff --git a/packages/scaffold/src/modal/w3m-modal/index.tsx b/packages/scaffold/src/modal/w3m-modal/index.tsx index 036d6a576..fe2db865c 100644 --- a/packages/scaffold/src/modal/w3m-modal/index.tsx +++ b/packages/scaffold/src/modal/w3m-modal/index.tsx @@ -2,7 +2,7 @@ import { useSnapshot } from 'valtio'; import { useCallback, useEffect } from 'react'; import { useWindowDimensions, StatusBar } from 'react-native'; import Modal from 'react-native-modal'; -import { Card } from '@reown/appkit-ui-react-native'; +import { Card, ThemeProvider } from '@reown/appkit-ui-react-native'; import { AccountController, ApiController, @@ -16,7 +16,8 @@ import { TransactionsController, type CaipAddress, type AppKitFrameProvider, - WebviewController + WebviewController, + ThemeController } from '@reown/appkit-core-react-native'; import { SIWEController } from '@reown/appkit-siwe-react-native'; @@ -31,6 +32,7 @@ export function AppKit() { const { connectors, connectedConnector } = useSnapshot(ConnectorController.state); const { caipAddress, isConnected } = useSnapshot(AccountController.state); const { frameViewVisible, webviewVisible } = useSnapshot(WebviewController.state); + const { themeMode, themeVariables } = useSnapshot(ThemeController.state); const { height } = useWindowDimensions(); const { isLandscape } = useCustomDimensions(); const portraitHeight = height - 120; @@ -117,27 +119,32 @@ export function AppKit() { return ( <> - - -
- - - - - {!!showAuth && AuthView && } - {!!showAuth && SocialView && } + + + +
+ + + + + {!!showAuth && AuthView && } + {!!showAuth && SocialView && } + ); } diff --git a/packages/scaffold/src/modal/w3m-network-button/index.tsx b/packages/scaffold/src/modal/w3m-network-button/index.tsx index ddffa20f2..353a18047 100644 --- a/packages/scaffold/src/modal/w3m-network-button/index.tsx +++ b/packages/scaffold/src/modal/w3m-network-button/index.tsx @@ -6,9 +6,10 @@ import { AssetUtil, EventsController, ModalController, - NetworkController + NetworkController, + ThemeController } from '@reown/appkit-core-react-native'; -import { NetworkButton as NetworkButtonUI } from '@reown/appkit-ui-react-native'; +import { NetworkButton as NetworkButtonUI, ThemeProvider } from '@reown/appkit-ui-react-native'; export interface NetworkButtonProps { disabled?: boolean; @@ -19,6 +20,7 @@ export function NetworkButton({ disabled, style }: NetworkButtonProps) { const { isConnected } = useSnapshot(AccountController.state); const { caipNetwork } = useSnapshot(NetworkController.state); const { loading } = useSnapshot(ModalController.state); + const { themeMode, themeVariables } = useSnapshot(ThemeController.state); const onNetworkPress = () => { ModalController.open({ view: 'Networks' }); @@ -29,16 +31,18 @@ export function NetworkButton({ disabled, style }: NetworkButtonProps) { }; return ( - - {caipNetwork?.name ?? (isConnected ? 'Unknown Network' : 'Select Network')} - + + + {caipNetwork?.name ?? (isConnected ? 'Unknown Network' : 'Select Network')} + + ); } diff --git a/packages/scaffold/src/modal/w3m-router/index.tsx b/packages/scaffold/src/modal/w3m-router/index.tsx index 5cc2a4a4f..d82091cf7 100644 --- a/packages/scaffold/src/modal/w3m-router/index.tsx +++ b/packages/scaffold/src/modal/w3m-router/index.tsx @@ -18,12 +18,16 @@ import { EmailVerifyDeviceView } from '../../views/w3m-email-verify-device-view' import { GetWalletView } from '../../views/w3m-get-wallet-view'; import { NetworksView } from '../../views/w3m-networks-view'; import { NetworkSwitchView } from '../../views/w3m-network-switch-view'; +import { SwapView } from '../../views/w3m-swap-view'; +import { SwapPreviewView } from '../../views/w3m-swap-preview-view'; +import { SwapSelectTokenView } from '../../views/w3m-swap-select-token-view'; +import { TransactionsView } from '../../views/w3m-transactions-view'; +import { UnsupportedChainView } from '../../views/w3m-unsupported-chain-view'; import { UpdateEmailWalletView } from '../../views/w3m-update-email-wallet-view'; import { UpdateEmailPrimaryOtpView } from '../../views/w3m-update-email-primary-otp-view'; import { UpdateEmailSecondaryOtpView } from '../../views/w3m-update-email-secondary-otp-view'; import { UpgradeEmailWalletView } from '../../views/w3m-upgrade-email-wallet-view'; import { UpgradeToSmartAccountView } from '../../views/w3m-upgrade-to-smart-account-view'; -import { TransactionsView } from '../../views/w3m-transactions-view'; import { WalletCompatibleNetworks } from '../../views/w3m-wallet-compatible-networks-view'; import { WalletReceiveView } from '../../views/w3m-wallet-receive-view'; import { WalletSendView } from '../../views/w3m-wallet-send-view'; @@ -75,8 +79,16 @@ export function AppKitRouter() { return NetworksView; case 'SwitchNetwork': return NetworkSwitchView; + case 'Swap': + return SwapView; + case 'SwapPreview': + return SwapPreviewView; + case 'SwapSelectToken': + return SwapSelectTokenView; case 'Transactions': return TransactionsView; + case 'UnsupportedChain': + return UnsupportedChainView; case 'UpdateEmailPrimaryOtp': return UpdateEmailPrimaryOtpView; case 'UpdateEmailSecondaryOtp': diff --git a/packages/scaffold/src/partials/w3m-account-activity/index.tsx b/packages/scaffold/src/partials/w3m-account-activity/index.tsx index 2e784083f..b08ff2654 100644 --- a/packages/scaffold/src/partials/w3m-account-activity/index.tsx +++ b/packages/scaffold/src/partials/w3m-account-activity/index.tsx @@ -107,24 +107,23 @@ export function AccountActivity({ style }: Props) { getTransactionListItemProps(transaction); const hasMultipleTransfers = transfers?.length > 2; + // Show only the first transfer if (hasMultipleTransfers) { - return transfers.map((transfer, index) => { - const description = TransactionUtil.getTransferDescription(transfer); + const description = TransactionUtil.getTransferDescription(transfers[0]); - return ( - - ); - }); + return ( + + ); } return ( diff --git a/packages/scaffold/src/partials/w3m-account-tokens/index.tsx b/packages/scaffold/src/partials/w3m-account-tokens/index.tsx index a1627cb94..26db07f9f 100644 --- a/packages/scaffold/src/partials/w3m-account-tokens/index.tsx +++ b/packages/scaffold/src/partials/w3m-account-tokens/index.tsx @@ -85,6 +85,7 @@ export function AccountTokens({ style }: Props) { value={token.value} amount={token.quantity.numeric} currency={token.symbol} + pressable={false} /> ))} diff --git a/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx b/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx index 462058ae7..659dddf40 100644 --- a/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx +++ b/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx @@ -3,10 +3,13 @@ import { useSnapshot } from 'valtio'; import { Balance, FlexView, IconLink, Tabs } from '@reown/appkit-ui-react-native'; import { AccountController, + ConstantsUtil, CoreHelperUtil, EventsController, NetworkController, - RouterController + OptionsController, + RouterController, + SwapController } from '@reown/appkit-core-react-native'; import type { Balance as BalanceType } from '@reown/appkit-common-react-native'; import { AccountActivity } from '../w3m-account-activity'; @@ -20,7 +23,9 @@ export interface AccountWalletFeaturesProps { export function AccountWalletFeatures() { const [activeTab, setActiveTab] = useState(0); const { tokenBalance } = useSnapshot(AccountController.state); + const { features } = useSnapshot(OptionsController.state); const balance = CoreHelperUtil.calculateAndFormatBalance(tokenBalance as BalanceType[]); + const isSwapsEnabled = features?.swaps; const onTabChange = (index: number) => { setActiveTab(index); @@ -39,6 +44,26 @@ export function AccountWalletFeatures() { }); }; + const onSwapPress = () => { + if ( + NetworkController.state.caipNetwork?.id && + !ConstantsUtil.SWAP_SUPPORTED_NETWORKS.includes(`${NetworkController.state.caipNetwork.id}`) + ) { + RouterController.push('UnsupportedChain'); + } else { + SwapController.resetState(); + EventsController.sendEvent({ + type: 'track', + event: 'OPEN_SWAP', + properties: { + network: NetworkController.state.caipNetwork?.id || '', + isSmartAccount: AccountController.state.preferredAccountType === 'smartAccount' + } + }); + RouterController.push('Swap'); + } + }; + const onSendPress = () => { EventsController.sendEvent({ type: 'track', @@ -64,6 +89,18 @@ export function AccountWalletFeatures() { justifyContent="space-around" padding={['0', 's', '0', 's']} > + {isSwapsEnabled && ( + + )} void; +} + +export function InformationModal({ + iconName, + title, + description, + visible, + onClose +}: InformationModalProps) { + const Theme = useTheme(); + + return ( + + + + {!!title && ( + + {title} + + )} + + {!!description && ( + + {description} + + )} + + + + ); +} diff --git a/packages/scaffold/src/partials/w3m-information-modal/styles.ts b/packages/scaffold/src/partials/w3m-information-modal/styles.ts new file mode 100644 index 000000000..5fe4bd34e --- /dev/null +++ b/packages/scaffold/src/partials/w3m-information-modal/styles.ts @@ -0,0 +1,22 @@ +import { Spacing } from '@reown/appkit-ui-react-native'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + modal: { + margin: 0, + justifyContent: 'flex-end' + }, + content: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + alignItems: 'center' + }, + title: { + marginTop: Spacing.s, + marginBottom: Spacing.xs + }, + button: { + marginTop: Spacing.xl, + width: '100%' + } +}); diff --git a/packages/scaffold/src/partials/w3m-input-address/index.tsx b/packages/scaffold/src/partials/w3m-send-input-address/index.tsx similarity index 95% rename from packages/scaffold/src/partials/w3m-input-address/index.tsx rename to packages/scaffold/src/partials/w3m-send-input-address/index.tsx index a7b01ab1b..fc7e81057 100644 --- a/packages/scaffold/src/partials/w3m-input-address/index.tsx +++ b/packages/scaffold/src/partials/w3m-send-input-address/index.tsx @@ -6,11 +6,11 @@ import { ConnectionController, SendController } from '@reown/appkit-core-react-n import { useDebounceCallback } from '../../hooks/useDebounceCallback'; import styles from './styles'; -export interface InputAddressProps { +export interface SendInputAddressProps { value?: string; } -export function InputAddress({ value }: InputAddressProps) { +export function SendInputAddress({ value }: SendInputAddressProps) { const Theme = useTheme(); const [inputValue, setInputValue] = useState(value); diff --git a/packages/scaffold/src/partials/w3m-input-address/styles.ts b/packages/scaffold/src/partials/w3m-send-input-address/styles.ts similarity index 100% rename from packages/scaffold/src/partials/w3m-input-address/styles.ts rename to packages/scaffold/src/partials/w3m-send-input-address/styles.ts diff --git a/packages/scaffold/src/partials/w3m-input-token/intex.tsx b/packages/scaffold/src/partials/w3m-send-input-token/index.tsx similarity index 81% rename from packages/scaffold/src/partials/w3m-input-token/intex.tsx rename to packages/scaffold/src/partials/w3m-send-input-token/index.tsx index 1e89612b9..1e754b699 100644 --- a/packages/scaffold/src/partials/w3m-input-token/intex.tsx +++ b/packages/scaffold/src/partials/w3m-send-input-token/index.tsx @@ -7,7 +7,7 @@ import { ConstantsUtil, SendController } from '@reown/appkit-core-react-native'; import { getMaxAmount, getSendValue } from './utils'; import styles from './styles'; -export interface InputTokenProps { +export interface SendInputTokenProps { token?: Balance; sendTokenAmount?: number; gasPrice?: number; @@ -15,13 +15,13 @@ export interface InputTokenProps { onTokenPress?: () => void; } -export function InputToken({ +export function SendInputToken({ token, sendTokenAmount, gasPrice, style, onTokenPress -}: InputTokenProps) { +}: SendInputTokenProps) { const Theme = useTheme(); const valueInputRef = useRef(null); const [inputValue, setInputValue] = useState(sendTokenAmount?.toString()); @@ -31,10 +31,11 @@ export function InputToken({ const onInputChange = (value: string) => { const formattedValue = value.replace(/,/g, '.'); - setInputValue(formattedValue); - Number(formattedValue) - ? SendController.setTokenAmount(Number(formattedValue)) - : SendController.setTokenAmount(undefined); + + if (Number(formattedValue) >= 0 || formattedValue === '') { + setInputValue(formattedValue); + SendController.setTokenAmount(Number(formattedValue)); + } }; const onMaxPress = () => { @@ -88,26 +89,26 @@ export function InputToken({ numberOfLines={1} autoFocus={!!token} /> - + - - - {sendValue ?? ''} - - {token && ( + {token && ( + + + {sendValue ?? ''} + {maxAmount ?? ''} Max - )} - + + )} ); } diff --git a/packages/scaffold/src/partials/w3m-input-token/styles.ts b/packages/scaffold/src/partials/w3m-send-input-token/styles.ts similarity index 100% rename from packages/scaffold/src/partials/w3m-input-token/styles.ts rename to packages/scaffold/src/partials/w3m-send-input-token/styles.ts diff --git a/packages/scaffold/src/partials/w3m-input-token/utils.ts b/packages/scaffold/src/partials/w3m-send-input-token/utils.ts similarity index 100% rename from packages/scaffold/src/partials/w3m-input-token/utils.ts rename to packages/scaffold/src/partials/w3m-send-input-token/utils.ts diff --git a/packages/scaffold/src/partials/w3m-snackbar/index.tsx b/packages/scaffold/src/partials/w3m-snackbar/index.tsx index 7d0802c80..ccf004a74 100644 --- a/packages/scaffold/src/partials/w3m-snackbar/index.tsx +++ b/packages/scaffold/src/partials/w3m-snackbar/index.tsx @@ -1,11 +1,17 @@ import { useSnapshot } from 'valtio'; import { useEffect, useMemo } from 'react'; import { Animated } from 'react-native'; -import { SnackController } from '@reown/appkit-core-react-native'; +import { SnackController, type SnackControllerState } from '@reown/appkit-core-react-native'; import { Snackbar as SnackbarComponent } from '@reown/appkit-ui-react-native'; import styles from './styles'; +const getIcon = (variant: SnackControllerState['variant']) => { + if (variant === 'loading') return 'loading'; + + return variant === 'success' ? 'checkmark' : 'close'; +}; + export function Snackbar() { const { open, message, variant, long } = useSnapshot(SnackController.state); const componentOpacity = useMemo(() => new Animated.Value(0), []); @@ -35,7 +41,7 @@ export function Snackbar() { return ( diff --git a/packages/scaffold/src/partials/w3m-swap-details/index.tsx b/packages/scaffold/src/partials/w3m-swap-details/index.tsx new file mode 100644 index 000000000..dd97e87a3 --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -0,0 +1,160 @@ +import { useSnapshot } from 'valtio'; +import { useState } from 'react'; +import { ConstantsUtil, NetworkController, SwapController } from '@reown/appkit-core-react-native'; +import { + FlexView, + Text, + UiUtil, + Toggle, + useTheme, + Pressable, + Icon +} from '@reown/appkit-ui-react-native'; +import { NumberUtil } from '@reown/appkit-common-react-native'; + +import { InformationModal } from '../w3m-information-modal'; +import styles from './styles'; +import { getModalData } from './utils'; + +interface SwapDetailsProps { + initialOpen?: boolean; + canClose?: boolean; +} + +// -- Constants ----------------------------------------- // +const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE; + +export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { + const Theme = useTheme(); + const { + maxSlippage = 0, + sourceToken, + toToken, + gasPriceInUSD = 0, + priceImpact, + toTokenAmount + } = useSnapshot(SwapController.state); + + const [modalData, setModalData] = useState<{ title: string; description: string } | undefined>(); + + const toTokenSwappedAmount = + SwapController.state.sourceTokenPriceInUSD && SwapController.state.toTokenPriceInUSD + ? (1 / SwapController.state.toTokenPriceInUSD) * SwapController.state.sourceTokenPriceInUSD + : 0; + + const renderTitle = () => ( + + + 1 {SwapController.state.sourceToken?.symbol} = {''} + {UiUtil.formatNumberToLocalString(toTokenSwappedAmount, 3)}{' '} + {SwapController.state.toToken?.symbol} + + + ~$ + {UiUtil.formatNumberToLocalString(SwapController.state.sourceTokenPriceInUSD)} + + + ); + + const minimumReceive = NumberUtil.parseLocalStringToNumber(toTokenAmount) - maxSlippage; + const providerFee = SwapController.getProviderFeePrice(); + + const onPriceImpactPress = () => { + setModalData(getModalData('priceImpact')); + }; + + const onSlippagePress = () => { + const minimumString = UiUtil.formatNumberToLocalString( + minimumReceive, + minimumReceive < 1 ? 8 : 2 + ); + setModalData( + getModalData('slippage', { + minimumReceive: minimumString, + toTokenSymbol: SwapController.state.toToken?.symbol + }) + ); + }; + + const onNetworkCostPress = () => { + setModalData( + getModalData('networkCost', { + networkSymbol: SwapController.state.networkTokenSymbol, + networkName: NetworkController.state.caipNetwork?.name + }) + ); + }; + + return ( + <> + + + + + Network cost + + + + + + + ${UiUtil.formatNumberToLocalString(gasPriceInUSD, gasPriceInUSD < 1 ? 8 : 2)} + + + {!!priceImpact && ( + + + + Price impact + + + + + + + ~{UiUtil.formatNumberToLocalString(priceImpact, 3)}% + + + )} + {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( + + + + Max. slippage + + + + + + + {UiUtil.formatNumberToLocalString(maxSlippage, 6)} {toToken?.symbol}{' '} + + {slippageRate}% + + + + )} + + + Included provider fee + + + ${UiUtil.formatNumberToLocalString(providerFee, providerFee < 1 ? 8 : 2)} + + + + setModalData(undefined)} + /> + + ); +} diff --git a/packages/scaffold/src/partials/w3m-swap-details/styles.ts b/packages/scaffold/src/partials/w3m-swap-details/styles.ts new file mode 100644 index 000000000..92fe6b172 --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-details/styles.ts @@ -0,0 +1,26 @@ +import { StyleSheet } from 'react-native'; +import { BorderRadius, Spacing } from '@reown/appkit-ui-react-native'; + +export default StyleSheet.create({ + container: { + width: '100%', + borderRadius: 16 + }, + titlePrice: { + marginLeft: Spacing['3xs'] + }, + detailTitle: { + marginRight: Spacing['3xs'] + }, + item: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: Spacing.s, + borderRadius: BorderRadius.xxs, + marginTop: Spacing['2xs'] + }, + infoIcon: { + borderRadius: BorderRadius.full + } +}); diff --git a/packages/scaffold/src/partials/w3m-swap-details/utils.ts b/packages/scaffold/src/partials/w3m-swap-details/utils.ts new file mode 100644 index 000000000..fc834532d --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-details/utils.ts @@ -0,0 +1,33 @@ +export interface ModalData { + detail: ModalDetail; + opts?: ModalDataOpts; +} + +export type ModalDetail = 'slippage' | 'networkCost' | 'priceImpact'; + +export interface ModalDataOpts { + networkSymbol?: string; + networkName?: string; + minimumReceive?: string; + toTokenSymbol?: string; +} + +export const getModalData = (detail: ModalDetail, opts?: ModalDataOpts) => { + switch (detail) { + case 'slippage': + return { + title: 'Max. slippage', + description: `Max slippage sets the minimum amount you must receive for the transaction to proceed. The transaction will be reversed if you receive less than ${opts?.minimumReceive} ${opts?.toTokenSymbol} due to price changes` + }; + case 'networkCost': + return { + title: 'Network cost', + description: `Network cost is paid in ${opts?.networkSymbol} on the ${opts?.networkName} network in order to execute the transaction` + }; + case 'priceImpact': + return { + title: 'Price impact', + description: 'Price impact reflects the change in market price due to your trade' + }; + } +}; diff --git a/packages/scaffold/src/partials/w3m-swap-input/index.tsx b/packages/scaffold/src/partials/w3m-swap-input/index.tsx new file mode 100644 index 000000000..5b2044748 --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-input/index.tsx @@ -0,0 +1,147 @@ +import { useRef } from 'react'; +import type BigNumber from 'bignumber.js'; +import { TextInput, type StyleProp, type ViewStyle } from 'react-native'; +import { + FlexView, + useTheme, + TokenButton, + Shimmer, + Text, + UiUtil, + Link +} from '@reown/appkit-ui-react-native'; +import { type SwapTokenWithBalance } from '@reown/appkit-core-react-native'; + +import styles from './styles'; +import { NumberUtil } from '@reown/appkit-common-react-native'; + +export interface SwapInputProps { + token?: SwapTokenWithBalance; + value?: string; + gasPrice?: number; + style?: StyleProp; + loading?: boolean; + onTokenPress?: () => void; + onMaxPress?: () => void; + onChange?: (value: string) => void; + balance?: BigNumber; + marketValue?: number; + editable?: boolean; + autoFocus?: boolean; +} + +const MINIMUM_USD_VALUE_TO_CONVERT = 0.00005; + +export function SwapInput({ + token, + value, + style, + loading, + onTokenPress, + onMaxPress, + onChange, + marketValue, + editable, + autoFocus +}: SwapInputProps) { + const Theme = useTheme(); + const valueInputRef = useRef(null); + const isMarketValueGreaterThanZero = + !!marketValue && NumberUtil.bigNumber(marketValue).isGreaterThan('0'); + const maxAmount = UiUtil.formatNumberToLocalString(token?.quantity.numeric, 3); + const maxError = Number(value) > Number(token?.quantity.numeric); + const showMax = + onMaxPress && + !!token?.quantity.numeric && + NumberUtil.multiply(token?.quantity.numeric, token?.price).isGreaterThan( + MINIMUM_USD_VALUE_TO_CONVERT + ); + + const handleInputChange = (_value: string) => { + const formattedValue = _value.replace(/,/g, '.'); + + if (Number(formattedValue) >= 0 || formattedValue === '') { + onChange?.(formattedValue); + } + }; + + const handleMaxPress = () => { + if (valueInputRef.current) { + valueInputRef.current.blur(); + } + + onMaxPress?.(); + }; + + return ( + + {loading ? ( + + + + + ) : ( + <> + + + + + {(showMax || isMarketValueGreaterThanZero) && ( + + + {isMarketValueGreaterThanZero + ? `~$${UiUtil.formatNumberToLocalString(marketValue, 2)}` + : ''} + + {showMax && ( + + + {showMax ? maxAmount : ''} + + Max + + )} + + )} + + )} + + ); +} diff --git a/packages/scaffold/src/partials/w3m-swap-input/styles.ts b/packages/scaffold/src/partials/w3m-swap-input/styles.ts new file mode 100644 index 000000000..e35dc185f --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-input/styles.ts @@ -0,0 +1,20 @@ +import { BorderRadius, Spacing } from '@reown/appkit-ui-react-native'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + height: 100, + width: '100%', + borderRadius: BorderRadius.s, + borderWidth: StyleSheet.hairlineWidth + }, + input: { + fontSize: 32, + flex: 1, + marginRight: Spacing.xs + }, + sendValue: { + flex: 1, + marginRight: Spacing.xs + } +}); diff --git a/packages/scaffold/src/views/w3m-account-default-view/components/auth-buttons.tsx b/packages/scaffold/src/views/w3m-account-default-view/components/auth-buttons.tsx index 7a1d903b2..43e3cd38e 100644 --- a/packages/scaffold/src/views/w3m-account-default-view/components/auth-buttons.tsx +++ b/packages/scaffold/src/views/w3m-account-default-view/components/auth-buttons.tsx @@ -33,7 +33,14 @@ export function AuthButtons({ ) : ( - + {text} diff --git a/packages/scaffold/src/views/w3m-account-default-view/index.tsx b/packages/scaffold/src/views/w3m-account-default-view/index.tsx index 22b90082b..dba1584a9 100644 --- a/packages/scaffold/src/views/w3m-account-default-view/index.tsx +++ b/packages/scaffold/src/views/w3m-account-default-view/index.tsx @@ -8,13 +8,16 @@ import { ConnectionController, ConnectorController, CoreHelperUtil, + ConnectionUtil, EventsController, ModalController, NetworkController, OptionsController, RouterController, SnackController, - type AppKitFrameProvider + type AppKitFrameProvider, + ConstantsUtil, + SwapController } from '@reown/appkit-core-react-native'; import { Avatar, @@ -46,6 +49,7 @@ export function AccountDefaultView() { const { caipNetwork } = useSnapshot(NetworkController.state); const { connectedConnector } = useSnapshot(ConnectorController.state); const { connectedSocialProvider } = useSnapshot(ConnectionController.state); + const { features } = useSnapshot(OptionsController.state); const { history } = useSnapshot(RouterController.state); const networkImage = AssetUtil.getNetworkImage(caipNetwork); const showCopy = OptionsController.isClipboardAvailable(); @@ -57,22 +61,9 @@ export function AccountDefaultView() { const { padding } = useCustomDimensions(); async function onDisconnect() { - try { - setDisconnecting(true); - await ConnectionController.disconnect(); - AccountController.setIsConnected(false); - ModalController.close(); - setDisconnecting(false); - EventsController.sendEvent({ - type: 'track', - event: 'DISCONNECT_SUCCESS' - }); - } catch (error) { - EventsController.sendEvent({ - type: 'track', - event: 'DISCONNECT_ERROR' - }); - } + setDisconnecting(true); + await ConnectionUtil.disconnect(); + setDisconnecting(false); } const onSwitchAccountType = async () => { @@ -130,6 +121,26 @@ export function AccountDefaultView() { } }; + const onSwapPress = () => { + if ( + NetworkController.state.caipNetwork?.id && + !ConstantsUtil.SWAP_SUPPORTED_NETWORKS.includes(`${NetworkController.state.caipNetwork.id}`) + ) { + RouterController.push('UnsupportedChain'); + } else { + SwapController.resetState(); + EventsController.sendEvent({ + type: 'track', + event: 'OPEN_SWAP', + properties: { + network: NetworkController.state.caipNetwork?.id || '', + isSmartAccount: false + } + }); + RouterController.push('Swap'); + } + }; + const onActivityPress = () => { RouterController.push('Transactions'); }; @@ -228,7 +239,8 @@ export function AccountDefaultView() { + + {!isAuth && features?.swaps && ( + + Swap + + )} {!isAuth && ( diff --git a/packages/scaffold/src/views/w3m-network-switch-view/index.tsx b/packages/scaffold/src/views/w3m-network-switch-view/index.tsx index 7c3739df9..8adf04559 100644 --- a/packages/scaffold/src/views/w3m-network-switch-view/index.tsx +++ b/packages/scaffold/src/views/w3m-network-switch-view/index.tsx @@ -8,7 +8,8 @@ import { ConnectorController, EventsController, NetworkController, - RouterController + RouterController, + RouterUtil } from '@reown/appkit-core-react-native'; import { Button, @@ -55,7 +56,7 @@ export function NetworkSwitchView() { useEffect(() => { // Go back if network is already switched if (caipNetwork?.id === network?.id) { - RouterController.goBack(); + RouterUtil.navigateAfterNetworkSwitch(); } }, [caipNetwork?.id, network?.id]); diff --git a/packages/scaffold/src/views/w3m-networks-view/index.tsx b/packages/scaffold/src/views/w3m-networks-view/index.tsx index af9386bde..30bdd0299 100644 --- a/packages/scaffold/src/views/w3m-networks-view/index.tsx +++ b/packages/scaffold/src/views/w3m-networks-view/index.tsx @@ -14,10 +14,9 @@ import { NetworkController, RouterController, type CaipNetwork, - AccountController, EventsController, - RouterUtil, - ConnectorController + CoreHelperUtil, + NetworkUtil } from '@reown/appkit-core-react-native'; import { useCustomDimensions } from '../../hooks/useCustomDimensions'; import styles from './styles'; @@ -33,7 +32,6 @@ export function NetworksView() { const itemGap = Math.abs( Math.trunc((usableWidth - numColumns * CardSelectWidth) / numColumns) / 2 ); - const isAuthConnected = ConnectorController.state.connectedConnector === 'AUTH'; const onHelpPress = () => { RouterController.push('WhatIsANetwork'); @@ -41,43 +39,22 @@ export function NetworksView() { }; const networksTemplate = () => { - if (!requestedCaipNetworks?.length) return undefined; - - const approvedIds = approvedCaipNetworkIds; - const requested = [...requestedCaipNetworks]; - - if (approvedIds?.length) { - requested?.sort((a, b) => { - if (approvedIds.includes(a.id) && !approvedIds.includes(b.id)) return -1; - if (approvedIds.includes(b.id) && !approvedIds.includes(a.id)) return 1; - - return 0; - }); - } + const networks = CoreHelperUtil.sortNetworks(approvedCaipNetworkIds, requestedCaipNetworks); const onNetworkPress = async (network: CaipNetwork) => { - if (AccountController.state.isConnected && caipNetwork?.id !== network.id) { - if (approvedCaipNetworkIds?.includes(network.id) && !isAuthConnected) { - await NetworkController.switchActiveNetwork(network); - RouterUtil.navigateAfterNetworkSwitch(['ConnectingSiwe']); - - EventsController.sendEvent({ - type: 'track', - event: 'SWITCH_NETWORK', - properties: { - network: network.id - } - }); - } else if (supportsAllNetworks || isAuthConnected) { - RouterController.push('SwitchNetwork', { network }); - } - } else if (!AccountController.state.isConnected) { - NetworkController.setCaipNetwork(network); - RouterController.push('Connect'); + const result = await NetworkUtil.handleNetworkSwitch(network); + if (result?.type === 'SWITCH_NETWORK') { + EventsController.sendEvent({ + type: 'track', + event: 'SWITCH_NETWORK', + properties: { + network: network.id + } + }); } }; - return requested.map(network => ( + return networks.map(network => ( { + RouterController.goBack(); + }; + + const onSwap = () => { + if (SwapController.state.approvalTransaction) { + SwapController.sendTransactionForApproval(SwapController.state.approvalTransaction); + } else { + SwapController.sendTransactionForSwap(SwapController.state.swapTransaction); + } + }; + + useEffect(() => { + function refreshTransaction() { + if (!SwapController.state.loadingApprovalTransaction) { + SwapController.getTransaction(); + } + } + + SwapController.getTransaction(); + + const interval = setInterval(refreshTransaction, 10000); + + return () => { + clearInterval(interval); + }; + }, []); + + return ( + + + + + + Send + + + ${UiUtil.formatNumberToLocalString(sourceTokenMarketValue, 2)} + + + + + + + + + Receive + + + ${UiUtil.formatNumberToLocalString(toTokenMarketValue, 2)} + + + + + + + + + Review transaction carefully + + + + + + + + + ); +} diff --git a/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts b/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts new file mode 100644 index 000000000..7af1b350b --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts @@ -0,0 +1,18 @@ +import { StyleSheet } from 'react-native'; +import { Spacing } from '@reown/appkit-ui-react-native'; + +export default StyleSheet.create({ + swapIcon: { + marginVertical: Spacing.xs + }, + reviewIcon: { + marginRight: Spacing['3xs'] + }, + cancelButton: { + flex: 1 + }, + sendButton: { + marginLeft: Spacing.s, + flex: 3 + } +}); diff --git a/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx new file mode 100644 index 000000000..28752eb51 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { useSnapshot } from 'valtio'; +import { ScrollView, SectionList, type SectionListData } from 'react-native'; +import { + FlexView, + InputText, + ListToken, + ListTokenTotalHeight, + Separator, + Text, + TokenButton, + useTheme +} from '@reown/appkit-ui-react-native'; + +import { + AssetUtil, + NetworkController, + RouterController, + SwapController, + type SwapTokenWithBalance +} from '@reown/appkit-core-react-native'; + +import { useCustomDimensions } from '../../hooks/useCustomDimensions'; +import { Placeholder } from '../../partials/w3m-placeholder'; +import styles from './styles'; +import { createSections } from './utils'; + +export function SwapSelectTokenView() { + const { padding } = useCustomDimensions(); + const Theme = useTheme(); + const { caipNetwork } = useSnapshot(NetworkController.state); + const { sourceToken, suggestedTokens } = useSnapshot(SwapController.state); + const networkImage = AssetUtil.getNetworkImage(caipNetwork); + const [tokenSearch, setTokenSearch] = useState(''); + const isSourceToken = RouterController.state.data?.swapTarget === 'sourceToken'; + + const [filteredTokens, setFilteredTokens] = useState(createSections(isSourceToken, tokenSearch)); + const suggestedList = suggestedTokens + ?.filter(token => token.address !== SwapController.state.sourceToken?.address) + .slice(0, 8); + + const onSearchChange = (value: string) => { + setTokenSearch(value); + setFilteredTokens(createSections(isSourceToken, value)); + }; + + const onTokenPress = (token: SwapTokenWithBalance) => { + if (isSourceToken) { + SwapController.setSourceToken(token); + } else { + SwapController.setToToken(token); + if (SwapController.state.sourceToken && SwapController.state.sourceTokenAmount) { + SwapController.swapTokens(); + } + } + RouterController.goBack(); + }; + + return ( + + + + {!isSourceToken && ( + + {suggestedList?.map((token, index) => ( + onTokenPress(token)} + style={index !== suggestedList.length - 1 ? styles.suggestedToken : undefined} + /> + ))} + + )} + + + []} + bounces={false} + fadingEdgeLength={20} + contentContainerStyle={styles.tokenList} + renderSectionHeader={({ section: { title } }) => ( + + {title} + + )} + ListEmptyComponent={ + + } + getItemLayout={(_, index) => ({ + length: ListTokenTotalHeight, + offset: ListTokenTotalHeight * index, + index + })} + renderItem={({ item }) => ( + onTokenPress(item)} + disabled={item.address === sourceToken?.address} + /> + )} + /> + + ); +} diff --git a/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts b/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts new file mode 100644 index 000000000..ffc103faa --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts @@ -0,0 +1,30 @@ +import { StyleSheet } from 'react-native'; +import { Spacing } from '@reown/appkit-ui-react-native'; + +export default StyleSheet.create({ + container: { + minHeight: 250, + maxHeight: 600 + }, + title: { + paddingTop: Spacing['2xs'] + }, + tokenList: { + paddingHorizontal: Spacing.m + }, + input: { + marginHorizontal: Spacing.xs + }, + suggestedList: { + marginTop: Spacing.xs + }, + suggestedListContent: { + paddingHorizontal: Spacing.s + }, + suggestedToken: { + marginRight: Spacing.s + }, + suggestedSeparator: { + marginVertical: Spacing.s + } +}); diff --git a/packages/scaffold/src/views/w3m-swap-select-token-view/utils.ts b/packages/scaffold/src/views/w3m-swap-select-token-view/utils.ts new file mode 100644 index 000000000..978d2bb66 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/utils.ts @@ -0,0 +1,33 @@ +import { SwapController, type SwapTokenWithBalance } from '@reown/appkit-core-react-native'; + +export function filterTokens(tokens: SwapTokenWithBalance[], searchValue?: string) { + if (!searchValue) { + return tokens; + } + + return tokens.filter( + token => + token.name.toLowerCase().includes(searchValue.toLowerCase()) || + token.symbol.toLowerCase().includes(searchValue.toLowerCase()) + ); +} + +export function createSections(isSourceToken: boolean, searchValue: string) { + const myTokensFiltered = filterTokens( + SwapController.state.myTokensWithBalance ?? [], + searchValue + ); + const popularFiltered = isSourceToken + ? [] + : filterTokens(SwapController.getFilteredPopularTokens() ?? [], searchValue); + + const sections = []; + if (myTokensFiltered.length > 0) { + sections.push({ title: 'Your tokens', data: myTokensFiltered }); + } + if (popularFiltered.length > 0) { + sections.push({ title: 'Popular tokens', data: popularFiltered }); + } + + return sections; +} diff --git a/packages/scaffold/src/views/w3m-swap-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx new file mode 100644 index 000000000..329e96391 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -0,0 +1,209 @@ +import { useSnapshot } from 'valtio'; +import { useCallback, useEffect } from 'react'; +import { Platform, ScrollView } from 'react-native'; +import { + AccountController, + EventsController, + NetworkController, + RouterController, + SwapController +} from '@reown/appkit-core-react-native'; +import { Button, FlexView, IconLink, Spacing, useTheme } from '@reown/appkit-ui-react-native'; +import { NumberUtil } from '@reown/appkit-common-react-native'; + +import { useKeyboard } from '../../hooks/useKeyboard'; +import { useCustomDimensions } from '../../hooks/useCustomDimensions'; +import { SwapInput } from '../../partials/w3m-swap-input'; +import { useDebounceCallback } from '../../hooks/useDebounceCallback'; +import { SwapDetails } from '../../partials/w3m-swap-details'; +import styles from './styles'; + +export function SwapView() { + const { + initializing, + sourceToken, + toToken, + sourceTokenAmount, + toTokenAmount, + loadingPrices, + loadingQuote, + sourceTokenPriceInUSD, + toTokenPriceInUSD, + myTokensWithBalance, + inputError + } = useSnapshot(SwapController.state); + const Theme = useTheme(); + const { padding } = useCustomDimensions(); + const { keyboardShown, keyboardHeight } = useKeyboard(); + const showDetails = !!sourceToken && !!toToken && !inputError; + + const showSwitch = + myTokensWithBalance && + myTokensWithBalance.findIndex( + token => token.address === SwapController.state.toToken?.address + ) >= 0; + + const paddingBottom = Platform.select({ + android: keyboardShown ? keyboardHeight + Spacing['2xl'] : Spacing['2xl'], + default: Spacing['2xl'] + }); + + const getActionButtonState = () => { + if (!SwapController.state.sourceToken || !SwapController.state.toToken) { + return { text: 'Select token', disabled: true }; + } + + if (!SwapController.state.sourceTokenAmount || !SwapController.state.toTokenAmount) { + return { text: 'Enter amount', disabled: true }; + } + + if (SwapController.state.inputError) { + return { text: SwapController.state.inputError, disabled: true }; + } + + return { text: 'Review swap', disabled: false }; + }; + + const actionState = getActionButtonState(); + const actionLoading = initializing || loadingPrices || loadingQuote; + + const onDebouncedSwap = useDebounceCallback({ + callback: SwapController.swapTokens.bind(SwapController), + delay: 400 + }); + + const onSourceTokenChange = (value: string) => { + SwapController.setSourceTokenAmount(value); + onDebouncedSwap(); + }; + + const onToTokenChange = (value: string) => { + SwapController.setToTokenAmount(value); + onDebouncedSwap(); + }; + + const onSourceTokenPress = () => { + RouterController.push('SwapSelectToken', { swapTarget: 'sourceToken' }); + }; + + const onReviewPress = () => { + EventsController.sendEvent({ + type: 'track', + event: 'INITIATE_SWAP', + properties: { + network: NetworkController.state.caipNetwork?.id || '', + swapFromToken: SwapController.state.sourceToken?.symbol || '', + swapToToken: SwapController.state.toToken?.symbol || '', + swapFromAmount: SwapController.state.sourceTokenAmount || '', + swapToAmount: SwapController.state.toTokenAmount || '', + isSmartAccount: AccountController.state.preferredAccountType === 'smartAccount' + } + }); + RouterController.push('SwapPreview'); + }; + + const onSourceMaxPress = () => { + const isNetworkToken = + SwapController.state.sourceToken?.address === + NetworkController.getActiveNetworkTokenAddress(); + + const _gasPriceInUSD = SwapController.state.gasPriceInUSD; + const _sourceTokenPriceInUSD = SwapController.state.sourceTokenPriceInUSD; + const _balance = SwapController.state.sourceToken?.quantity.numeric; + + if (_balance) { + if (!_gasPriceInUSD) { + return SwapController.setSourceTokenAmount(_balance); + } + + const amountOfTokenGasRequires = NumberUtil.bigNumber(_gasPriceInUSD.toFixed(5)).dividedBy( + _sourceTokenPriceInUSD + ); + + const maxValue = isNetworkToken + ? NumberUtil.bigNumber(_balance).minus(amountOfTokenGasRequires) + : NumberUtil.bigNumber(_balance); + + SwapController.setSourceTokenAmount(maxValue.isGreaterThan(0) ? maxValue.toFixed(20) : '0'); + SwapController.swapTokens(); + } + }; + + const onToTokenPress = () => { + RouterController.push('SwapSelectToken', { swapTarget: 'toToken' }); + }; + + const onSwitchPress = () => { + SwapController.switchTokens(); + }; + + const watchTokens = useCallback(() => { + SwapController.getNetworkTokenPrice(); + SwapController.getMyTokensWithBalance(); + SwapController.swapTokens(); + }, []); + + useEffect(() => { + SwapController.initializeState(); + + const interval = setInterval(watchTokens, 10000); + + return () => { + clearInterval(interval); + }; + }, [watchTokens]); + + return ( + + + + + + {showSwitch && ( + + )} + + {showDetails && } + + + + ); +} diff --git a/packages/scaffold/src/views/w3m-swap-view/styles.ts b/packages/scaffold/src/views/w3m-swap-view/styles.ts new file mode 100644 index 000000000..99c07ce4c --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-view/styles.ts @@ -0,0 +1,23 @@ +import { BorderRadius, Spacing } from '@reown/appkit-ui-react-native'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + bottomInputContainer: { + width: '100%' + }, + arrowIcon: { + position: 'absolute', + top: -30, + borderRadius: BorderRadius.xs, + borderWidth: 4, + height: 50, + width: 50 + }, + tokenInput: { + marginBottom: Spacing.xs + }, + actionButton: { + marginTop: Spacing.xs, + width: '100%' + } +}); diff --git a/packages/scaffold/src/views/w3m-unsupported-chain-view/index.tsx b/packages/scaffold/src/views/w3m-unsupported-chain-view/index.tsx new file mode 100644 index 000000000..f2074e505 --- /dev/null +++ b/packages/scaffold/src/views/w3m-unsupported-chain-view/index.tsx @@ -0,0 +1,92 @@ +import { useSnapshot } from 'valtio'; +import { useState } from 'react'; +import { FlatList } from 'react-native'; +import { Icon, ListItem, Separator, Text } from '@reown/appkit-ui-react-native'; +import { + ApiController, + AssetUtil, + CoreHelperUtil, + ConnectionUtil, + EventsController, + NetworkController, + NetworkUtil, + type CaipNetwork, + type NetworkControllerState +} from '@reown/appkit-core-react-native'; +import styles from './styles'; + +export function UnsupportedChainView() { + const { caipNetwork, supportsAllNetworks, approvedCaipNetworkIds, requestedCaipNetworks } = + useSnapshot(NetworkController.state) as NetworkControllerState; + + const [disconnecting, setDisconnecting] = useState(false); + const networks = CoreHelperUtil.sortNetworks(approvedCaipNetworkIds, requestedCaipNetworks); + const imageHeaders = ApiController._getApiHeaders(); + + const onNetworkPress = async (network: CaipNetwork) => { + const result = await NetworkUtil.handleNetworkSwitch(network); + if (result?.type === 'SWITCH_NETWORK') { + EventsController.sendEvent({ + type: 'track', + event: 'SWITCH_NETWORK', + properties: { + network: network.id + } + }); + } + }; + + const onDisconnect = async () => { + setDisconnecting(true); + await ConnectionUtil.disconnect(); + setDisconnecting(false); + }; + + return ( + + The swap feature doesn't support your current network. Switch to an available option to + continue. + + } + contentContainerStyle={styles.contentContainer} + renderItem={({ item }) => ( + onNetworkPress(item)} + testID="button-network" + style={styles.networkItem} + contentStyle={styles.networkItemContent} + disabled={!supportsAllNetworks && !approvedCaipNetworkIds?.includes(item.id)} + > + + {item.name ?? 'Unknown'} + + {item.id === caipNetwork?.id && } + + )} + ListFooterComponent={ + <> + + + Disconnect + + + } + /> + ); +} diff --git a/packages/scaffold/src/views/w3m-unsupported-chain-view/styles.ts b/packages/scaffold/src/views/w3m-unsupported-chain-view/styles.ts new file mode 100644 index 000000000..0c07dc9c3 --- /dev/null +++ b/packages/scaffold/src/views/w3m-unsupported-chain-view/styles.ts @@ -0,0 +1,21 @@ +import { Spacing } from '@reown/appkit-ui-react-native'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + contentContainer: { + padding: Spacing.s, + paddingBottom: Spacing.xl + }, + header: { + marginBottom: Spacing.s + }, + networkItem: { + marginVertical: Spacing['3xs'] + }, + networkItemContent: { + justifyContent: 'space-between' + }, + separator: { + marginBottom: Spacing['2xs'] + } +}); diff --git a/packages/scaffold/src/views/w3m-wallet-send-select-token-view/index.tsx b/packages/scaffold/src/views/w3m-wallet-send-select-token-view/index.tsx index aac2d89ab..73a065e36 100644 --- a/packages/scaffold/src/views/w3m-wallet-send-select-token-view/index.tsx +++ b/packages/scaffold/src/views/w3m-wallet-send-select-token-view/index.tsx @@ -19,20 +19,21 @@ export function WalletSendSelectTokenView() { const { padding } = useCustomDimensions(); const { tokenBalance } = useSnapshot(AccountController.state); const { caipNetwork } = useSnapshot(NetworkController.state); + const { token } = useSnapshot(SendController.state); const networkImage = AssetUtil.getNetworkImage(caipNetwork); const [tokenSearch, setTokenSearch] = useState(''); const [filteredTokens, setFilteredTokens] = useState(tokenBalance ?? []); const onSearchChange = (value: string) => { setTokenSearch(value); - const filtered = AccountController.state.tokenBalance?.filter(token => - token.name.toLowerCase().includes(value.toLowerCase()) + const filtered = AccountController.state.tokenBalance?.filter(_token => + _token.name.toLowerCase().includes(value.toLowerCase()) ); setFilteredTokens(filtered ?? []); }; - const onTokenPress = (token: Balance) => { - SendController.setToken(token); + const onTokenPress = (_token: Balance) => { + SendController.setToken(_token); SendController.setTokenAmount(undefined); RouterController.goBack(); }; @@ -56,16 +57,17 @@ export function WalletSendSelectTokenView() { Your tokens {filteredTokens.length ? ( - filteredTokens.map((token, index) => ( + filteredTokens.map((_token, index) => ( onTokenPress(token)} + value={_token.value} + amount={_token.quantity.numeric} + currency={_token.symbol} + onPress={() => onTokenPress(_token)} + disabled={_token.address === token?.address} /> )) ) : ( diff --git a/packages/scaffold/src/views/w3m-wallet-send-view/index.tsx b/packages/scaffold/src/views/w3m-wallet-send-view/index.tsx index 31d3f8509..aecdeb052 100644 --- a/packages/scaffold/src/views/w3m-wallet-send-view/index.tsx +++ b/packages/scaffold/src/views/w3m-wallet-send-view/index.tsx @@ -8,18 +8,11 @@ import { SendController, SwapController } from '@reown/appkit-core-react-native'; -import { - Button, - FlexView, - IconBox, - LoadingSpinner, - Spacing, - Text -} from '@reown/appkit-ui-react-native'; -import { InputToken } from '../../partials/w3m-input-token/intex'; +import { Button, FlexView, IconBox, Spacing } from '@reown/appkit-ui-react-native'; +import { SendInputToken } from '../../partials/w3m-send-input-token'; import { useCustomDimensions } from '../../hooks/useCustomDimensions'; import { useKeyboard } from '../../hooks/useKeyboard'; -import { InputAddress } from '../../partials/w3m-input-address'; +import { SendInputAddress } from '../../partials/w3m-send-input-address'; import styles from './styles'; export function WalletSendView() { @@ -93,7 +86,6 @@ export function WalletSendView() { }, [token, tokenBalance, fetchNetworkPrice]); const actionText = getActionText(); - const disabled = actionText !== 'Preview send'; return ( - RouterController.push('WalletSendSelectToken')} /> - + - {loading ? ( - - ) : ( - - {getActionText()} - - )} + {actionText} diff --git a/packages/scaffold/src/views/w3m-wallet-send-view/styles.ts b/packages/scaffold/src/views/w3m-wallet-send-view/styles.ts index 7e52d3f4f..a3cdd0f4d 100644 --- a/packages/scaffold/src/views/w3m-wallet-send-view/styles.ts +++ b/packages/scaffold/src/views/w3m-wallet-send-view/styles.ts @@ -13,7 +13,7 @@ export default StyleSheet.create({ arrowIcon: { position: 'absolute', top: -30, - borderRadius: 20 + borderRadius: BorderRadius.s }, addressContainer: { width: '100%' diff --git a/packages/ui/package.json b/packages/ui/package.json index f91110bee..fa5b4dc63 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,6 +38,7 @@ "access": "public" }, "dependencies": { + "polished": "4.3.1", "qrcode": "1.5.3" }, "peerDependencies": { diff --git a/packages/ui/src/assets/svg/RecycleHorizontal.tsx b/packages/ui/src/assets/svg/RecycleHorizontal.tsx new file mode 100644 index 000000000..ed87f3898 --- /dev/null +++ b/packages/ui/src/assets/svg/RecycleHorizontal.tsx @@ -0,0 +1,12 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; +const RecycleHorizontalSvg = (props: SvgProps) => ( + + + +); +export default RecycleHorizontalSvg; diff --git a/packages/ui/src/components/wui-icon/index.tsx b/packages/ui/src/components/wui-icon/index.tsx index e939f6189..4ba5677aa 100644 --- a/packages/ui/src/components/wui-icon/index.tsx +++ b/packages/ui/src/components/wui-icon/index.tsx @@ -45,6 +45,7 @@ import NftPlaceholderSvg from '../../assets/svg/NftPlaceholder'; import OffSvg from '../../assets/svg/Off'; import PaperplaneSvg from '../../assets/svg/Paperplane'; import QrCodeSvg from '../../assets/svg/QrCode'; +import RecycleHorizontalSvg from '../../assets/svg/RecycleHorizontal'; import RefreshSvg from '../../assets/svg/Refresh'; import SearchSvg from '../../assets/svg/Search'; import SwapHorizontalSvg from '../../assets/svg/SwapHorizontal'; @@ -105,6 +106,7 @@ const svgOptions: Record JSX.Element> = { off: OffSvg, paperplane: PaperplaneSvg, qrCode: QrCodeSvg, + recycleHorizontal: RecycleHorizontalSvg, refresh: RefreshSvg, search: SearchSvg, swapHorizontal: SwapHorizontalSvg, diff --git a/packages/ui/src/components/wui-pressable/index.tsx b/packages/ui/src/components/wui-pressable/index.tsx new file mode 100644 index 000000000..1dd9ab329 --- /dev/null +++ b/packages/ui/src/components/wui-pressable/index.tsx @@ -0,0 +1,92 @@ +import { useRef } from 'react'; +import { + Animated, + Pressable as RNPressable, + type PressableProps as RNPressableProps, + type StyleProp, + type ViewStyle +} from 'react-native'; +import { useTheme } from '../../hooks/useTheme'; +import type { ColorType } from '../../utils/TypesUtil'; + +const AnimatedPressable = Animated.createAnimatedComponent(RNPressable); + +export interface PressableProps extends RNPressableProps { + children: React.ReactNode; + style?: StyleProp; + backgroundColor?: ColorType | 'transparent'; + pressedBackgroundColor?: ColorType; + bounceScale?: number; + animationDuration?: number; + disabled?: boolean; + pressable?: boolean; +} + +export function Pressable({ + children, + style, + disabled = false, + pressable = true, + onPress, + backgroundColor = 'gray-glass-002', + pressedBackgroundColor = 'gray-glass-010', + bounceScale = 0.99, // Scale to 99% of original size + animationDuration = 200, // 200ms animation + ...rest +}: PressableProps) { + const Theme = useTheme(); + const pressAnimation = useRef(new Animated.Value(0)); + const scaleAnimation = useRef(new Animated.Value(1)); + + const onPressIn = () => { + Animated.parallel([ + Animated.timing(pressAnimation.current, { + toValue: 1, + useNativeDriver: false, + duration: animationDuration + }), + + Animated.timing(scaleAnimation.current, { + toValue: bounceScale, + useNativeDriver: false, + duration: animationDuration + }) + ]).start(); + }; + + const onPressOut = () => { + Animated.parallel([ + Animated.timing(pressAnimation.current, { + toValue: 0, + useNativeDriver: false, + duration: animationDuration + }), + Animated.timing(scaleAnimation.current, { + toValue: 1, + useNativeDriver: false, + duration: animationDuration + }) + ]).start(); + }; + + const bgColor = pressAnimation.current.interpolate({ + inputRange: [0, 1], + outputRange: [ + Theme[backgroundColor as ColorType] ?? 'transparent', + Theme[pressedBackgroundColor as ColorType] ?? 'transparent' + ] + }); + + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/wui-shimmer/index.tsx b/packages/ui/src/components/wui-shimmer/index.tsx index 798ee3911..b4b927a42 100644 --- a/packages/ui/src/components/wui-shimmer/index.tsx +++ b/packages/ui/src/components/wui-shimmer/index.tsx @@ -1,5 +1,5 @@ import { Svg, Rect } from 'react-native-svg'; -import { Animated, type StyleProp, type ViewStyle } from 'react-native'; +import { Animated, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; import { useTheme } from '../../hooks/useTheme'; const AnimatedRect = Animated.createAnimatedComponent(Rect); @@ -52,7 +52,14 @@ export const Shimmer = ({ ).start(); return ( - + ); diff --git a/packages/ui/src/composites/wui-button/index.tsx b/packages/ui/src/composites/wui-button/index.tsx index 697c05ca2..95b4ddf88 100644 --- a/packages/ui/src/composites/wui-button/index.tsx +++ b/packages/ui/src/composites/wui-button/index.tsx @@ -95,7 +95,7 @@ export function Button({ style={[styles.iconLeft, iconStyle]} /> )} - {loading && } + {loading && } {!loading && (typeof children === 'string' ? ( => { const buttonBaseStyle = { - borderColor: theme['gray-glass-010'] + borderColor: theme['accent-glass-020'] }; if (disabled) { return { - backgroundColor: variant === 'fill' ? theme['gray-glass-005'] : theme['gray-glass-010'], - borderColor: theme['gray-glass-002'] + ...buttonBaseStyle, + backgroundColor: variant === 'fill' ? theme['gray-glass-005'] : theme['gray-glass-010'] }; } diff --git a/packages/ui/src/composites/wui-input-text/index.tsx b/packages/ui/src/composites/wui-input-text/index.tsx index 61880fd46..b5c4a53a5 100644 --- a/packages/ui/src/composites/wui-input-text/index.tsx +++ b/packages/ui/src/composites/wui-input-text/index.tsx @@ -4,8 +4,10 @@ import { Pressable, TextInput, type NativeSyntheticEvent, + type StyleProp, type TextInputFocusEventData, - type TextInputProps + type TextInputProps, + type ViewStyle } from 'react-native'; import { Icon } from '../../components/wui-icon'; import useAnimatedValue from '../../hooks/useAnimatedValue'; @@ -26,6 +28,7 @@ export type InputTextProps = TextInputProps & { icon?: IconType; disabled?: boolean; size?: Exclude; + style?: StyleProp; }; export const InputText = forwardRef( @@ -40,6 +43,7 @@ export const InputText = forwardRef( returnKeyType, onBlur, onFocus, + style, ...rest }: InputTextProps, ref @@ -94,7 +98,11 @@ export const InputText = forwardRef( return ( <> inputRef.current?.focus()} > diff --git a/packages/ui/src/composites/wui-list-token/index.tsx b/packages/ui/src/composites/wui-list-token/index.tsx index 30b8662eb..45dc14dec 100644 --- a/packages/ui/src/composites/wui-list-token/index.tsx +++ b/packages/ui/src/composites/wui-list-token/index.tsx @@ -1,12 +1,14 @@ -import { Pressable } from 'react-native'; import { Icon } from '../../components/wui-icon'; import { Image } from '../../components/wui-image'; import { Text } from '../../components/wui-text'; +import { Pressable } from '../../components/wui-pressable'; import { useTheme } from '../../hooks/useTheme'; import { FlexView } from '../../layout/wui-flex'; import { UiUtil } from '../../utils/UiUtil'; import styles from './styles'; +export const ListTokenTotalHeight = 64; + export interface ListTokenProps { imageSrc: string; networkSrc?: string; @@ -15,6 +17,8 @@ export interface ListTokenProps { amount?: string; currency: string; onPress?: () => void; + disabled?: boolean; + pressable?: boolean; } export function ListToken({ @@ -24,17 +28,25 @@ export function ListToken({ value, amount, currency, - onPress + onPress, + disabled, + pressable = true }: ListTokenProps) { const Theme = useTheme(); return ( - + {imageSrc ? ( @@ -66,15 +78,26 @@ export function ListToken({ )} - - {name} + + {UiUtil.getTruncateString({ + string: name, + charsStart: 15, + charsEnd: 0, + truncate: 'end' + })} - {UiUtil.formatNumberToLocalString(amount, 4)} {currency} + {UiUtil.formatNumberToLocalString(amount, 4)}{' '} + {UiUtil.getTruncateString({ + string: currency, + charsStart: 8, + charsEnd: 0, + truncate: 'end' + })} - + ${value?.toFixed(2) ?? '0.00'} diff --git a/packages/ui/src/composites/wui-list-token/styles.ts b/packages/ui/src/composites/wui-list-token/styles.ts index 73afea33a..527686d35 100644 --- a/packages/ui/src/composites/wui-list-token/styles.ts +++ b/packages/ui/src/composites/wui-list-token/styles.ts @@ -2,6 +2,9 @@ import { StyleSheet } from 'react-native'; import { BorderRadius, WalletImageSize } from '../../utils/ThemeUtil'; export default StyleSheet.create({ + pressable: { + borderRadius: BorderRadius.s + }, image: { height: WalletImageSize.sm, width: WalletImageSize.sm, diff --git a/packages/ui/src/composites/wui-snackbar/index.tsx b/packages/ui/src/composites/wui-snackbar/index.tsx index 74039c3aa..b51d488f9 100644 --- a/packages/ui/src/composites/wui-snackbar/index.tsx +++ b/packages/ui/src/composites/wui-snackbar/index.tsx @@ -4,11 +4,12 @@ import { useTheme } from '../../hooks/useTheme'; import type { ColorType, IconType } from '../../utils/TypesUtil'; import { IconBox } from '../wui-icon-box'; import styles from './styles'; +import { LoadingSpinner } from '../../components/wui-loading-spinner'; export interface SnackbarProps { message: string; iconColor: ColorType; - icon: IconType; + icon: IconType | 'loading'; style?: StyleProp; } @@ -24,7 +25,11 @@ export function Snackbar({ message, iconColor, icon, style }: SnackbarProps) { style ]} > - + {icon === 'loading' ? ( + + ) : ( + + )} ; + initialOpen?: boolean; + canClose?: boolean; +} + +export function Toggle({ + children, + style, + title = 'Details', + initialOpen = false, + canClose = true +}: ToggleProps) { + const [isOpen, setIsOpen] = useState(initialOpen); + const animatedHeight = useRef(new Animated.Value(0)).current; + const contentHeight = useRef(0); + const hasInitialized = useRef(false); + + const toggleDetails = () => { + if (canClose) { + const toValue = isOpen ? 0 : contentHeight.current; + + Animated.spring(animatedHeight, { + toValue, + useNativeDriver: false, + bounciness: 0 + }).start(); + + setIsOpen(!isOpen); + } + }; + + const measureContent = (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + contentHeight.current = height; + + if (!hasInitialized.current && initialOpen) { + hasInitialized.current = true; + animatedHeight.setValue(height); + } + }; + + return ( + + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {canClose && ( + + )} + + + + + {children} + + + + ); +} diff --git a/packages/ui/src/composites/wui-toggle/styles.ts b/packages/ui/src/composites/wui-toggle/styles.ts new file mode 100644 index 000000000..4ff8d43e9 --- /dev/null +++ b/packages/ui/src/composites/wui-toggle/styles.ts @@ -0,0 +1,24 @@ +import { StyleSheet } from 'react-native'; +import { Spacing } from '../../utils/ThemeUtil'; + +export default StyleSheet.create({ + container: { + overflow: 'hidden' + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: Spacing.s, + paddingHorizontal: Spacing.l + }, + content: { + paddingHorizontal: Spacing.l, + paddingBottom: Spacing.l, + position: 'absolute', + width: '100%' + }, + contentWrapper: { + overflow: 'hidden' + } +}); diff --git a/packages/ui/src/composites/wui-token-button/index.tsx b/packages/ui/src/composites/wui-token-button/index.tsx index b7a489e2a..7faf50105 100644 --- a/packages/ui/src/composites/wui-token-button/index.tsx +++ b/packages/ui/src/composites/wui-token-button/index.tsx @@ -1,4 +1,4 @@ -import type { Balance } from '@reown/appkit-common-react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; import { Image } from '../../components/wui-image'; import { Text } from '../../components/wui-text'; import { Button } from '../wui-button'; @@ -6,13 +6,30 @@ import styles from './styles'; export interface TokenButtonProps { onPress?: () => void; - token?: Balance; + imageUrl?: string; + text?: string; + inverse?: boolean; + style?: StyleProp; + disabled?: boolean; } -export function TokenButton({ token, onPress }: TokenButtonProps) { - if (!token) { +export function TokenButton({ + imageUrl, + text, + inverse, + onPress, + style, + disabled = false +}: TokenButtonProps) { + if (!text) { return ( -