From 0062123b7e939a88caf03cacc797079702677925 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:21:45 -0300 Subject: [PATCH 01/31] chore: WIP swaps --- apps/native/App.tsx | 7 + packages/common/src/utils/NumberUtil.ts | 24 ++ .../core/src/controllers/ApiController.ts | 2 +- .../controllers/BlockchainApiController.ts | 38 ++ .../core/src/controllers/NetworkController.ts | 8 + .../core/src/controllers/RouterController.ts | 7 +- .../core/src/controllers/SwapController.ts | 383 +++++++++++++++++- packages/core/src/utils/ConstantsUtil.ts | 120 ++++++ packages/core/src/utils/CoreHelperUtil.ts | 14 - packages/core/src/utils/SwapApiUtil.ts | 56 +++ .../core/src/utils/SwapCalculationUtil.ts | 114 ++++++ packages/core/src/utils/TypeUtil.ts | 99 +++++ packages/scaffold/src/client.ts | 2 +- .../scaffold/src/hooks/useDebounceCallback.ts | 4 +- .../scaffold/src/modal/w3m-router/index.tsx | 8 +- .../w3m-account-wallet-features/index.tsx | 44 +- .../w3m-account-wallet-features/styles.ts | 3 + .../src/partials/w3m-header/index.tsx | 4 + .../index.tsx | 4 +- .../styles.ts | 0 .../index.tsx} | 32 +- .../styles.ts | 0 .../utils.ts | 0 .../src/partials/w3m-swap-input/index.tsx | 107 +++++ .../src/partials/w3m-swap-input/styles.ts | 20 + .../w3m-swap-select-token-view/index.tsx | 118 ++++++ .../w3m-swap-select-token-view/styles.ts | 15 + .../src/views/w3m-swap-view/index.tsx | 91 +++++ .../src/views/w3m-swap-view/styles.ts | 16 + .../src/views/w3m-wallet-send-view/index.tsx | 8 +- .../src/views/w3m-wallet-send-view/styles.ts | 2 +- .../ui/src/assets/svg/RecycleHorizontal.tsx | 12 + packages/ui/src/components/wui-icon/index.tsx | 2 + .../ui/src/components/wui-shimmer/index.tsx | 11 +- .../src/composites/wui-list-token/index.tsx | 21 +- .../src/composites/wui-token-button/index.tsx | 12 +- packages/ui/src/index.ts | 2 +- packages/ui/src/utils/TypesUtil.ts | 1 + 38 files changed, 1347 insertions(+), 64 deletions(-) rename packages/scaffold/src/partials/{w3m-input-address => w3m-send-input-address}/index.tsx (95%) rename packages/scaffold/src/partials/{w3m-input-address => w3m-send-input-address}/styles.ts (100%) rename packages/scaffold/src/partials/{w3m-input-token/intex.tsx => w3m-send-input-token/index.tsx} (85%) rename packages/scaffold/src/partials/{w3m-input-token => w3m-send-input-token}/styles.ts (100%) rename packages/scaffold/src/partials/{w3m-input-token => w3m-send-input-token}/utils.ts (100%) create mode 100644 packages/scaffold/src/partials/w3m-swap-input/index.tsx create mode 100644 packages/scaffold/src/partials/w3m-swap-input/styles.ts create mode 100644 packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx create mode 100644 packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts create mode 100644 packages/scaffold/src/views/w3m-swap-view/index.tsx create mode 100644 packages/scaffold/src/views/w3m-swap-view/styles.ts create mode 100644 packages/ui/src/assets/svg/RecycleHorizontal.tsx diff --git a/apps/native/App.tsx b/apps/native/App.tsx index ae99f40cf..25ad1d662 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -14,6 +14,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'; @@ -79,6 +80,9 @@ export default function Native() { + + AppKit for React Native + = 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 + }); } }; 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..62eeab242 100644 --- a/packages/core/src/controllers/BlockchainApiController.ts +++ b/packages/core/src/controllers/BlockchainApiController.ts @@ -9,6 +9,10 @@ import type { BlockchainApiIdentityRequest, BlockchainApiIdentityResponse, BlockchainApiLookupEnsName, + BlockchainApiSwapQuoteRequest, + BlockchainApiSwapQuoteResponse, + BlockchainApiSwapTokensRequest, + BlockchainApiSwapTokensResponse, BlockchainApiTokenPriceRequest, BlockchainApiTokenPriceResponse, BlockchainApiTransactionsRequest, @@ -95,6 +99,40 @@ export const BlockchainApiController = { }); }, + fetchSwapQuote({ + projectId, + amount, + userAddress, + from, + to, + gasPrice + }: BlockchainApiSwapQuoteRequest) { + return state.api.get({ + path: `/v1/convert/quotes`, + headers: { + 'Content-Type': 'application/json' + }, + params: { + projectId, + amount, + userAddress, + from, + to, + gasPrice + } + }); + }, + + fetchSwapTokens({ projectId, chainId }: BlockchainApiSwapTokensRequest) { + return state.api.get({ + path: `/v1/convert/tokens`, + params: { + projectId, + chainId + } + }); + }, + async getBalance(address: string, chainId?: string, forceUpdate?: string) { const { sdkType, sdkVersion } = OptionsController.state; 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 a472288ed..172c1fb68 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'; import type { SocialProvider } from '@reown/appkit-common-react-native'; // -- Types --------------------------------------------- // @@ -30,7 +30,11 @@ export interface RouterControllerState { | 'GetWallet' | 'Networks' | 'SwitchNetwork' + | 'Swap' + | 'SwapSelectToken' + | 'SwapPreview' | 'Transactions' + | 'UnsupportedChain' | 'UpdateEmailPrimaryOtp' | 'UpdateEmailSecondaryOtp' | 'UpdateEmailWallet' @@ -51,6 +55,7 @@ export interface RouterControllerState { email?: string; newEmail?: string; socialProvider?: SocialProvider; + swapTarget?: SwapInputTarget; }; transactionStack: TransactionAction[]; } diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 35ef82461..a89b0cd30 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,101 @@ 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'; // -- Constants ---------------------------------------- // export const INITIAL_GAS_LIMIT = 150000; export const TO_AMOUNT_DECIMALS = 6; // -- Types --------------------------------------------- // - export interface SwapControllerState { + // Loading states + initializing: boolean; + initialized: boolean; + loadingPrices: boolean; + loadingQuote?: boolean; + loadingApprovalTransaction?: boolean; + loadingBuildTransaction?: boolean; + loadingTransaction?: boolean; + // 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, + // 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 +120,168 @@ 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 || + !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' }; }, 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) + ); + 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); + }, + + 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; + }, + + 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 +289,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 +297,6 @@ export const SwapController = { state.networkPrice = price; }, - //this async getInitialGasPrice() { const res = await SwapApiUtil.fetchGasPrice(); @@ -104,5 +313,165 @@ export const SwapController = { state.gasPriceInUSD = gasPrice; return { gasPrice: gasFee, gasPriceInUSD: state.gasPriceInUSD }; + }, + + 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) { + const { availableToSwap } = this.getParams(); + 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 (availableToSwap) { + this.swapTokens(); + } + } + }, + + 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(); + } + }, + + // -- 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/utils/ConstantsUtil.ts b/packages/core/src/utils/ConstantsUtil.ts index 8e537210f..56b4eb3a7 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', 'farcaster'] @@ -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..6b67fa119 100644 --- a/packages/core/src/utils/CoreHelperUtil.ts +++ b/packages/core/src/utils/CoreHelperUtil.ts @@ -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://'); }, diff --git a/packages/core/src/utils/SwapApiUtil.ts b/packages/core/src/utils/SwapApiUtil.ts index 3c47f65c9..e5373d9de 100644 --- a/packages/core/src/utils/SwapApiUtil.ts +++ b/packages/core/src/utils/SwapApiUtil.ts @@ -1,8 +1,64 @@ import { BlockchainApiController } from '../controllers/BlockchainApiController'; import { OptionsController } from '../controllers/OptionsController'; import { NetworkController } from '../controllers/NetworkController'; +import type { BlockchainApiBalanceResponse, SwapTokenWithBalance } from './TypeUtil'; +import { AccountController } from '../controllers/AccountController'; 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 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(), //TODO: check this + 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..6e2ccee97 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,118 @@ 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(); + }, + + 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 d91b2cc23..8d085837f 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -64,6 +64,11 @@ export type SdkVersion = | `react-native-ethers-${string}`; 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} @@ -219,6 +224,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; @@ -441,6 +475,50 @@ 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; + }; + } | { type: 'track'; event: 'SEND_INITIATED'; @@ -521,6 +599,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 diff --git a/packages/scaffold/src/client.ts b/packages/scaffold/src/client.ts index 80d682707..cc3ed1d95 100644 --- a/packages/scaffold/src/client.ts +++ b/packages/scaffold/src/client.ts @@ -61,7 +61,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-router/index.tsx b/packages/scaffold/src/modal/w3m-router/index.tsx index 5cc2a4a4f..f32e5e7b4 100644 --- a/packages/scaffold/src/modal/w3m-router/index.tsx +++ b/packages/scaffold/src/modal/w3m-router/index.tsx @@ -18,12 +18,14 @@ 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 { SwapSelectTokenView } from '../../views/w3m-swap-select-token-view'; +import { TransactionsView } from '../../views/w3m-transactions-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,6 +77,10 @@ export function AppKitRouter() { return NetworksView; case 'SwitchNetwork': return NetworkSwitchView; + case 'Swap': + return SwapView; + case 'SwapSelectToken': + return SwapSelectTokenView; case 'Transactions': return TransactionsView; case 'UpdateEmailPrimaryOtp': 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..2883e2c85 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, + SnackController } 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,29 @@ export function AccountWalletFeatures() { }); }; + const onSwapPress = () => { + if ( + NetworkController.state.caipNetwork?.id && + !ConstantsUtil.SWAP_SUPPORTED_NETWORKS.includes(`${NetworkController.state.caipNetwork.id}`) + ) { + SnackController.showError('Unsupported Chain'); + // RouterController.push('UnsupportedChain', { + // swapUnsupportedChain: true + // }); + RouterController.push('Swap'); + } else { + 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 +92,18 @@ export function AccountWalletFeatures() { justifyContent="space-around" padding={['0', 's', '0', 's']} > + {isSwapsEnabled && ( + + )} (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 85% 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..8a828b0e1 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()); @@ -88,26 +88,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-swap-input/index.tsx b/packages/scaffold/src/partials/w3m-swap-input/index.tsx new file mode 100644 index 000000000..18da96bf1 --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-input/index.tsx @@ -0,0 +1,107 @@ +import { useRef } from 'react'; +import { TextInput, type StyleProp, type ViewStyle } from 'react-native'; +import { FlexView, useTheme, TokenButton, Shimmer } from '@reown/appkit-ui-react-native'; +import { type SwapToken } from '@reown/appkit-core-react-native'; + +import styles from './styles'; + +export interface SwapInputProps { + token?: SwapToken; + value?: string; + gasPrice?: number; + style?: StyleProp; + loading?: boolean; + onTokenPress?: () => void; + onChange?: (value: string) => void; +} + +export function SwapInput({ + token, + value, + style, + loading, + onTokenPress, + onChange +}: SwapInputProps) { + const Theme = useTheme(); + const valueInputRef = useRef(null); + // const [inputValue, setInputValue] = useState(value?.toString()); + // const sendValue = getSendValue(token, sendTokenAmount); + // const maxAmount = getMaxAmount(token); + // const maxError = token && sendTokenAmount && sendTokenAmount > Number(token.quantity.numeric); + + const onInputChange = (_value: string) => { + onChange?.(_value); + }; + + // const onMaxPress = () => { + // // + // }; + + return ( + + {loading ? ( + + + + + ) : ( + <> + + + + + {/* {token && ( + + + {sendValue ?? ''} + + + + {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-swap-select-token-view/index.tsx b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx new file mode 100644 index 000000000..efa8dd610 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useSnapshot } from 'valtio'; +import { FlatList } from 'react-native'; +import { + FlexView, + InputText, + ListToken, + ListTokenTotalHeight, + Text +} 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'; + +export function SwapSelectTokenView() { + const { padding } = useCustomDimensions(); + const { caipNetwork } = useSnapshot(NetworkController.state); + const { myTokensWithBalance, popularTokens } = useSnapshot(SwapController.state); + + const networkImage = AssetUtil.getNetworkImage(caipNetwork); + const [tokenSearch, setTokenSearch] = useState(''); + const isSourceToken = RouterController.state.data?.swapTarget === 'sourceToken'; + const [filteredTokens, setFilteredTokens] = useState( + isSourceToken ? myTokensWithBalance : popularTokens + ); + + const onSearchChange = (value: string) => { + let filtered = []; + setTokenSearch(value); + + if (isSourceToken) { + filtered = + SwapController.state.myTokensWithBalance?.filter(token => + token.name.toLowerCase().includes(value.toLowerCase()) + ) ?? []; + } else { + filtered = + SwapController.state.popularTokens?.filter(token => + token.name.toLowerCase().includes(value.toLowerCase()) + ) ?? []; + } + + setFilteredTokens(filtered); + }; + + 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 ( + + + + + + Your tokens + + } + ListEmptyComponent={ + + } + getItemLayout={(_, index) => ({ + length: ListTokenTotalHeight, + offset: ListTokenTotalHeight * index, + index + })} + renderItem={({ item }) => ( + onTokenPress(item)} + /> + )} + /> + + ); +} 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..23c2e7c51 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts @@ -0,0 +1,15 @@ +import { StyleSheet } from 'react-native'; +import { Spacing } from '@reown/appkit-ui-react-native'; + +export default StyleSheet.create({ + container: { + minHeight: 250, + maxHeight: 600 + }, + title: { + marginBottom: Spacing.xs + }, + tokenList: { + paddingHorizontal: Spacing.m + } +}); 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..6812ffac7 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -0,0 +1,91 @@ +import { useSnapshot } from 'valtio'; +import { useEffect } from 'react'; +import { Platform, ScrollView } from 'react-native'; +import { RouterController, SwapController } from '@reown/appkit-core-react-native'; +import { FlexView, IconBox, Spacing } from '@reown/appkit-ui-react-native'; + +import { useKeyboard } from '../../hooks/useKeyboard'; +import { useCustomDimensions } from '../../hooks/useCustomDimensions'; +import styles from './styles'; +import { SwapInput } from '../../partials/w3m-swap-input'; +import { useDebounceCallback } from '../../hooks/useDebounceCallback'; + +export function SwapView() { + const { padding } = useCustomDimensions(); + const { initializing, sourceToken, toToken, sourceTokenAmount, toTokenAmount } = useSnapshot( + SwapController.state + ); + const { keyboardShown, keyboardHeight } = useKeyboard(); + const onDebouncedSwap = useDebounceCallback({ + callback: SwapController.swapTokens.bind(SwapController), + delay: 400 + }); + + const paddingBottom = Platform.select({ + android: keyboardShown ? keyboardHeight + Spacing['2xl'] : Spacing['2xl'], + default: Spacing['2xl'] + }); + + const onSourceTokenChange = (value: string) => { + SwapController.setSourceTokenAmount(value); + onDebouncedSwap(); + }; + + const onToTokenChange = (value: string) => { + SwapController.setToTokenAmount(value); + onDebouncedSwap(); + }; + + const onSourceTokenPress = () => { + RouterController.push('SwapSelectToken', { swapTarget: 'sourceToken' }); + }; + + const onToTokenPress = () => { + RouterController.push('SwapSelectToken', { swapTarget: 'toToken' }); + }; + + useEffect(() => { + SwapController.initializeState(); + // watch values + }, []); + + return ( + + + + + + + + + + ); +} 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..54c0f7021 --- /dev/null +++ b/packages/scaffold/src/views/w3m-swap-view/styles.ts @@ -0,0 +1,16 @@ +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.s + }, + tokenInput: { + marginBottom: Spacing.xs + } +}); 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..807f1ad6b 100644 --- a/packages/scaffold/src/views/w3m-wallet-send-view/index.tsx +++ b/packages/scaffold/src/views/w3m-wallet-send-view/index.tsx @@ -16,10 +16,10 @@ import { Spacing, Text } from '@reown/appkit-ui-react-native'; -import { InputToken } from '../../partials/w3m-input-token/intex'; +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() { @@ -102,7 +102,7 @@ export function WalletSendView() { keyboardShouldPersistTaps="always" > - RouterController.push('WalletSendSelectToken')} /> - + ( + + + +); +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-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-list-token/index.tsx b/packages/ui/src/composites/wui-list-token/index.tsx index 30b8662eb..97ae6e957 100644 --- a/packages/ui/src/composites/wui-list-token/index.tsx +++ b/packages/ui/src/composites/wui-list-token/index.tsx @@ -5,8 +5,12 @@ import { Text } from '../../components/wui-text'; import { useTheme } from '../../hooks/useTheme'; import { FlexView } from '../../layout/wui-flex'; import { UiUtil } from '../../utils/UiUtil'; +import { Spacing } from '../../utils/ThemeUtil'; import styles from './styles'; +const ListTokenHeight = 52; +export const ListTokenTotalHeight = ListTokenHeight + Spacing['2xs']; + export interface ListTokenProps { imageSrc: string; networkSrc?: string; @@ -36,7 +40,7 @@ export function ListToken({ alignItems="center" padding={['2xs', 'm', '2xs', 'xs']} > - + {imageSrc ? ( - {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' + })} diff --git a/packages/ui/src/composites/wui-token-button/index.tsx b/packages/ui/src/composites/wui-token-button/index.tsx index b7a489e2a..044d04a1c 100644 --- a/packages/ui/src/composites/wui-token-button/index.tsx +++ b/packages/ui/src/composites/wui-token-button/index.tsx @@ -1,4 +1,3 @@ -import type { Balance } from '@reown/appkit-common-react-native'; import { Image } from '../../components/wui-image'; import { Text } from '../../components/wui-text'; import { Button } from '../wui-button'; @@ -6,11 +5,12 @@ import styles from './styles'; export interface TokenButtonProps { onPress?: () => void; - token?: Balance; + imageUrl?: string; + symbol?: string; } -export function TokenButton({ token, onPress }: TokenButtonProps) { - if (!token) { +export function TokenButton({ imageUrl, symbol, onPress }: TokenButtonProps) { + if (!symbol) { return ( - {/* {token && ( + {(showMax || isMarketValueGreaterThanZero) && ( - - {sendValue ?? ''} + + {isMarketValueGreaterThanZero + ? `$${UiUtil.formatNumberToLocalString(marketValue, 2)}` + : ''} - - - {maxAmount ?? ''} - - Max - + {showMax && ( + + + {showMax ? maxAmount : ''} + + Max + + )} - )} */} + )} )} 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 index efa8dd610..a138c152d 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx @@ -39,13 +39,17 @@ export function SwapSelectTokenView() { if (isSourceToken) { filtered = - SwapController.state.myTokensWithBalance?.filter(token => - token.name.toLowerCase().includes(value.toLowerCase()) + SwapController.state.myTokensWithBalance?.filter( + token => + token.name.toLowerCase().includes(value.toLowerCase()) || + token.symbol.toLowerCase().includes(value.toLowerCase()) ) ?? []; } else { filtered = - SwapController.state.popularTokens?.filter(token => - token.name.toLowerCase().includes(value.toLowerCase()) + SwapController.state.popularTokens?.filter( + token => + token.name.toLowerCase().includes(value.toLowerCase()) || + token.symbol.toLowerCase().includes(value.toLowerCase()) ) ?? []; } diff --git a/packages/scaffold/src/views/w3m-swap-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx index 6812ffac7..bb37f509e 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -1,21 +1,58 @@ import { useSnapshot } from 'valtio'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { Platform, ScrollView } from 'react-native'; -import { RouterController, SwapController } from '@reown/appkit-core-react-native'; -import { FlexView, IconBox, Spacing } from '@reown/appkit-ui-react-native'; +import { + NetworkController, + RouterController, + SwapController +} from '@reown/appkit-core-react-native'; +import { Button, FlexView, IconBox, Spacing } 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 styles from './styles'; import { SwapInput } from '../../partials/w3m-swap-input'; import { useDebounceCallback } from '../../hooks/useDebounceCallback'; +import styles from './styles'; export function SwapView() { const { padding } = useCustomDimensions(); - const { initializing, sourceToken, toToken, sourceTokenAmount, toTokenAmount } = useSnapshot( - SwapController.state - ); + const { + initializing, + sourceToken, + toToken, + sourceTokenAmount, + toTokenAmount, + loadingPrices, + loadingQuote, + sourceTokenPriceInUSD, + toTokenPriceInUSD + } = useSnapshot(SwapController.state); const { keyboardShown, keyboardHeight } = useKeyboard(); + + const getActionButtonState = () => { + // if (fetchError) { + // return 'Swap' + // } + + 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 @@ -40,14 +77,51 @@ export function SwapView() { RouterController.push('SwapSelectToken', { swapTarget: 'sourceToken' }); }; + 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'); + } + }; + const onToTokenPress = () => { RouterController.push('SwapSelectToken', { swapTarget: 'toToken' }); }; + const watchTokens = useCallback(() => { + SwapController.getNetworkTokenPrice(); + SwapController.getMyTokensWithBalance(); + SwapController.swapTokens(); + }, []); + useEffect(() => { SwapController.initializeState(); - // watch values - }, []); + + const interval = setInterval(watchTokens, 10000); + + return () => { + clearInterval(interval); + }; + }, [watchTokens]); return ( + ); diff --git a/packages/scaffold/src/views/w3m-swap-view/styles.ts b/packages/scaffold/src/views/w3m-swap-view/styles.ts index 54c0f7021..9fd181b4c 100644 --- a/packages/scaffold/src/views/w3m-swap-view/styles.ts +++ b/packages/scaffold/src/views/w3m-swap-view/styles.ts @@ -12,5 +12,9 @@ export default StyleSheet.create({ }, tokenInput: { marginBottom: Spacing.xs + }, + actionButton: { + marginTop: Spacing.xs, + width: '100%' } }); From 72117c664b389b5e48e3446cea43f1033ba654bc Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:06:38 -0300 Subject: [PATCH 03/31] chore: added swap details and toggle component --- packages/common/src/utils/NumberUtil.ts | 8 +- .../partials/w3m-send-input-token/index.tsx | 2 +- .../src/partials/w3m-swap-details/index.tsx | 80 +++++++++++++++++++ .../src/partials/w3m-swap-details/styles.ts | 20 +++++ .../src/partials/w3m-swap-input/index.tsx | 2 +- .../src/views/w3m-swap-view/index.tsx | 6 +- .../src/views/w3m-wallet-send-view/index.tsx | 19 +---- .../ui/src/composites/wui-toggle/index.tsx | 63 +++++++++++++++ .../ui/src/composites/wui-toggle/styles.ts | 24 ++++++ packages/ui/src/index.ts | 1 + packages/ui/src/layout/wui-flex/index.tsx | 9 ++- 11 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 packages/scaffold/src/partials/w3m-swap-details/index.tsx create mode 100644 packages/scaffold/src/partials/w3m-swap-details/styles.ts create mode 100644 packages/ui/src/composites/wui-toggle/index.tsx create mode 100644 packages/ui/src/composites/wui-toggle/styles.ts diff --git a/packages/common/src/utils/NumberUtil.ts b/packages/common/src/utils/NumberUtil.ts index 5c34e1f66..3c1a3f982 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(/,/g, '') : a); + const bBigNumber = new BigNumber.BigNumber(typeof b === 'string' ? b.replace(/,/g, '') : b); return aBigNumber.multipliedBy(bBigNumber); }, diff --git a/packages/scaffold/src/partials/w3m-send-input-token/index.tsx b/packages/scaffold/src/partials/w3m-send-input-token/index.tsx index df8691e73..aef8d1c94 100644 --- a/packages/scaffold/src/partials/w3m-send-input-token/index.tsx +++ b/packages/scaffold/src/partials/w3m-send-input-token/index.tsx @@ -32,7 +32,7 @@ export function SendInputToken({ const onInputChange = (value: string) => { const formattedValue = value.replace(/,/g, '.'); - if (Number(formattedValue) || formattedValue === '') { + if (Number(formattedValue) >= 0 || formattedValue === '') { setInputValue(formattedValue); SendController.setTokenAmount(Number(formattedValue)); } 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..44de085e2 --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -0,0 +1,80 @@ +import { useSnapshot } from 'valtio'; +import { ConstantsUtil, SwapController } from '@reown/appkit-core-react-native'; +import { FlexView, Text, UiUtil, Toggle, useTheme } from '@reown/appkit-ui-react-native'; + +import styles from './styles'; + +// -- Constants ----------------------------------------- // +const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE; + +export function SwapDetails() { + const Theme = useTheme(); + const { maxSlippage, sourceToken, toToken, gasPriceInUSD, priceImpact } = useSnapshot( + SwapController.state + ); + 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)} + + + ); + + return ( + + + + Network cost + + + ${UiUtil.formatNumberToLocalString(gasPriceInUSD, 2)} + + + {!!priceImpact && ( + + + Price impact + + + ~{UiUtil.formatNumberToLocalString(priceImpact, 3)}% + + + )} + {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( + + + Max. slippage + + + {UiUtil.formatNumberToLocalString(maxSlippage, 6)} {toToken?.symbol}{' '} + + {slippageRate}% + + + + )} + + + + Provider fee + + + 0.85% + + + + ); +} 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..b89a7705f --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-details/styles.ts @@ -0,0 +1,20 @@ +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'] + }, + item: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: Spacing.s, + borderRadius: BorderRadius.xxs, + marginTop: Spacing['2xs'] + } +}); diff --git a/packages/scaffold/src/partials/w3m-swap-input/index.tsx b/packages/scaffold/src/partials/w3m-swap-input/index.tsx index 24adf2ccb..acd70b3b4 100644 --- a/packages/scaffold/src/partials/w3m-swap-input/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-input/index.tsx @@ -60,7 +60,7 @@ export function SwapInput({ const handleInputChange = (_value: string) => { const formattedValue = _value.replace(/,/g, '.'); - if (Number(formattedValue) || formattedValue === '') { + if (Number(formattedValue) >= 0 || formattedValue === '') { onChange?.(formattedValue); } }; diff --git a/packages/scaffold/src/views/w3m-swap-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx index bb37f509e..533e9ce4b 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -14,6 +14,7 @@ import { useCustomDimensions } from '../../hooks/useCustomDimensions'; import { SwapInput } from '../../partials/w3m-swap-input'; import { useDebounceCallback } from '../../hooks/useDebounceCallback'; import styles from './styles'; +import { SwapDetails } from '../../partials/w3m-swap-details'; export function SwapView() { const { padding } = useCustomDimensions(); @@ -26,9 +27,11 @@ export function SwapView() { loadingPrices, loadingQuote, sourceTokenPriceInUSD, - toTokenPriceInUSD + toTokenPriceInUSD, + inputError } = useSnapshot(SwapController.state); const { keyboardShown, keyboardHeight } = useKeyboard(); + const showDetails = !!sourceToken && !!toToken && !inputError; const getActionButtonState = () => { // if (fetchError) { @@ -164,6 +167,7 @@ export function SwapView() { style={styles.arrowIcon} /> + {showDetails && } diff --git a/packages/ui/src/composites/wui-toggle/index.tsx b/packages/ui/src/composites/wui-toggle/index.tsx new file mode 100644 index 000000000..70f278c3b --- /dev/null +++ b/packages/ui/src/composites/wui-toggle/index.tsx @@ -0,0 +1,63 @@ +import { useState, useRef } from 'react'; +import { + Animated, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, + Pressable +} from 'react-native'; + +import { IconBox } from '../wui-icon-box'; +import { FlexView } from '../../layout/wui-flex'; +import { Text } from '../../components/wui-text'; +import styles from './styles'; + +export interface ToggleProps { + children?: React.ReactNode; + title?: string | React.ReactNode; + style?: StyleProp; +} + +export function Toggle({ children, style, title = 'Details' }: ToggleProps) { + const [isOpen, setIsOpen] = useState(false); + const animatedHeight = useRef(new Animated.Value(0)).current; + const contentHeight = useRef(0); + + const toggleDetails = () => { + 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; + }; + + return ( + + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + + + + + + {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/index.ts b/packages/ui/src/index.ts index 7cc91e1ca..2f61957d7 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -56,6 +56,7 @@ export { SearchBar, type SearchBarProps } from './composites/wui-search-bar'; export { Snackbar, type SnackbarProps } from './composites/wui-snackbar'; export { Tabs, type TabsProps } from './composites/wui-tabs'; export { Tag, type TagProps } from './composites/wui-tag'; +export { Toggle, type ToggleProps } from './composites/wui-toggle'; export { TokenButton, type TokenButtonProps } from './composites/wui-token-button'; export { Tooltip, type TooltipProps } from './composites/wui-tooltip'; export { WalletImage, type WalletImageProps } from './composites/wui-wallet-image'; diff --git a/packages/ui/src/layout/wui-flex/index.tsx b/packages/ui/src/layout/wui-flex/index.tsx index c810bd6e1..b531bd2da 100644 --- a/packages/ui/src/layout/wui-flex/index.tsx +++ b/packages/ui/src/layout/wui-flex/index.tsx @@ -1,4 +1,4 @@ -import { type StyleProp, type ViewStyle } from 'react-native'; +import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from 'react-native'; import type { FlexAlignType, @@ -14,6 +14,7 @@ import { LeanView } from '../../components/wui-lean-view'; export interface FlexViewProps { children?: React.ReactNode; + onLayout?: (event: LayoutChangeEvent) => void; flexDirection?: FlexDirectionType; flexWrap?: FlexWrapType; flexGrow?: FlexGrowType; @@ -45,5 +46,9 @@ export function FlexView(props: FlexViewProps) { marginLeft: props.margin && UiUtil.getSpacingStyles(props.margin, 3) }; - return {props.children}; + return ( + + {props.children} + + ); } From f9dcce28221bd0992187751084a8ffc411cfd61d Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 27 Nov 2024 20:45:45 -0300 Subject: [PATCH 04/31] chore: improvements in token selector, ui improvements --- .../core/src/controllers/SwapController.ts | 34 ++++++- .../src/partials/w3m-account-tokens/index.tsx | 1 + .../src/partials/w3m-swap-details/index.tsx | 37 ++++++-- .../src/partials/w3m-swap-input/index.tsx | 2 +- .../w3m-swap-select-token-view/index.tsx | 52 ++++------- .../w3m-swap-select-token-view/styles.ts | 3 +- .../views/w3m-swap-select-token-view/utils.ts | 33 +++++++ .../src/views/w3m-swap-view/index.tsx | 44 ++++++--- .../src/views/w3m-swap-view/styles.ts | 5 +- .../ui/src/components/wui-pressable/index.tsx | 92 +++++++++++++++++++ .../src/composites/wui-list-token/index.tsx | 29 ++++-- .../src/composites/wui-list-token/styles.ts | 3 + packages/ui/src/index.ts | 1 + packages/ui/src/utils/TypesUtil.ts | 1 + 14 files changed, 268 insertions(+), 69 deletions(-) create mode 100644 packages/scaffold/src/views/w3m-swap-select-token-view/utils.ts create mode 100644 packages/ui/src/components/wui-pressable/index.tsx diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 39fb74938..19542472c 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -155,6 +155,32 @@ export const SwapController = { }; }, + 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; @@ -224,6 +250,12 @@ export const SwapController = { 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; @@ -361,7 +393,7 @@ export const SwapController = { async setTokenPrice(address: string, target: SwapInputTarget) { const { availableToSwap } = this.getParams(); - let price = state.tokensPriceMap[address] || 0; + let price = state.tokensPriceMap[address] || 0; // TODO: check this if (!price) { state.loadingPrices = true; 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-swap-details/index.tsx b/packages/scaffold/src/partials/w3m-swap-details/index.tsx index 44de085e2..3f7ac6082 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -1,17 +1,23 @@ import { useSnapshot } from 'valtio'; -import { ConstantsUtil, SwapController } from '@reown/appkit-core-react-native'; +import { SwapController } from '@reown/appkit-core-react-native'; import { FlexView, Text, UiUtil, Toggle, useTheme } from '@reown/appkit-ui-react-native'; import styles from './styles'; +import { NumberUtil } from '@reown/appkit-common-react-native'; // -- Constants ----------------------------------------- // -const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE; +// const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE; export function SwapDetails() { const Theme = useTheme(); - const { maxSlippage, sourceToken, toToken, gasPriceInUSD, priceImpact } = useSnapshot( - SwapController.state - ); + const { + maxSlippage = 0, + // sourceToken, + toToken, + gasPriceInUSD = 0, + priceImpact, + toTokenAmount + } = useSnapshot(SwapController.state); const toTokenSwappedAmount = SwapController.state.sourceTokenPriceInUSD && SwapController.state.toTokenPriceInUSD ? (1 / SwapController.state.toTokenPriceInUSD) * SwapController.state.sourceTokenPriceInUSD @@ -25,11 +31,14 @@ export function SwapDetails() { {SwapController.state.toToken?.symbol} - ${UiUtil.formatNumberToLocalString(SwapController.state.sourceTokenPriceInUSD)} + ~$ + {UiUtil.formatNumberToLocalString(SwapController.state.sourceTokenPriceInUSD)} ); + const minimumReceive = NumberUtil.parseLocalStringToNumber(toTokenAmount) - maxSlippage; + return ( )} - {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( + {minimumReceive !== undefined && minimumReceive > 0 && !!toToken?.symbol && ( + + + Minimum receive + + + {UiUtil.formatNumberToLocalString(minimumReceive, 6)} {toToken?.symbol} + + + )} + {/* {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( Max. slippage @@ -65,11 +84,11 @@ export function SwapDetails() { - )} + )} */} - Provider fee + Included provider fee 0.85% diff --git a/packages/scaffold/src/partials/w3m-swap-input/index.tsx b/packages/scaffold/src/partials/w3m-swap-input/index.tsx index acd70b3b4..3fe959068 100644 --- a/packages/scaffold/src/partials/w3m-swap-input/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-input/index.tsx @@ -123,7 +123,7 @@ export function SwapInput({ > {isMarketValueGreaterThanZero - ? `$${UiUtil.formatNumberToLocalString(marketValue, 2)}` + ? `~$${UiUtil.formatNumberToLocalString(marketValue, 2)}` : ''} {showMax && ( 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 index a138c152d..ea608135f 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { useSnapshot } from 'valtio'; -import { FlatList } from 'react-native'; +import { SectionList, type SectionListData } from 'react-native'; import { FlexView, InputText, ListToken, ListTokenTotalHeight, - Text + Text, + useTheme } from '@reown/appkit-ui-react-native'; import { @@ -20,40 +21,22 @@ import { 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 { myTokensWithBalance, popularTokens } = useSnapshot(SwapController.state); - + const { sourceToken } = useSnapshot(SwapController.state); const networkImage = AssetUtil.getNetworkImage(caipNetwork); const [tokenSearch, setTokenSearch] = useState(''); const isSourceToken = RouterController.state.data?.swapTarget === 'sourceToken'; - const [filteredTokens, setFilteredTokens] = useState( - isSourceToken ? myTokensWithBalance : popularTokens - ); + + const [filteredTokens, setFilteredTokens] = useState(createSections(isSourceToken, tokenSearch)); const onSearchChange = (value: string) => { - let filtered = []; setTokenSearch(value); - - if (isSourceToken) { - filtered = - SwapController.state.myTokensWithBalance?.filter( - token => - token.name.toLowerCase().includes(value.toLowerCase()) || - token.symbol.toLowerCase().includes(value.toLowerCase()) - ) ?? []; - } else { - filtered = - SwapController.state.popularTokens?.filter( - token => - token.name.toLowerCase().includes(value.toLowerCase()) || - token.symbol.toLowerCase().includes(value.toLowerCase()) - ) ?? []; - } - - setFilteredTokens(filtered); + setFilteredTokens(createSections(isSourceToken, value)); }; const onTokenPress = (token: SwapTokenWithBalance) => { @@ -82,16 +65,20 @@ export function SwapSelectTokenView() { clearButtonMode="while-editing" /> - []} bounces={false} fadingEdgeLength={20} contentContainerStyle={styles.tokenList} - ListHeaderComponent={ - - Your tokens + renderSectionHeader={({ section: { title } }) => ( + + {title} - } + )} ListEmptyComponent={ 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 index 23c2e7c51..80436b50e 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts @@ -7,7 +7,8 @@ export default StyleSheet.create({ maxHeight: 600 }, title: { - marginBottom: Spacing.xs + paddingTop: Spacing.s, + paddingBottom: Spacing.xs }, tokenList: { paddingHorizontal: Spacing.m 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 index 533e9ce4b..07c019d7c 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -6,15 +6,15 @@ import { RouterController, SwapController } from '@reown/appkit-core-react-native'; -import { Button, FlexView, IconBox, Spacing } from '@reown/appkit-ui-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 styles from './styles'; import { SwapDetails } from '../../partials/w3m-swap-details'; +import styles from './styles'; export function SwapView() { const { padding } = useCustomDimensions(); @@ -28,11 +28,20 @@ export function SwapView() { loadingQuote, sourceTokenPriceInUSD, toTokenPriceInUSD, + myTokensWithBalance, + gasPriceInUSD = 0, inputError } = useSnapshot(SwapController.state); + const Theme = useTheme(); const { keyboardShown, keyboardHeight } = useKeyboard(); const showDetails = !!sourceToken && !!toToken && !inputError; + const showSwitch = + myTokensWithBalance && + myTokensWithBalance.findIndex( + token => token.address === SwapController.state.toToken?.address + ) >= 0; + const getActionButtonState = () => { // if (fetchError) { // return 'Swap' @@ -110,6 +119,10 @@ export function SwapView() { RouterController.push('SwapSelectToken', { swapTarget: 'toToken' }); }; + const onSwitchPress = () => { + SwapController.switchTokens(); + }; + const watchTokens = useCallback(() => { SwapController.getNetworkTokenPrice(); SwapController.getMyTokensWithBalance(); @@ -148,24 +161,27 @@ export function SwapView() { - + {showSwitch && ( + + )} {showDetails && } 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/ui/src/composites/wui-list-token/index.tsx b/packages/ui/src/composites/wui-list-token/index.tsx index f4f0874c5..45dc14dec 100644 --- a/packages/ui/src/composites/wui-list-token/index.tsx +++ b/packages/ui/src/composites/wui-list-token/index.tsx @@ -1,11 +1,10 @@ -// 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 { Pressable } from '../../components/wui-pressable'; import styles from './styles'; export const ListTokenTotalHeight = 64; From c4ebec43306b4fa1a8791a1e4f8e04e4dc1844ec Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:35:40 -0300 Subject: [PATCH 06/31] chore: swap preview + swap call function --- packages/common/babel.config.js | 3 + packages/common/jest.config.ts | 5 + .../common/src/__tests__/NamesUtil.test.ts | 24 ++ .../common/src/__tests__/NumberUtil.test.ts | 46 +++ packages/common/src/utils/NumberUtil.ts | 6 +- .../controllers/BlockchainApiController.ts | 118 ++++++- .../src/controllers/ConnectionController.ts | 5 + .../core/src/controllers/SnackController.ts | 8 +- .../core/src/controllers/SwapController.ts | 334 +++++++++++++++++- packages/core/src/utils/SwapApiUtil.ts | 37 +- packages/core/src/utils/TypeUtil.ts | 63 ++++ packages/ethers/src/client.ts | 42 ++- packages/ethers5/src/client.ts | 40 +++ .../partials/w3m-send-input-token/index.tsx | 2 +- .../src/partials/w3m-snackbar/index.tsx | 10 +- .../src/partials/w3m-swap-details/index.tsx | 32 +- .../src/partials/w3m-swap-input/index.tsx | 2 +- .../src/views/w3m-swap-preview-view/index.tsx | 144 +++++++- .../src/views/w3m-swap-preview-view/styles.ts | 18 + .../src/views/w3m-swap-view/index.tsx | 17 +- .../ui/src/composites/wui-button/index.tsx | 2 +- .../ui/src/composites/wui-button/styles.ts | 5 +- .../ui/src/composites/wui-snackbar/index.tsx | 9 +- .../ui/src/composites/wui-toggle/index.tsx | 38 +- .../src/composites/wui-token-button/index.tsx | 43 ++- .../src/composites/wui-token-button/styles.ts | 4 + packages/wagmi/src/client.ts | 28 +- 27 files changed, 1023 insertions(+), 62 deletions(-) create mode 100644 packages/common/babel.config.js create mode 100644 packages/common/jest.config.ts create mode 100644 packages/common/src/__tests__/NamesUtil.test.ts create mode 100644 packages/common/src/__tests__/NumberUtil.test.ts create mode 100644 packages/scaffold/src/views/w3m-swap-preview-view/styles.ts diff --git a/packages/common/babel.config.js b/packages/common/babel.config.js new file mode 100644 index 000000000..6403cbe52 --- /dev/null +++ b/packages/common/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:metro-react-native-babel-preset'] +}; diff --git a/packages/common/jest.config.ts b/packages/common/jest.config.ts new file mode 100644 index 000000000..8ab281e04 --- /dev/null +++ b/packages/common/jest.config.ts @@ -0,0 +1,5 @@ +const commonConfig = { + ...require('../../jest.config'), + setupFilesAfterEnv: ['../../jest-setup.ts'] +}; +module.exports = commonConfig; diff --git a/packages/common/src/__tests__/NamesUtil.test.ts b/packages/common/src/__tests__/NamesUtil.test.ts new file mode 100644 index 000000000..87e1ac058 --- /dev/null +++ b/packages/common/src/__tests__/NamesUtil.test.ts @@ -0,0 +1,24 @@ +import { ConstantsUtil, NamesUtil } from '../index'; + +describe('NamesUtil', () => { + 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 3c1a3f982..c539cd35e 100644 --- a/packages/common/src/utils/NumberUtil.ts +++ b/packages/common/src/utils/NumberUtil.ts @@ -20,8 +20,8 @@ export const NumberUtil = { return BigNumber.BigNumber(0); } - const aBigNumber = new BigNumber.BigNumber(typeof a === 'string' ? a.replace(/,/g, '') : a); - const bBigNumber = new BigNumber.BigNumber(typeof b === 'string' ? b.replace(/,/g, '') : 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); }, @@ -67,6 +67,6 @@ export const NumberUtil = { } // Remove any commas used as thousand separators and parse the float - return parseFloat(value.replace(/,/g, '')); + return parseFloat(value.replace(/,/gu, '')); } }; diff --git a/packages/core/src/controllers/BlockchainApiController.ts b/packages/core/src/controllers/BlockchainApiController.ts index 62eeab242..fa5d1d02a 100644 --- a/packages/core/src/controllers/BlockchainApiController.ts +++ b/packages/core/src/controllers/BlockchainApiController.ts @@ -6,9 +6,15 @@ import type { BlockchainApiBalanceResponse, BlockchainApiGasPriceRequest, BlockchainApiGasPriceResponse, + BlockchainApiGenerateApproveCalldataRequest, + BlockchainApiGenerateApproveCalldataResponse, + BlockchainApiGenerateSwapCalldataRequest, + BlockchainApiGenerateSwapCalldataResponse, BlockchainApiIdentityRequest, BlockchainApiIdentityResponse, BlockchainApiLookupEnsName, + BlockchainApiSwapAllowanceRequest, + BlockchainApiSwapAllowanceResponse, BlockchainApiSwapQuoteRequest, BlockchainApiSwapQuoteResponse, BlockchainApiSwapTokensRequest, @@ -19,6 +25,7 @@ import type { BlockchainApiTransactionsResponse } from '../utils/TypeUtil'; import { OptionsController } from './OptionsController'; +import { ConstantsUtil } from '../utils/ConstantsUtil'; // -- Helpers ------------------------------------------- // const baseUrl = CoreHelperUtil.getBlockchainApiUrl(); @@ -40,10 +47,17 @@ export const BlockchainApiController = { state, fetchIdentity({ address }: BlockchainApiIdentityRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + return state.api.get({ path: `/v1/identity/${address}`, params: { projectId: OptionsController.state.projectId + }, + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion } }); }, @@ -56,8 +70,15 @@ export const BlockchainApiController = { signal, cache }: BlockchainApiTransactionsRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + return state.api.get({ path: `/v1/account/${account}/history`, + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + }, params: { projectId, cursor, @@ -69,6 +90,8 @@ export const BlockchainApiController = { }, fetchTokenPrice({ projectId, addresses }: BlockchainApiTokenPriceRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + return state.api.post({ path: '/v1/fungible/price', body: { @@ -77,7 +100,27 @@ export const BlockchainApiController = { addresses }, headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + } + }); + }, + + fetchSwapAllowance({ projectId, tokenAddress, userAddress }: BlockchainApiSwapAllowanceRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + + return state.api.get({ + path: `/v1/convert/allowance`, + params: { + projectId, + tokenAddress, + userAddress + }, + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion } }); }, @@ -107,10 +150,14 @@ export const BlockchainApiController = { to, gasPrice }: BlockchainApiSwapQuoteRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + return state.api.get({ path: `/v1/convert/quotes`, headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion }, params: { projectId, @@ -124,8 +171,15 @@ export const BlockchainApiController = { }, fetchSwapTokens({ projectId, chainId }: BlockchainApiSwapTokensRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + return state.api.get({ path: `/v1/convert/tokens`, + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + }, params: { projectId, chainId @@ -133,6 +187,59 @@ export const BlockchainApiController = { }); }, + generateSwapCalldata({ + amount, + from, + projectId, + to, + userAddress + }: BlockchainApiGenerateSwapCalldataRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + + return state.api.post({ + path: '/v1/convert/build-transaction', + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + }, + body: { + amount, + eip155: { + slippage: ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE + }, + from, + projectId, + to, + userAddress + } + }); + }, + + generateApproveCalldata({ + from, + projectId, + to, + userAddress + }: BlockchainApiGenerateApproveCalldataRequest) { + const { sdkType, sdkVersion } = OptionsController.state; + + return state.api.get({ + path: `/v1/convert/build-approve`, + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + }, + params: { + projectId, + userAddress, + from, + to + } + }); + }, + async getBalance(address: string, chainId?: string, forceUpdate?: string) { const { sdkType, sdkVersion } = OptionsController.state; @@ -152,8 +259,15 @@ export const BlockchainApiController = { }, async lookupEnsName(name: string) { + const { sdkType, sdkVersion } = OptionsController.state; + return state.api.get({ path: `/v1/profile/account/${name}`, + headers: { + 'Content-Type': 'application/json', + 'x-sdk-type': sdkType, + 'x-sdk-version': sdkVersion + }, params: { projectId: OptionsController.state.projectId, apiVersion: '2' diff --git a/packages/core/src/controllers/ConnectionController.ts b/packages/core/src/controllers/ConnectionController.ts index 4e0087901..b749b8ea3 100644 --- a/packages/core/src/controllers/ConnectionController.ts +++ b/packages/core/src/controllers/ConnectionController.ts @@ -5,6 +5,7 @@ import { CoreHelperUtil } from '../utils/CoreHelperUtil'; import { StorageUtil } from '../utils/StorageUtil'; import type { Connector, + EstimateGasTransactionArgs, SendTransactionArgs, WcWallet, WriteContractArgs @@ -31,6 +32,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; @@ -154,6 +156,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); }, 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 87e01c093..d5eba9504 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -14,12 +14,34 @@ 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; @@ -30,6 +52,14 @@ export interface SwapControllerState { 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; @@ -74,6 +104,14 @@ const initialState: SwapControllerState = { loadingBuildTransaction: false, loadingTransaction: false, + // Error states + fetchError: false, + + // Approval & Swap transaction states + approvalTransaction: undefined, + swapTransaction: undefined, + transactionError: undefined, + // Input values sourceToken: undefined, sourceTokenAmount: '', @@ -400,7 +438,7 @@ export const SwapController = { async setTokenPrice(address: string, target: SwapInputTarget) { const { availableToSwap } = this.getParams(); - let price = state.tokensPriceMap[address] || 0; // TODO: check this + let price = state.tokensPriceMap[address] || 0; if (!price) { state.loadingPrices = true; @@ -421,6 +459,7 @@ export const SwapController = { } }, + // -- Swap ---------------------------------------------- // async swapTokens() { const address = AccountController.state.address as `${string}:${string}:${string}`; const sourceToken = state.sourceToken; @@ -473,6 +512,299 @@ export const SwapController = { } }, + // -- 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('Account'); + } + SwapController.getMyTokensWithBalance(forceUpdateAddresses); + TransactionsController.fetchTransactions(); + + 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( diff --git a/packages/core/src/utils/SwapApiUtil.ts b/packages/core/src/utils/SwapApiUtil.ts index e5373d9de..be33994bc 100644 --- a/packages/core/src/utils/SwapApiUtil.ts +++ b/packages/core/src/utils/SwapApiUtil.ts @@ -1,8 +1,13 @@ import { BlockchainApiController } from '../controllers/BlockchainApiController'; import { OptionsController } from '../controllers/OptionsController'; import { NetworkController } from '../controllers/NetworkController'; -import type { BlockchainApiBalanceResponse, SwapTokenWithBalance } from './TypeUtil'; +import type { + BlockchainApiBalanceResponse, + BlockchainApiSwapAllowanceRequest, + SwapTokenWithBalance +} from './TypeUtil'; import { AccountController } from '../controllers/AccountController'; +import { ConnectionController } from '../controllers/ConnectionController'; export const SwapApiUtil = { async getTokenList() { @@ -28,6 +33,34 @@ export const SwapApiUtil = { 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; @@ -50,7 +83,7 @@ export const SwapApiUtil = { token => ({ ...token, - address: token?.address || NetworkController.getActiveNetworkTokenAddress(), //TODO: check this + address: token?.address || NetworkController.getActiveNetworkTokenAddress(), decimals: parseInt(token.quantity.decimals, 10), logoUri: token.iconUrl, eip2612: false diff --git a/packages/core/src/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index 8d085837f..f489257db 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -174,6 +174,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'; @@ -189,6 +239,12 @@ export interface BlockchainApiTokenPriceResponse { }[]; } +export interface BlockchainApiSwapAllowanceRequest { + projectId: string; + tokenAddress: string; + userAddress: string; +} + export interface BlockchainApiGasPriceRequest { projectId: string; chainId: string; @@ -580,6 +636,12 @@ export type Event = }; // -- Send Controller Types ------------------------------------- +export type EstimateGasTransactionArgs = { + chainNamespace?: undefined | 'eip155'; + address: `0x${string}`; + to: `0x${string}`; + data: `0x${string}`; +}; export interface SendTransactionArgs { to: `0x${string}`; @@ -588,6 +650,7 @@ export interface SendTransactionArgs { gas?: bigint; gasPrice: bigint; address: `0x${string}`; + chainNamespace?: 'eip155'; } export interface WriteContractArgs { diff --git a/packages/ethers/src/client.ts b/packages/ethers/src/client.ts index 79c0370f4..24643fd3c 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), diff --git a/packages/ethers5/src/client.ts b/packages/ethers5/src/client.ts index 0f48b56e1..2934282ca 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(), diff --git a/packages/scaffold/src/partials/w3m-send-input-token/index.tsx b/packages/scaffold/src/partials/w3m-send-input-token/index.tsx index aef8d1c94..1e754b699 100644 --- a/packages/scaffold/src/partials/w3m-send-input-token/index.tsx +++ b/packages/scaffold/src/partials/w3m-send-input-token/index.tsx @@ -89,7 +89,7 @@ export function SendInputToken({ numberOfLines={1} autoFocus={!!token} /> - + {token && ( { + 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 index 3f7ac6082..79757608b 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -1,18 +1,23 @@ import { useSnapshot } from 'valtio'; -import { SwapController } from '@reown/appkit-core-react-native'; +import { ConstantsUtil, SwapController } from '@reown/appkit-core-react-native'; import { FlexView, Text, UiUtil, Toggle, useTheme } from '@reown/appkit-ui-react-native'; +import { NumberUtil } from '@reown/appkit-common-react-native'; import styles from './styles'; -import { NumberUtil } from '@reown/appkit-common-react-native'; + +interface SwapDetailsProps { + initialOpen?: boolean; + canClose?: boolean; +} // -- Constants ----------------------------------------- // -// const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE; +const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE; -export function SwapDetails() { +export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { const Theme = useTheme(); const { maxSlippage = 0, - // sourceToken, + sourceToken, toToken, gasPriceInUSD = 0, priceImpact, @@ -38,18 +43,21 @@ export function SwapDetails() { ); const minimumReceive = NumberUtil.parseLocalStringToNumber(toTokenAmount) - maxSlippage; + const providerFee = SwapController.getProviderFeePrice(); return ( Network cost - ${UiUtil.formatNumberToLocalString(gasPriceInUSD, 2)} + ${UiUtil.formatNumberToLocalString(gasPriceInUSD, gasPriceInUSD < 1 ? 8 : 2)} {!!priceImpact && ( @@ -67,12 +75,13 @@ export function SwapDetails() { Minimum receive - - {UiUtil.formatNumberToLocalString(minimumReceive, 6)} {toToken?.symbol} + + {UiUtil.formatNumberToLocalString(minimumReceive, minimumReceive < 1 ? 8 : 2)}{' '} + {toToken?.symbol} )} - {/* {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( + {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( Max. slippage @@ -84,14 +93,13 @@ export function SwapDetails() { - )} */} - + )} Included provider fee - 0.85% + ${UiUtil.formatNumberToLocalString(providerFee, providerFee < 1 ? 8 : 2)} diff --git a/packages/scaffold/src/partials/w3m-swap-input/index.tsx b/packages/scaffold/src/partials/w3m-swap-input/index.tsx index 3fe959068..5b2044748 100644 --- a/packages/scaffold/src/partials/w3m-swap-input/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-input/index.tsx @@ -112,7 +112,7 @@ export function SwapInput({ editable={editable} autoFocus={autoFocus} /> - + {(showMax || isMarketValueGreaterThanZero) && ( ; + const { padding } = useCustomDimensions(); + const { keyboardShown, keyboardHeight } = useKeyboard(); + const { + sourceToken, + sourceTokenAmount, + sourceTokenPriceInUSD, + toToken, + toTokenAmount, + toTokenPriceInUSD, + loadingQuote, + loadingBuildTransaction, + loadingTransaction, + loadingApprovalTransaction + } = useSnapshot(SwapController.state); + + const sourceTokenMarketValue = + NumberUtil.parseLocalStringToNumber(sourceTokenAmount) * sourceTokenPriceInUSD; + const toTokenMarketValue = NumberUtil.parseLocalStringToNumber(toTokenAmount) * toTokenPriceInUSD; + + const paddingBottom = Platform.select({ + android: keyboardShown ? keyboardHeight + Spacing['2xl'] : Spacing['2xl'], + default: Spacing['2xl'] + }); + + const loading = + loadingQuote || loadingBuildTransaction || loadingTransaction || loadingApprovalTransaction; + + const onCancel = () => { + 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-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx index 11c76f35d..33c1aa380 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -17,7 +17,6 @@ import { SwapDetails } from '../../partials/w3m-swap-details'; import styles from './styles'; export function SwapView() { - const { padding } = useCustomDimensions(); const { initializing, sourceToken, @@ -29,10 +28,10 @@ export function SwapView() { sourceTokenPriceInUSD, toTokenPriceInUSD, myTokensWithBalance, - gasPriceInUSD = 0, inputError } = useSnapshot(SwapController.state); const Theme = useTheme(); + const { padding } = useCustomDimensions(); const { keyboardShown, keyboardHeight } = useKeyboard(); const showDetails = !!sourceToken && !!toToken && !inputError; @@ -42,6 +41,11 @@ export function SwapView() { 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 (fetchError) { // return 'Swap' @@ -70,11 +74,6 @@ export function SwapView() { delay: 400 }); - const paddingBottom = Platform.select({ - android: keyboardShown ? keyboardHeight + Spacing['2xl'] : Spacing['2xl'], - default: Spacing['2xl'] - }); - const onSourceTokenChange = (value: string) => { SwapController.setSourceTokenAmount(value); onDebouncedSwap(); @@ -165,9 +164,7 @@ export function SwapView() { )} - {loading && } + {loading && } {!loading && (typeof children === 'string' ? ( => { const buttonBaseStyle = { - borderColor: theme['gray-glass-010'] + borderColor: theme['gray-glass-020'] }; if (disabled) { return { - backgroundColor: variant === 'fill' ? theme['gray-glass-005'] : theme['gray-glass-010'], - borderColor: theme['gray-glass-002'] + backgroundColor: variant === 'fill' ? theme['gray-glass-005'] : theme['gray-glass-010'] }; } diff --git a/packages/ui/src/composites/wui-snackbar/index.tsx b/packages/ui/src/composites/wui-snackbar/index.tsx index 299e79e5f..a0e4697c2 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' ? ( + + ) : ( + + )} {message} diff --git a/packages/ui/src/composites/wui-toggle/index.tsx b/packages/ui/src/composites/wui-toggle/index.tsx index 70f278c3b..8623e61df 100644 --- a/packages/ui/src/composites/wui-toggle/index.tsx +++ b/packages/ui/src/composites/wui-toggle/index.tsx @@ -16,28 +16,44 @@ export interface ToggleProps { children?: React.ReactNode; title?: string | React.ReactNode; style?: StyleProp; + initialOpen?: boolean; + canClose?: boolean; } -export function Toggle({ children, style, title = 'Details' }: ToggleProps) { - const [isOpen, setIsOpen] = useState(false); +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 = () => { - const toValue = isOpen ? 0 : contentHeight.current; + if (canClose) { + const toValue = isOpen ? 0 : contentHeight.current; - Animated.spring(animatedHeight, { - toValue, - useNativeDriver: false, - bounciness: 0 - }).start(); + Animated.spring(animatedHeight, { + toValue, + useNativeDriver: false, + bounciness: 0 + }).start(); - setIsOpen(!isOpen); + 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 ( @@ -50,7 +66,9 @@ export function Toggle({ children, style, title = 'Details' }: ToggleProps) { ) : ( title )} - + {canClose && ( + + )} diff --git a/packages/ui/src/composites/wui-token-button/index.tsx b/packages/ui/src/composites/wui-token-button/index.tsx index 044d04a1c..7faf50105 100644 --- a/packages/ui/src/composites/wui-token-button/index.tsx +++ b/packages/ui/src/composites/wui-token-button/index.tsx @@ -1,3 +1,4 @@ +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 +7,29 @@ import styles from './styles'; export interface TokenButtonProps { onPress?: () => void; imageUrl?: string; - symbol?: string; + text?: string; + inverse?: boolean; + style?: StyleProp; + disabled?: boolean; } -export function TokenButton({ imageUrl, symbol, onPress }: TokenButtonProps) { - if (!symbol) { +export function TokenButton({ + imageUrl, + text, + inverse, + onPress, + style, + disabled = false +}: TokenButtonProps) { + if (!text) { return ( - ); } diff --git a/packages/ui/src/composites/wui-token-button/styles.ts b/packages/ui/src/composites/wui-token-button/styles.ts index 7ece57a06..2f3fe8ae1 100644 --- a/packages/ui/src/composites/wui-token-button/styles.ts +++ b/packages/ui/src/composites/wui-token-button/styles.ts @@ -14,5 +14,9 @@ export default StyleSheet.create({ height: 24, borderRadius: BorderRadius.full, marginRight: Spacing['2xs'] + }, + imageInverse: { + marginRight: 0, + marginLeft: Spacing['2xs'] } }); diff --git a/packages/wagmi/src/client.ts b/packages/wagmi/src/client.ts index eb5d42c79..e2ef997dd 100644 --- a/packages/wagmi/src/client.ts +++ b/packages/wagmi/src/client.ts @@ -16,6 +16,7 @@ import { getEnsAddress as wagmiGetEnsAddress, getBalance, prepareTransactionRequest, + estimateGas as wagmiEstimateGas, sendTransaction as wagmiSendTransaction, waitForTransactionReceipt, writeContract as wagmiWriteContract @@ -37,7 +38,8 @@ import { type Token, AppKitScaffold, type WriteContractArgs, - type AppKitFrameProvider + type AppKitFrameProvider, + type EstimateGasTransactionArgs } from '@reown/appkit-scaffold-react-native'; import { ConstantsUtil, @@ -287,6 +289,30 @@ export class AppKit extends AppKitScaffold { return tx; }, + estimateGas: async ({ + address, + to, + data, + chainNamespace + }: EstimateGasTransactionArgs): Promise => { + if (chainNamespace && chainNamespace !== 'eip155') { + throw new Error('estimateGas - chainNamespace is not eip155'); + } + + try { + const result = await wagmiEstimateGas(this.wagmiConfig, { + account: address as Hex, + to: to as Hex, + data: data as Hex, + type: 'legacy' + }); + + return result; + } catch (error) { + throw new Error('WagmiAdapter:estimateGas - error estimating gas'); + } + }, + parseUnits, formatUnits, From 51f00989675afe3392e6f2f9f56627289be9a789 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 Nov 2024 13:05:54 -0300 Subject: [PATCH 07/31] chore: reload balance and transactions after swap --- packages/core/src/controllers/SwapController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index d5eba9504..e264760db 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -779,7 +779,8 @@ export const SwapController = { RouterController.replace('Account'); } SwapController.getMyTokensWithBalance(forceUpdateAddresses); - TransactionsController.fetchTransactions(); + AccountController.fetchTokenBalance(); + TransactionsController.fetchTransactions(AccountController.state.address, true); return transactionHash; } catch (err) { From 30758a51f79e6f38488cdb9c34f2ac8c748e2a37 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:43:36 -0300 Subject: [PATCH 08/31] chore: added unsupported network view --- .../src/controllers/ConnectionController.ts | 3 +- .../core/src/controllers/SwapController.ts | 52 +++++------ packages/core/src/index.ts | 2 + packages/core/src/utils/ConnectionUtil.ts | 28 ++++++ packages/core/src/utils/CoreHelperUtil.ts | 21 ++++- packages/core/src/utils/NetworkUtil.ts | 30 ++++++ packages/core/src/utils/RouterUtil.ts | 5 +- packages/core/src/utils/TypeUtil.ts | 1 + .../scaffold/src/modal/w3m-router/index.tsx | 3 + .../w3m-account-wallet-features/index.tsx | 9 +- .../src/partials/w3m-header/index.tsx | 2 +- .../views/w3m-account-default-view/index.tsx | 20 +--- .../views/w3m-network-switch-view/index.tsx | 5 +- .../src/views/w3m-networks-view/index.tsx | 49 +++------- .../src/views/w3m-swap-view/index.tsx | 14 +++ .../w3m-unsupported-chain-view/index.tsx | 92 +++++++++++++++++++ .../w3m-unsupported-chain-view/styles.ts | 21 +++++ 17 files changed, 265 insertions(+), 92 deletions(-) create mode 100644 packages/core/src/utils/ConnectionUtil.ts create mode 100644 packages/core/src/utils/NetworkUtil.ts create mode 100644 packages/scaffold/src/views/w3m-unsupported-chain-view/index.tsx create mode 100644 packages/scaffold/src/views/w3m-unsupported-chain-view/styles.ts diff --git a/packages/core/src/controllers/ConnectionController.ts b/packages/core/src/controllers/ConnectionController.ts index b749b8ea3..ff88287ff 100644 --- a/packages/core/src/controllers/ConnectionController.ts +++ b/packages/core/src/controllers/ConnectionController.ts @@ -10,7 +10,6 @@ import type { WcWallet, WriteContractArgs } from '../utils/TypeUtil'; -import { RouterController } from './RouterController'; import { ConnectorController } from './ConnectorController'; // -- Types --------------------------------------------- // @@ -191,6 +190,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/SwapController.ts b/packages/core/src/controllers/SwapController.ts index e264760db..023d40cc0 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -16,7 +16,7 @@ import { AccountController } from './AccountController'; import { CoreHelperUtil } from '../utils/CoreHelperUtil'; import { ConnectionController } from './ConnectionController'; import { TransactionsController } from './TransactionsController'; -// import { EventsController } from './EventsController'; +import { EventsController } from './EventsController'; // -- Constants ---------------------------------------- // export const INITIAL_GAS_LIMIT = 150000; @@ -762,18 +762,18 @@ export const SwapController = { 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' - // } - // }); + 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('Account'); @@ -788,19 +788,19 @@ export const SwapController = { 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' - // } - // }); + 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; } 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..a3cd486d7 --- /dev/null +++ b/packages/core/src/utils/ConnectionUtil.ts @@ -0,0 +1,28 @@ +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' + }); + throw new Error('ERROR PA'); + } catch (error) { + EventsController.sendEvent({ + type: 'track', + event: 'DISCONNECT_ERROR' + }); + } + } +}; diff --git a/packages/core/src/utils/CoreHelperUtil.ts b/packages/core/src/utils/CoreHelperUtil.ts index 6b67fa119..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 { @@ -268,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..64f8bbf4a --- /dev/null +++ b/packages/core/src/utils/NetworkUtil.ts @@ -0,0 +1,30 @@ +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 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'; + + if (isConnected && caipNetwork?.id !== network.id) { + if (approvedCaipNetworkIds?.includes(network.id) && !isAuthConnected) { + await NetworkController.switchActiveNetwork(network); + RouterUtil.navigateAfterNetworkSwitch(['ConnectingSiwe']); + + return { type: 'SWITCH_NETWORK', networkId: network.id }; + } else if (supportsAllNetworks || isAuthConnected) { + RouterController.push('SwitchNetwork', { network }); + } + } else if (!isConnected) { + NetworkController.setCaipNetwork(network); + RouterController.push('Connect'); + } + + return; + } +}; 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/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index f489257db..cc147a60a 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -573,6 +573,7 @@ export type Event = swapToToken: string; swapFromAmount: string; swapToAmount: string; + message: string; }; } | { diff --git a/packages/scaffold/src/modal/w3m-router/index.tsx b/packages/scaffold/src/modal/w3m-router/index.tsx index 43569ab55..d82091cf7 100644 --- a/packages/scaffold/src/modal/w3m-router/index.tsx +++ b/packages/scaffold/src/modal/w3m-router/index.tsx @@ -22,6 +22,7 @@ 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'; @@ -86,6 +87,8 @@ export function AppKitRouter() { 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-wallet-features/index.tsx b/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx index 2883e2c85..11e734218 100644 --- a/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx +++ b/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx @@ -8,8 +8,7 @@ import { EventsController, NetworkController, OptionsController, - RouterController, - SnackController + RouterController } from '@reown/appkit-core-react-native'; import type { Balance as BalanceType } from '@reown/appkit-common-react-native'; import { AccountActivity } from '../w3m-account-activity'; @@ -49,11 +48,7 @@ export function AccountWalletFeatures() { NetworkController.state.caipNetwork?.id && !ConstantsUtil.SWAP_SUPPORTED_NETWORKS.includes(`${NetworkController.state.caipNetwork.id}`) ) { - SnackController.showError('Unsupported Chain'); - // RouterController.push('UnsupportedChain', { - // swapUnsupportedChain: true - // }); - RouterController.push('Swap'); + RouterController.push('UnsupportedChain'); } else { EventsController.sendEvent({ type: 'track', diff --git a/packages/scaffold/src/partials/w3m-header/index.tsx b/packages/scaffold/src/partials/w3m-header/index.tsx index cfeb43af0..bdedf2ff9 100644 --- a/packages/scaffold/src/partials/w3m-header/index.tsx +++ b/packages/scaffold/src/partials/w3m-header/index.tsx @@ -46,7 +46,7 @@ export function Header() { SwapSelectToken: 'Select token', SwapPreview: 'Review swap', Transactions: 'Activity', - UnsupportedChain: 'Unsupported network', + UnsupportedChain: 'Switch network', UpdateEmailPrimaryOtp: 'Confirm current email', UpdateEmailSecondaryOtp: 'Confirm new email', UpdateEmailWallet: 'Edit email', 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 8988f3ed8..75d92cf10 100644 --- a/packages/scaffold/src/views/w3m-account-default-view/index.tsx +++ b/packages/scaffold/src/views/w3m-account-default-view/index.tsx @@ -8,6 +8,7 @@ import { ConnectionController, ConnectorController, CoreHelperUtil, + ConnectionUtil, EventsController, ModalController, NetworkController, @@ -57,22 +58,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 () => { 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 2803d2382..c2b374048 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 => ( { + 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'); }; 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'] + } +}); From 09fedf1a6f6ca60bac0ec161a9ec9a6dc988823e Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:52:44 -0300 Subject: [PATCH 09/31] chore: refresh swap when selecting a new source token --- packages/core/src/controllers/SwapController.ts | 9 +++------ packages/ui/src/components/wui-pressable/index.tsx | 14 +++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 023d40cc0..afd7fece2 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -437,12 +437,12 @@ export const SwapController = { }, async setTokenPrice(address: string, target: SwapInputTarget) { - const { availableToSwap } = this.getParams(); let price = state.tokensPriceMap[address] || 0; if (!price) { state.loadingPrices = true; price = await this.getAddressPrice(address); + state.loadingPrices = false; } if (target === 'sourceToken') { @@ -451,11 +451,8 @@ export const SwapController = { state.toTokenPriceInUSD = price; } - if (state.loadingPrices) { - state.loadingPrices = false; - if (availableToSwap) { - this.swapTokens(); - } + if (this.getParams().availableToSwap) { + this.swapTokens(); } }, diff --git a/packages/ui/src/components/wui-pressable/index.tsx b/packages/ui/src/components/wui-pressable/index.tsx index 74ce56f1b..1dd9ab329 100644 --- a/packages/ui/src/components/wui-pressable/index.tsx +++ b/packages/ui/src/components/wui-pressable/index.tsx @@ -17,7 +17,7 @@ export interface PressableProps extends RNPressableProps { backgroundColor?: ColorType | 'transparent'; pressedBackgroundColor?: ColorType; bounceScale?: number; - bounceDuration?: number; + animationDuration?: number; disabled?: boolean; pressable?: boolean; } @@ -30,8 +30,8 @@ export function Pressable({ onPress, backgroundColor = 'gray-glass-002', pressedBackgroundColor = 'gray-glass-010', - bounceScale = 0.99, // Scale to 98% of original size - bounceDuration = 200, // 100ms animation + bounceScale = 0.99, // Scale to 99% of original size + animationDuration = 200, // 200ms animation ...rest }: PressableProps) { const Theme = useTheme(); @@ -43,13 +43,13 @@ export function Pressable({ Animated.timing(pressAnimation.current, { toValue: 1, useNativeDriver: false, - duration: bounceDuration + duration: animationDuration }), Animated.timing(scaleAnimation.current, { toValue: bounceScale, useNativeDriver: false, - duration: bounceDuration + duration: animationDuration }) ]).start(); }; @@ -59,12 +59,12 @@ export function Pressable({ Animated.timing(pressAnimation.current, { toValue: 0, useNativeDriver: false, - duration: bounceDuration + duration: animationDuration }), Animated.timing(scaleAnimation.current, { toValue: 1, useNativeDriver: false, - duration: bounceDuration + duration: animationDuration }) ]).start(); }; From ec3e36e03371b57fce5495cca6be5d35a897ce10 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:55:32 -0300 Subject: [PATCH 10/31] chore: change loading after setting new prices --- packages/core/src/controllers/SwapController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index afd7fece2..2b89bbdbf 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -442,7 +442,6 @@ export const SwapController = { if (!price) { state.loadingPrices = true; price = await this.getAddressPrice(address); - state.loadingPrices = false; } if (target === 'sourceToken') { @@ -451,6 +450,10 @@ export const SwapController = { state.toTokenPriceInUSD = price; } + if (state.loadingPrices) { + state.loadingPrices = false; + } + if (this.getParams().availableToSwap) { this.swapTokens(); } From 8e296039124b251ad63432a96cd2b6772140d8e8 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:45:09 -0300 Subject: [PATCH 11/31] chore: refresh values after pressing max button --- packages/scaffold/src/views/w3m-swap-view/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/scaffold/src/views/w3m-swap-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx index bf67b8257..063fed1de 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -129,6 +129,7 @@ export function SwapView() { : NumberUtil.bigNumber(_balance); SwapController.setSourceTokenAmount(maxValue.isGreaterThan(0) ? maxValue.toFixed(20) : '0'); + SwapController.swapTokens(); } }; From a9af22d6c7afbd6b712c39239562a1d182f6a771 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:57:14 -0300 Subject: [PATCH 12/31] chore: added suggested token selector --- .../w3m-swap-select-token-view/index.tsx | 33 ++++++++++++++++--- .../w3m-swap-select-token-view/styles.ts | 18 ++++++++-- .../src/composites/wui-input-text/index.tsx | 12 +++++-- 3 files changed, 55 insertions(+), 8 deletions(-) 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 index ea608135f..b35e09702 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; import { useSnapshot } from 'valtio'; -import { SectionList, type SectionListData } from 'react-native'; +import { ScrollView, SectionList, type SectionListData } from 'react-native'; import { FlexView, InputText, ListToken, ListTokenTotalHeight, + Separator, Text, + TokenButton, useTheme } from '@reown/appkit-ui-react-native'; @@ -27,12 +29,13 @@ export function SwapSelectTokenView() { const { padding } = useCustomDimensions(); const Theme = useTheme(); const { caipNetwork } = useSnapshot(NetworkController.state); - const { sourceToken } = useSnapshot(SwapController.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?.slice(0, 8); const onSearchChange = (value: string) => { setTokenSearch(value); @@ -56,15 +59,37 @@ export function SwapSelectTokenView() { margin={['l', '0', '2xl', '0']} style={[styles.container, { paddingHorizontal: padding }]} > - + + {!isSourceToken && ( + + {suggestedList?.map((token, index) => ( + onTokenPress(token)} + style={index !== suggestedList.length - 1 ? styles.suggestedToken : undefined} + /> + ))} + + )} + []} bounces={false} @@ -74,7 +99,7 @@ export function SwapSelectTokenView() { {title} 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 index 80436b50e..ffc103faa 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/styles.ts @@ -7,10 +7,24 @@ export default StyleSheet.create({ maxHeight: 600 }, title: { - paddingTop: Spacing.s, - paddingBottom: Spacing.xs + 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/ui/src/composites/wui-input-text/index.tsx b/packages/ui/src/composites/wui-input-text/index.tsx index 6903e056e..c4d252275 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()} testID={rest.testID} From 662cc69fc5382b8645ceb3364a2584bbba553e47 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:46:34 -0300 Subject: [PATCH 13/31] chore: reset swap state when network is changed and in swap navigation --- packages/core/src/controllers/SwapController.ts | 12 +++++++----- packages/core/src/utils/NetworkUtil.ts | 9 ++++++--- .../partials/w3m-account-wallet-features/index.tsx | 4 +++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 2b89bbdbf..e0d330ec2 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -246,12 +246,14 @@ export const SwapController = { if (networkToken) { state.networkTokenSymbol = networkToken.symbol; - const sourceToken = state.myTokensWithBalance?.find(token => - token.address.startsWith(networkAddress) - ); - this.setSourceToken(sourceToken); - this.setSourceTokenAmount('1'); } + + const sourceToken = + state.myTokensWithBalance?.find(token => token.address.startsWith(networkAddress)) || + state.myTokensWithBalance?.[0]; + + this.setSourceToken(sourceToken); + this.setSourceTokenAmount('1'); }, async getTokenList() { diff --git a/packages/core/src/utils/NetworkUtil.ts b/packages/core/src/utils/NetworkUtil.ts index 64f8bbf4a..4ab2b5b48 100644 --- a/packages/core/src/utils/NetworkUtil.ts +++ b/packages/core/src/utils/NetworkUtil.ts @@ -3,6 +3,7 @@ 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 = { @@ -10,13 +11,13 @@ export const NetworkUtil = { 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']); - - return { type: 'SWITCH_NETWORK', networkId: network.id }; + eventData = { type: 'SWITCH_NETWORK', networkId: network.id }; } else if (supportsAllNetworks || isAuthConnected) { RouterController.push('SwitchNetwork', { network }); } @@ -25,6 +26,8 @@ export const NetworkUtil = { RouterController.push('Connect'); } - return; + SwapController.resetState(); + + return eventData; } }; 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 11e734218..659dddf40 100644 --- a/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx +++ b/packages/scaffold/src/partials/w3m-account-wallet-features/index.tsx @@ -8,7 +8,8 @@ import { EventsController, NetworkController, OptionsController, - RouterController + 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'; @@ -50,6 +51,7 @@ export function AccountWalletFeatures() { ) { RouterController.push('UnsupportedChain'); } else { + SwapController.resetState(); EventsController.sendEvent({ type: 'track', event: 'OPEN_SWAP', From 96b592e77bdc0bb48f0bf1e5734b9b8f408015c3 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:23:53 -0300 Subject: [PATCH 14/31] chore: added info modal --- .../partials/w3m-information-modal/index.tsx | 86 ++++++++++ .../partials/w3m-information-modal/styles.ts | 33 ++++ .../src/partials/w3m-swap-details/index.tsx | 153 ++++++++++++------ .../src/partials/w3m-swap-details/styles.ts | 6 + .../src/partials/w3m-swap-details/utils.ts | 33 ++++ 5 files changed, 261 insertions(+), 50 deletions(-) create mode 100644 packages/scaffold/src/partials/w3m-information-modal/index.tsx create mode 100644 packages/scaffold/src/partials/w3m-information-modal/styles.ts create mode 100644 packages/scaffold/src/partials/w3m-swap-details/utils.ts diff --git a/packages/scaffold/src/partials/w3m-information-modal/index.tsx b/packages/scaffold/src/partials/w3m-information-modal/index.tsx new file mode 100644 index 000000000..1c45bf7c2 --- /dev/null +++ b/packages/scaffold/src/partials/w3m-information-modal/index.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from 'react'; +import { Animated, Modal, Pressable as RNPressable } from 'react-native'; +import { + FlexView, + Text, + type IconType, + IconBox, + useTheme, + Button +} from '@reown/appkit-ui-react-native'; +import styles from './styles'; + +const AnimatedPressable = Animated.createAnimatedComponent(RNPressable); + +interface InformationModalProps { + iconName: IconType; + title?: string; + description?: string; + visible: boolean; + onClose: () => void; +} + +export function InformationModal({ + iconName, + title, + description, + visible, + onClose +}: InformationModalProps) { + const Theme = useTheme(); + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: visible ? 0.7 : 0, + duration: 400, + useNativeDriver: false + }).start(); + }, [visible, fadeAnim]); + + 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..0e714d09c --- /dev/null +++ b/packages/scaffold/src/partials/w3m-information-modal/styles.ts @@ -0,0 +1,33 @@ +import { Spacing } from '@reown/appkit-ui-react-native'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + alignContent: 'center', + justifyContent: 'flex-end' + }, + backdrop: { + flex: 1, + position: 'absolute', + width: '100%', + height: '100%', + top: 0 + }, + hidden: { + display: 'none' + }, + 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-swap-details/index.tsx b/packages/scaffold/src/partials/w3m-swap-details/index.tsx index 79757608b..c43ad61ed 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -1,9 +1,20 @@ import { useSnapshot } from 'valtio'; -import { ConstantsUtil, SwapController } from '@reown/appkit-core-react-native'; -import { FlexView, Text, UiUtil, Toggle, useTheme } from '@reown/appkit-ui-react-native'; +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; @@ -23,6 +34,9 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { 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 @@ -45,63 +59,102 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { 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 - + + + Network cost + + + + + - ~{UiUtil.formatNumberToLocalString(priceImpact, 3)}% + ${UiUtil.formatNumberToLocalString(gasPriceInUSD, gasPriceInUSD < 1 ? 8 : 2)} - )} - {minimumReceive !== undefined && minimumReceive > 0 && !!toToken?.symbol && ( + {!!priceImpact && ( + + + + Price impact + + + + + + + ~{UiUtil.formatNumberToLocalString(priceImpact, 3)}% + + + )} + {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( + + + + Max. slippage + + + + + + + {UiUtil.formatNumberToLocalString(maxSlippage, 6)} {toToken?.symbol}{' '} + + {slippageRate}% + + + + )} - - Minimum receive + + Included provider fee - {UiUtil.formatNumberToLocalString(minimumReceive, minimumReceive < 1 ? 8 : 2)}{' '} - {toToken?.symbol} - - - )} - {maxSlippage !== undefined && maxSlippage > 0 && !!sourceToken?.symbol && ( - - - Max. slippage - - - {UiUtil.formatNumberToLocalString(maxSlippage, 6)} {toToken?.symbol}{' '} - - {slippageRate}% - + ${UiUtil.formatNumberToLocalString(providerFee, providerFee < 1 ? 8 : 2)} - )} - - - 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 index b89a7705f..92fe6b172 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/styles.ts +++ b/packages/scaffold/src/partials/w3m-swap-details/styles.ts @@ -9,6 +9,9 @@ export default StyleSheet.create({ titlePrice: { marginLeft: Spacing['3xs'] }, + detailTitle: { + marginRight: Spacing['3xs'] + }, item: { flexDirection: 'row', justifyContent: 'space-between', @@ -16,5 +19,8 @@ export default StyleSheet.create({ 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' + }; + } +}; From 3393457ad7c99eaa68520dff947c9142e1fde204 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:01:40 -0300 Subject: [PATCH 15/31] chore: simplified info modal component --- .../scaffold/src/modal/w3m-modal/index.tsx | 1 + .../partials/w3m-information-modal/index.tsx | 79 +++++++------------ .../partials/w3m-information-modal/styles.ts | 15 +--- .../src/partials/w3m-swap-details/index.tsx | 6 +- .../src/views/w3m-swap-view/index.tsx | 1 - .../ui/src/composites/wui-button/styles.ts | 3 +- 6 files changed, 37 insertions(+), 68 deletions(-) diff --git a/packages/scaffold/src/modal/w3m-modal/index.tsx b/packages/scaffold/src/modal/w3m-modal/index.tsx index d4a9c4cae..258dcec96 100644 --- a/packages/scaffold/src/modal/w3m-modal/index.tsx +++ b/packages/scaffold/src/modal/w3m-modal/index.tsx @@ -122,6 +122,7 @@ export function AppKit() { coverScreen={!frameViewVisible && !webviewVisible} isVisible={open} useNativeDriver + useNativeDriverForBackdrop statusBarTranslucent hideModalContentWhileAnimating propagateSwipe diff --git a/packages/scaffold/src/partials/w3m-information-modal/index.tsx b/packages/scaffold/src/partials/w3m-information-modal/index.tsx index 1c45bf7c2..2392c6aae 100644 --- a/packages/scaffold/src/partials/w3m-information-modal/index.tsx +++ b/packages/scaffold/src/partials/w3m-information-modal/index.tsx @@ -1,5 +1,4 @@ -import { useEffect, useRef } from 'react'; -import { Animated, Modal, Pressable as RNPressable } from 'react-native'; +import Modal from 'react-native-modal'; import { FlexView, Text, @@ -10,8 +9,6 @@ import { } from '@reown/appkit-ui-react-native'; import styles from './styles'; -const AnimatedPressable = Animated.createAnimatedComponent(RNPressable); - interface InformationModalProps { iconName: IconType; title?: string; @@ -28,58 +25,40 @@ export function InformationModal({ onClose }: InformationModalProps) { const Theme = useTheme(); - const fadeAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: visible ? 0.7 : 0, - duration: 400, - useNativeDriver: false - }).start(); - }, [visible, fadeAnim]); return ( - - - - - {!!title && ( - - {title} - - )} + + + {!!title && ( + + {title} + + )} - {!!description && ( - - {description} - - )} - - + {!!description && ( + + {description} + + )} + ); diff --git a/packages/scaffold/src/partials/w3m-information-modal/styles.ts b/packages/scaffold/src/partials/w3m-information-modal/styles.ts index 0e714d09c..5fe4bd34e 100644 --- a/packages/scaffold/src/partials/w3m-information-modal/styles.ts +++ b/packages/scaffold/src/partials/w3m-information-modal/styles.ts @@ -2,21 +2,10 @@ import { Spacing } from '@reown/appkit-ui-react-native'; import { StyleSheet } from 'react-native'; export default StyleSheet.create({ - container: { - flex: 1, - alignContent: 'center', + modal: { + margin: 0, justifyContent: 'flex-end' }, - backdrop: { - flex: 1, - position: 'absolute', - width: '100%', - height: '100%', - top: 0 - }, - hidden: { - display: 'none' - }, content: { borderTopLeftRadius: 16, borderTopRightRadius: 16, diff --git a/packages/scaffold/src/partials/w3m-swap-details/index.tsx b/packages/scaffold/src/partials/w3m-swap-details/index.tsx index c43ad61ed..502c7a7e3 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -99,7 +99,7 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { Network cost - + @@ -113,7 +113,7 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { Price impact - + @@ -128,7 +128,7 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { Max. slippage - + diff --git a/packages/scaffold/src/views/w3m-swap-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx index 063fed1de..12db4da85 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -173,7 +173,6 @@ export function SwapView() { onChange={onSourceTokenChange} onTokenPress={onSourceTokenPress} onMaxPress={onSourceMaxPress} - autoFocus /> => { const buttonBaseStyle = { - borderColor: theme['gray-glass-020'] + borderColor: theme['accent-glass-020'] }; if (disabled) { return { + ...buttonBaseStyle, backgroundColor: variant === 'fill' ? theme['gray-glass-005'] : theme['gray-glass-010'] }; } From ede9670db0e25e22904ac255e5711aca06ae481d Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:29:38 -0300 Subject: [PATCH 16/31] chore: made info buttons more pressable --- packages/scaffold/src/partials/w3m-swap-details/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/scaffold/src/partials/w3m-swap-details/index.tsx b/packages/scaffold/src/partials/w3m-swap-details/index.tsx index 502c7a7e3..dd97e87a3 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.tsx +++ b/packages/scaffold/src/partials/w3m-swap-details/index.tsx @@ -98,7 +98,7 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { Network cost - + @@ -112,7 +112,7 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { Price impact - + @@ -127,7 +127,7 @@ export function SwapDetails({ initialOpen, canClose }: SwapDetailsProps) { Max. slippage - + From 35e5d73e67f780fa6dda0c1c2b7fa4c44a368358 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:36:18 -0300 Subject: [PATCH 17/31] chore: create github releases using changesets --- .github/workflows/changesets.yml | 15 +++++++-------- package.json | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) 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/package.json b/package.json index 9995cfad1..217c0b074 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "clean": "turbo clean && rm -rf node_modules && watchman watch-del-all", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore", "changeset:prepublish": "yarn run clean; yarn install; yarn version:update; yarn run lint && yarn run prettier; yarn run build; yarn run test;", - "changeset:publish": "yarn run changeset:prepublish; yarn run changeset publish --no-git-tag", + "changeset:publish": "yarn run changeset:prepublish; yarn run changeset publish", "changeset:version": "changeset version; yarn run version:update; yarn install --refresh-lockfile", "version:update": "./scripts/bump-version.sh" }, From e3d98228c9c22c0d58b8d3d24e1ee01110cfb07c Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:41:15 -0300 Subject: [PATCH 18/31] fix: remove source token from suggested tokens --- .../scaffold/src/views/w3m-swap-select-token-view/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index b35e09702..28752eb51 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.tsx @@ -35,7 +35,9 @@ export function SwapSelectTokenView() { const isSourceToken = RouterController.state.data?.swapTarget === 'sourceToken'; const [filteredTokens, setFilteredTokens] = useState(createSections(isSourceToken, tokenSearch)); - const suggestedList = suggestedTokens?.slice(0, 8); + const suggestedList = suggestedTokens + ?.filter(token => token.address !== SwapController.state.sourceToken?.address) + .slice(0, 8); const onSearchChange = (value: string) => { setTokenSearch(value); From 26a6d245ad0c3ffb4d84747fb91123ca38fa5bd4 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:41:17 -0300 Subject: [PATCH 19/31] fix: show activity correctly --- .../partials/w3m-account-activity/index.tsx | 31 +++-- .../components/auth-buttons.tsx | 2 +- packages/ui/src/utils/TransactionUtil.ts | 124 ++++++++++-------- packages/wallet/src/AppKitAuthWebview.tsx | 1 + 4 files changed, 89 insertions(+), 69 deletions(-) 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/views/w3m-account-default-view/components/auth-buttons.tsx b/packages/scaffold/src/views/w3m-account-default-view/components/auth-buttons.tsx index 7a1d903b2..70524cb51 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,7 @@ export function AuthButtons({ ) : ( - + {text} diff --git a/packages/ui/src/utils/TransactionUtil.ts b/packages/ui/src/utils/TransactionUtil.ts index b05374079..680b37915 100644 --- a/packages/ui/src/utils/TransactionUtil.ts +++ b/packages/ui/src/utils/TransactionUtil.ts @@ -2,7 +2,8 @@ import { DateUtil } from '@reown/appkit-common-react-native'; import type { TransactionTransfer, Transaction, - TransactionImage + TransactionImage, + TransactionMetadata } from '@reown/appkit-common-react-native'; import type { TransactionType } from './TypesUtil'; import { UiUtil } from './UiUtil'; @@ -24,20 +25,22 @@ export const TransactionUtil = { }, getTransactionImages(transfers: TransactionTransfer[]): TransactionImage[] { - const [transfer, secondTransfer] = transfers; - const isAllNFT = Boolean(transfer) && transfers?.every(item => Boolean(item.nft_info)); + const isAllNFT = Boolean(transfers[0]) && transfers?.every(item => Boolean(item.nft_info)); const haveMultipleTransfers = transfers?.length > 1; const haveTwoTransfers = transfers?.length === 2; if (haveTwoTransfers && !isAllNFT) { - return [this.getTransactionImage(transfer), this.getTransactionImage(secondTransfer)]; + const first = transfers.find(t => t?.direction === 'out'); + const second = transfers.find(t => t?.direction === 'in'); + + return [this.getTransactionImage(first), this.getTransactionImage(second)]; } if (haveMultipleTransfers) { return transfers.map(item => this.getTransactionImage(item)); } - return [this.getTransactionImage(transfer)]; + return [this.getTransactionImage(transfers[0])]; }, getTransactionImage(transfer?: TransactionTransfer): TransactionImage { @@ -72,66 +75,83 @@ export const TransactionUtil = { }, getTransactionDescriptions(transaction: Transaction) { - const type = transaction?.metadata?.operationType as TransactionType; + if (!transaction.metadata) { + return ['Unknown transaction']; + } + const type = transaction?.metadata?.operationType as TransactionType; const transfers = transaction?.transfers; - const haveTransfer = transaction?.transfers?.length > 0; - const haveMultipleTransfers = transaction?.transfers?.length > 1; - const isSendOrReceive = type === 'send' || type === 'receive'; - const isFungible = - haveTransfer && transfers?.every(transfer => Boolean(transfer?.fungible_info)); - const [firstTransfer, secondTransfer] = transfers; - - let firstDescription = this.getTransferDescription(firstTransfer); - let secondDescription = this.getTransferDescription(secondTransfer); - - if (!haveTransfer) { - if (isSendOrReceive && isFungible) { - firstDescription = UiUtil.getTruncateString({ - string: transaction?.metadata.sentFrom, - charsStart: 4, - charsEnd: 6, - truncate: 'middle' - }); - secondDescription = UiUtil.getTruncateString({ - string: transaction?.metadata.sentTo, - charsStart: 4, - charsEnd: 6, - truncate: 'middle' - }); - - return [firstDescription, secondDescription]; - } - - return [transaction.metadata.status]; + + // Early return for trade transactions + if (type === 'trade') { + return this.getTradeDescriptions(transfers); } - if (haveMultipleTransfers) { - return transfers.map(item => this.getTransferDescription(item)); + // Handle multiple transfers + if (transfers.length > 1) { + return transfers.map(transfer => this.getTransferDescription(transfer)); } - let prefix = ''; - if (plusTypes.includes(type)) { - prefix = '+'; - } else if (minusTypes.includes(type)) { - prefix = '-'; + // Handle single transfer + if (transfers.length === 1) { + return [this.formatSingleTransfer(transfers[0]!, type, transaction.metadata)]; } - firstDescription = prefix.concat(firstDescription); + return [transaction.metadata.status]; + }, + + isSendReceiveTransaction(type: TransactionType): boolean { + return type === 'send' || type === 'receive'; + }, + + hasFungibleTransfers(transfers: TransactionTransfer[]): boolean { + return transfers.every(transfer => Boolean(transfer?.fungible_info)); + }, + + getSendReceiveDescriptions(metadata: TransactionMetadata): string[] { + return [this.truncateAddress(metadata.sentFrom), this.truncateAddress(metadata.sentTo)]; + }, + + truncateAddress(address: string): string { + return UiUtil.getTruncateString({ + string: address, + charsStart: 4, + charsEnd: 6, + truncate: 'middle' + }); + }, - if (isSendOrReceive) { + formatSingleTransfer( + transfer: TransactionTransfer, + type: TransactionType, + metadata: TransactionMetadata + ): string { + const prefix = this.getPrefix(type); + let description = prefix.concat(this.getTransferDescription(transfer)); + + if (this.isSendReceiveTransaction(type)) { const isSend = type === 'send'; - const address = UiUtil.getTruncateString({ - string: isSend ? transaction.metadata.sentTo : transaction.metadata.sentFrom, - charsStart: 4, - charsEnd: 4, - truncate: 'middle' - }); + + const address = this.truncateAddress(isSend ? metadata.sentTo : metadata.sentFrom); const arrow = isSend ? '→' : '←'; - firstDescription = firstDescription.concat(` ${arrow} ${address}`); + description = description.concat(` ${arrow} ${address}`); } - return [firstDescription]; + return description; + }, + + getPrefix(type: TransactionType): string { + if (plusTypes.includes(type)) return '+'; + if (minusTypes.includes(type)) return '-'; + + return ''; + }, + + getTradeDescriptions(transfers: TransactionTransfer[]): string[] { + const outTransfer = transfers.find(transfer => transfer?.direction === 'out'); + const inTransfer = transfers.find(transfer => transfer?.direction === 'in'); + + return [this.getTransferDescription(outTransfer), this.getTransferDescription(inTransfer)]; }, getTransferDescription(transfer?: TransactionTransfer) { diff --git a/packages/wallet/src/AppKitAuthWebview.tsx b/packages/wallet/src/AppKitAuthWebview.tsx index a7d20567a..095345785 100644 --- a/packages/wallet/src/AppKitAuthWebview.tsx +++ b/packages/wallet/src/AppKitAuthWebview.tsx @@ -139,6 +139,7 @@ function _AuthWebview() { provider.onNotConnected(() => { ConnectorController.setAuthLoading(false); ModalController.setLoading(false); + ConnectionController.setConnectedSocialProvider(undefined); if (ConnectorController.state.connectedConnector === 'AUTH') { ConnectionController.disconnect(); } From a2a8e6bcc4f2c00ec2c48702234e57e6595f069e Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:11:21 -0300 Subject: [PATCH 20/31] chore: added swap to eoa account view --- .../core/src/controllers/SwapController.ts | 9 +++- .../views/w3m-account-default-view/index.tsx | 41 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index e0d330ec2..c8ecb3797 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -777,12 +777,17 @@ export const SwapController = { } }); SwapController.resetState(); + if (!isAuthConnector) { - RouterController.replace('Account'); + RouterController.replace('AccountDefault'); } + SwapController.getMyTokensWithBalance(forceUpdateAddresses); AccountController.fetchTokenBalance(); - TransactionsController.fetchTransactions(AccountController.state.address, true); + + setTimeout(() => { + TransactionsController.fetchTransactions(AccountController.state.address, true); + }, 3000); return transactionHash; } catch (err) { 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 afc662daa..7449c2519 100644 --- a/packages/scaffold/src/views/w3m-account-default-view/index.tsx +++ b/packages/scaffold/src/views/w3m-account-default-view/index.tsx @@ -15,7 +15,9 @@ import { OptionsController, RouterController, SnackController, - type AppKitFrameProvider + type AppKitFrameProvider, + ConstantsUtil, + SwapController } from '@reown/appkit-core-react-native'; import { Avatar, @@ -47,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(); @@ -118,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'); }; @@ -227,10 +250,24 @@ export function AccountDefaultView() { {caipNetwork?.name} + + {!isAuth && features?.swaps && ( + + Swap + + )} {!isAuth && ( Date: Tue, 14 Jan 2025 17:37:30 -0300 Subject: [PATCH 21/31] chore: dont call swap if source and to tokens are the same --- packages/core/src/controllers/SwapController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index c8ecb3797..e327265fb 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -171,6 +171,7 @@ export const SwapController = { const invalidSourceToken = !state.sourceToken?.address || !state.sourceToken?.decimals || + state.sourceToken.address === state.toToken?.address || !NumberUtil.bigNumber(state.sourceTokenAmount).isGreaterThan(0); const invalidSourceTokenAmount = !state.sourceTokenAmount; From fb2af734e351d557d63a9439035259bdf2d27840 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:12:06 -0300 Subject: [PATCH 22/31] chore: added bundle id in secure site url, disable loader on auth provider timeout --- apps/native/App.tsx | 3 ++- package.json | 3 ++- packages/core/src/controllers/SwapController.ts | 2 +- packages/core/src/utils/TypeUtil.ts | 1 - packages/ethers/src/client.ts | 1 + packages/ethers5/src/client.ts | 1 + packages/wagmi/src/client.ts | 1 + packages/wallet/src/AppKitAuthWebview.tsx | 5 +---- packages/wallet/src/AppKitFrameProvider.ts | 8 +++----- 9 files changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 8f3c7f98a..9c39a08c0 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -77,7 +77,8 @@ createAppKit({ features: { email: true, socials: ['x', 'farcaster', 'discord', 'apple'], - emailShowWallets: true + emailShowWallets: true, + swaps: true } }); diff --git a/package.json b/package.json index 7dbaf2c12..54ed7525d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "test": "turbo run test --parallel", "clean": "turbo clean && rm -rf node_modules && watchman watch-del-all", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore", - "changeset:prepublish": "yarn run clean; yarn install; yarn version:update; yarn run lint && yarn run prettier; yarn run build; yarn run test;", + "playwright:test": "cd apps/native && yarn playwright:test", + "changeset:prepublish": "yarn run clean; yarn install; yarn version:update; yarn run lint && yarn run prettier; yarn run build; yarn run test", "changeset:publish": "yarn run changeset:prepublish; yarn run changeset publish --no-git-tag", "changeset:version": "changeset version; yarn run version:update; yarn install --refresh-lockfile", "version:update": "./scripts/bump-version.sh" diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index e327265fb..260db03e2 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -788,7 +788,7 @@ export const SwapController = { setTimeout(() => { TransactionsController.fetchTransactions(AccountController.state.address, true); - }, 3000); + }, 5000); return transactionHash; } catch (err) { diff --git a/packages/core/src/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index 39cce07f8..ee42ebca6 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -713,7 +713,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 71f96359e..846fd8a26 100644 --- a/packages/ethers/src/client.ts +++ b/packages/ethers/src/client.ts @@ -1012,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 d767c23b8..083096c2f 100644 --- a/packages/ethers5/src/client.ts +++ b/packages/ethers5/src/client.ts @@ -989,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/wagmi/src/client.ts b/packages/wagmi/src/client.ts index fe65cea4c..75a490346 100644 --- a/packages/wagmi/src/client.ts +++ b/packages/wagmi/src/client.ts @@ -624,6 +624,7 @@ export class AppKit extends AppKitScaffold { provider.setOnTimeout(async () => { this.handleAlertError(ErrorUtil.ALERT_ERRORS.SOCIALS_TIMEOUT); + this.setLoading(false); }); } } diff --git a/packages/wallet/src/AppKitAuthWebview.tsx b/packages/wallet/src/AppKitAuthWebview.tsx index 095345785..15c629b8d 100644 --- a/packages/wallet/src/AppKitAuthWebview.tsx +++ b/packages/wallet/src/AppKitAuthWebview.tsx @@ -167,10 +167,7 @@ function _AuthWebview() { ]} > Date: Wed, 15 Jan 2025 14:18:17 -0300 Subject: [PATCH 23/31] chore: code improvements --- .../controllers/BlockchainApiController.ts | 97 ++++--------------- packages/core/src/utils/ConnectionUtil.ts | 1 - .../src/views/w3m-swap-view/index.tsx | 4 - 3 files changed, 21 insertions(+), 81 deletions(-) diff --git a/packages/core/src/controllers/BlockchainApiController.ts b/packages/core/src/controllers/BlockchainApiController.ts index fa5d1d02a..2b982d408 100644 --- a/packages/core/src/controllers/BlockchainApiController.ts +++ b/packages/core/src/controllers/BlockchainApiController.ts @@ -30,6 +30,16 @@ 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; @@ -47,18 +57,12 @@ export const BlockchainApiController = { state, fetchIdentity({ address }: BlockchainApiIdentityRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/identity/${address}`, params: { projectId: OptionsController.state.projectId }, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - } + headers: getHeaders() }); }, @@ -70,15 +74,9 @@ export const BlockchainApiController = { signal, cache }: BlockchainApiTransactionsRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/account/${account}/history`, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { projectId, cursor, @@ -90,8 +88,6 @@ export const BlockchainApiController = { }, fetchTokenPrice({ projectId, addresses }: BlockchainApiTokenPriceRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.post({ path: '/v1/fungible/price', body: { @@ -99,17 +95,11 @@ export const BlockchainApiController = { currency: 'usd', addresses }, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - } + headers: getHeaders() }); }, fetchSwapAllowance({ projectId, tokenAddress, userAddress }: BlockchainApiSwapAllowanceRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/convert/allowance`, params: { @@ -117,24 +107,14 @@ export const BlockchainApiController = { tokenAddress, userAddress }, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - } + headers: getHeaders() }); }, fetchGasPrice({ projectId, chainId }: BlockchainApiGasPriceRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - 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 @@ -150,15 +130,9 @@ export const BlockchainApiController = { to, gasPrice }: BlockchainApiSwapQuoteRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/convert/quotes`, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { projectId, amount, @@ -171,15 +145,9 @@ export const BlockchainApiController = { }, fetchSwapTokens({ projectId, chainId }: BlockchainApiSwapTokensRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/convert/tokens`, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { projectId, chainId @@ -194,15 +162,9 @@ export const BlockchainApiController = { to, userAddress }: BlockchainApiGenerateSwapCalldataRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.post({ path: '/v1/convert/build-transaction', - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), body: { amount, eip155: { @@ -222,15 +184,9 @@ export const BlockchainApiController = { to, userAddress }: BlockchainApiGenerateApproveCalldataRequest) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/convert/build-approve`, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { projectId, userAddress, @@ -241,14 +197,9 @@ export const BlockchainApiController = { }, async getBalance(address: string, chainId?: string, forceUpdate?: string) { - const { sdkType, sdkVersion } = OptionsController.state; - 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, @@ -259,15 +210,9 @@ export const BlockchainApiController = { }, async lookupEnsName(name: string) { - const { sdkType, sdkVersion } = OptionsController.state; - return state.api.get({ path: `/v1/profile/account/${name}`, - headers: { - 'Content-Type': 'application/json', - 'x-sdk-type': sdkType, - 'x-sdk-version': sdkVersion - }, + headers: getHeaders(), params: { projectId: OptionsController.state.projectId, apiVersion: '2' diff --git a/packages/core/src/utils/ConnectionUtil.ts b/packages/core/src/utils/ConnectionUtil.ts index a3cd486d7..0803b6998 100644 --- a/packages/core/src/utils/ConnectionUtil.ts +++ b/packages/core/src/utils/ConnectionUtil.ts @@ -17,7 +17,6 @@ export const ConnectionUtil = { type: 'track', event: 'DISCONNECT_SUCCESS' }); - throw new Error('ERROR PA'); } catch (error) { EventsController.sendEvent({ type: 'track', diff --git a/packages/scaffold/src/views/w3m-swap-view/index.tsx b/packages/scaffold/src/views/w3m-swap-view/index.tsx index 12db4da85..329e96391 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.tsx +++ b/packages/scaffold/src/views/w3m-swap-view/index.tsx @@ -49,10 +49,6 @@ export function SwapView() { }); const getActionButtonState = () => { - // if (fetchError) { - // return 'Swap' - // } - if (!SwapController.state.sourceToken || !SwapController.state.toToken) { return { text: 'Select token', disabled: true }; } From 278023fa03c4a09be4c2b2b0d2b65e86d05e589b Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:33:44 -0300 Subject: [PATCH 24/31] chore: added changeset file --- .changeset/fair-ravens-shop.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/fair-ravens-shop.md 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 From be9124095fdcab14dfa678522432f6ebdf1633db Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:56:54 -0300 Subject: [PATCH 25/31] chore: code improvements --- packages/core/src/controllers/SwapController.ts | 6 +++--- packages/core/src/utils/SwapCalculationUtil.ts | 4 ++-- packages/core/src/utils/TypeUtil.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 260db03e2..ce2601af3 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -718,7 +718,7 @@ export const SwapController = { const error = err as TransactionError; state.transactionError = error?.shortMessage as unknown as string; state.loadingApprovalTransaction = false; - SnackController.showError(error?.shortMessage || 'Transaction error'); + SnackController.showError(error?.shortMessage ?? 'Transaction error'); } }, @@ -795,12 +795,12 @@ export const SwapController = { const error = err as TransactionError; state.transactionError = error?.shortMessage; state.loadingTransaction = false; - SnackController.showError(error?.shortMessage || 'Transaction error'); + SnackController.showError(error?.shortMessage ?? 'Transaction error'); EventsController.sendEvent({ type: 'track', event: 'SWAP_ERROR', properties: { - message: error?.shortMessage || error?.message || 'Unknown', + message: error?.shortMessage ?? error?.message ?? 'Unknown', network: NetworkController.state.caipNetwork?.id || '', swapFromToken: this.state.sourceToken?.symbol || '', swapToToken: this.state.toToken?.symbol || '', diff --git a/packages/core/src/utils/SwapCalculationUtil.ts b/packages/core/src/utils/SwapCalculationUtil.ts index 49303f40c..9fa64e136 100644 --- a/packages/core/src/utils/SwapCalculationUtil.ts +++ b/packages/core/src/utils/SwapCalculationUtil.ts @@ -63,7 +63,7 @@ export const SwapCalculationUtil = { }, isInsufficientNetworkTokenForGas(networkBalanceInUSD: string, gasPriceInUSD: number | undefined) { - const gasPrice = gasPriceInUSD || '0'; + const gasPrice = gasPriceInUSD ?? '0'; if (NumberUtil.bigNumber(networkBalanceInUSD).isZero()) { return true; @@ -80,7 +80,7 @@ export const SwapCalculationUtil = { const sourceTokenBalance = balance?.find(token => token.address === sourceTokenAddress) ?.quantity?.numeric; - const isInSufficientBalance = NumberUtil.bigNumber(sourceTokenBalance || '0').isLessThan( + const isInSufficientBalance = NumberUtil.bigNumber(sourceTokenBalance ?? '0').isLessThan( sourceTokenAmount ); diff --git a/packages/core/src/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index bf6b81b63..5b3babaf6 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -654,7 +654,7 @@ export type Event = // -- Send Controller Types ------------------------------------- export type EstimateGasTransactionArgs = { - chainNamespace?: undefined | 'eip155'; + chainNamespace?: 'eip155'; address: `0x${string}`; to: `0x${string}`; data: `0x${string}`; From db1e2a88d2c263fca438a99e0020aa1b2c55e360 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:30:38 -0300 Subject: [PATCH 26/31] feat: added ability to change themeMode and override accent color --- .changeset/late-cycles-lick.md | 18 +++++++ apps/native/App.tsx | 2 +- packages/common/src/utils/TypeUtil.ts | 6 +++ .../core/src/controllers/ThemeController.ts | 6 +-- packages/core/src/utils/TypeUtil.ts | 15 +++--- packages/scaffold/src/client.ts | 3 +- .../src/modal/w3m-account-button/index.tsx | 32 ++++++----- .../src/modal/w3m-connect-button/index.tsx | 26 +++++---- .../scaffold/src/modal/w3m-modal/index.tsx | 54 ++++++++++--------- .../src/modal/w3m-network-button/index.tsx | 30 ++++++----- packages/ui/package.json | 1 + packages/ui/src/context/ThemeContext.tsx | 44 +++++++++++++++ packages/ui/src/hooks/useTheme.ts | 12 ++--- packages/ui/src/index.ts | 1 + packages/ui/src/utils/ThemeUtil.ts | 18 +++++++ yarn.lock | 10 ++++ 16 files changed, 193 insertions(+), 85 deletions(-) create mode 100644 .changeset/late-cycles-lick.md create mode 100644 packages/ui/src/context/ThemeContext.tsx diff --git a/.changeset/late-cycles-lick.md b/.changeset/late-cycles-lick.md new file mode 100644 index 000000000..a217932e4 --- /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 themeMode prop diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 9c39a08c0..672675e69 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -76,7 +76,7 @@ createAppKit({ debug: true, features: { email: true, - socials: ['x', 'farcaster', 'discord', 'apple'], + socials: ['x', 'discord', 'apple'], emailShowWallets: true, swaps: true } 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/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/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index 5b3babaf6..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; @@ -140,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; diff --git a/packages/scaffold/src/client.ts b/packages/scaffold/src/client.ts index cc3ed1d95..af3e9c6bc 100644 --- a/packages/scaffold/src/client.ts +++ b/packages/scaffold/src/client.ts @@ -10,7 +10,6 @@ import type { EventsControllerState, PublicStateControllerState, ThemeControllerState, - ThemeMode, ThemeVariables, Connector, ConnectedWalletInfo, @@ -33,7 +32,7 @@ import { ThemeController, TransactionsController } from '@reown/appkit-core-react-native'; -import { ConstantsUtil, ErrorUtil } from '@reown/appkit-common-react-native'; +import { ConstantsUtil, ErrorUtil, type ThemeMode } from '@reown/appkit-common-react-native'; // -- Types --------------------------------------------------------------------- export interface LibraryOptions { 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 61ef0a29c..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,28 +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/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/context/ThemeContext.tsx b/packages/ui/src/context/ThemeContext.tsx new file mode 100644 index 000000000..298051937 --- /dev/null +++ b/packages/ui/src/context/ThemeContext.tsx @@ -0,0 +1,44 @@ +import { useColorScheme } from 'react-native'; +import { createContext, useContext, type ReactNode } from 'react'; +import type { ThemeMode, ThemeVariables } from '@reown/appkit-common-react-native'; + +import { DarkTheme, LightTheme, getAccentColors } from '../utils/ThemeUtil'; + +type ThemeContextType = { + themeMode?: ThemeMode; + themeVariables?: ThemeVariables; +}; + +export const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: ReactNode; + themeMode?: ThemeMode; + themeVariables?: ThemeVariables; +} + +export function ThemeProvider({ children, themeMode, themeVariables }: ThemeProviderProps) { + return ( + {children} + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + const scheme = useColorScheme(); + + // If the theme mode is not set, use the system color scheme + const themeMode = context?.themeMode ?? scheme; + const themeVariables = context?.themeVariables ?? {}; + + let Theme = themeMode === 'dark' ? DarkTheme : LightTheme; + + if (themeVariables.accent) { + Theme = { + ...Theme, + ...getAccentColors(themeVariables.accent) + }; + } + + return Theme; +} diff --git a/packages/ui/src/hooks/useTheme.ts b/packages/ui/src/hooks/useTheme.ts index eec122ac3..4a55a0cca 100644 --- a/packages/ui/src/hooks/useTheme.ts +++ b/packages/ui/src/hooks/useTheme.ts @@ -1,9 +1,5 @@ -import { useColorScheme } from 'react-native'; -import { DarkTheme, LightTheme } from '../utils/ThemeUtil'; +import { useTheme as useThemeContext } from '../context/ThemeContext'; -export function useTheme() { - const scheme = useColorScheme(); - const Theme = scheme === 'dark' ? DarkTheme : LightTheme; - - return Theme; -} +export const useTheme = () => { + return useThemeContext(); +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fc780cffe..b7a7251c7 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -85,4 +85,5 @@ export { TransactionUtil } from './utils/TransactionUtil'; export { Spacing, BorderRadius } from './utils/ThemeUtil'; export { useTheme } from './hooks/useTheme'; +export { ThemeProvider } from './context/ThemeContext'; export { useAnimatedValue } from './hooks/useAnimatedValue'; diff --git a/packages/ui/src/utils/ThemeUtil.ts b/packages/ui/src/utils/ThemeUtil.ts index 9671a12d8..00d1e0d26 100644 --- a/packages/ui/src/utils/ThemeUtil.ts +++ b/packages/ui/src/utils/ThemeUtil.ts @@ -1,5 +1,23 @@ +import { transparentize, darken, lighten } from 'polished'; + import type { SpacingType, ThemeKeys } from './TypesUtil'; +export const getAccentColors = (baseAccentColor: string) => { + return { + 'accent-100': baseAccentColor, + 'accent-090': lighten(0.05, baseAccentColor), + 'accent-080': lighten(0.1, baseAccentColor), + 'accent-020': darken(0.1, baseAccentColor), + 'accent-glass-090': transparentize(0.1, baseAccentColor), + 'accent-glass-080': transparentize(0.2, baseAccentColor), + 'accent-glass-020': transparentize(0.8, baseAccentColor), + 'accent-glass-015': transparentize(0.85, baseAccentColor), + 'accent-glass-010': transparentize(0.9, baseAccentColor), + 'accent-glass-005': transparentize(0.95, baseAccentColor), + 'accent-glass-002': transparentize(0.98, baseAccentColor) + }; +}; + export const DarkTheme: { [key in ThemeKeys]: string } = { 'accent-100': '#667DFF', 'accent-090': '#7388FD', diff --git a/yarn.lock b/yarn.lock index 5b846cbb1..abf64c8b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6823,6 +6823,7 @@ __metadata: version: 0.0.0-use.local resolution: "@reown/appkit-ui-react-native@workspace:packages/ui" dependencies: + polished: "npm:4.3.1" qrcode: "npm:1.5.3" peerDependencies: react: ">=17" @@ -19220,6 +19221,15 @@ __metadata: languageName: node linkType: hard +"polished@npm:4.3.1": + version: 4.3.1 + resolution: "polished@npm:4.3.1" + dependencies: + "@babel/runtime": "npm:^7.17.8" + checksum: 45480d4c7281a134281cef092f6ecc202a868475ff66a390fee6e9261386e16f3047b4de46a2f2e1cf7fb7aa8f52d30b4ed631a1e3bcd6f303ca31161d4f07fe + languageName: node + linkType: hard + "polished@npm:^4.2.2": version: 4.2.2 resolution: "polished@npm:4.2.2" From 0cb238a5f1d000f5f925d4f33c55e91bf375f60a Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:34:06 -0300 Subject: [PATCH 27/31] chore: changed changeset file --- .changeset/late-cycles-lick.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/late-cycles-lick.md b/.changeset/late-cycles-lick.md index a217932e4..0de79bcf3 100644 --- a/.changeset/late-cycles-lick.md +++ b/.changeset/late-cycles-lick.md @@ -15,4 +15,4 @@ '@reown/appkit-siwe-react-native': minor --- -feat: added themeMode prop +feat: added ability to change themeMode and override accent color From 3a72107b81da16890cdbda2a9501c63f1b14b227 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:42:39 -0300 Subject: [PATCH 28/31] chore: apply theme to account buttons --- .../w3m-account-default-view/components/auth-buttons.tsx | 9 ++++++++- .../src/views/w3m-account-default-view/index.tsx | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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 70524cb51..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 7449c2519..dba1584a9 100644 --- a/packages/scaffold/src/views/w3m-account-default-view/index.tsx +++ b/packages/scaffold/src/views/w3m-account-default-view/index.tsx @@ -239,7 +239,8 @@ export function AccountDefaultView() { From 4f7f3f6b6b356196fee5b1cf81d27101adb41fa9 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:52:04 -0300 Subject: [PATCH 29/31] chore: import issue --- packages/scaffold/src/client.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/scaffold/src/client.ts b/packages/scaffold/src/client.ts index af3e9c6bc..a5a3fc9e4 100644 --- a/packages/scaffold/src/client.ts +++ b/packages/scaffold/src/client.ts @@ -10,7 +10,6 @@ import type { EventsControllerState, PublicStateControllerState, ThemeControllerState, - ThemeVariables, Connector, ConnectedWalletInfo, Features @@ -32,7 +31,12 @@ import { ThemeController, TransactionsController } from '@reown/appkit-core-react-native'; -import { ConstantsUtil, ErrorUtil, type ThemeMode } from '@reown/appkit-common-react-native'; +import { + ConstantsUtil, + ErrorUtil, + type ThemeMode, + type ThemeVariables +} from '@reown/appkit-common-react-native'; // -- Types --------------------------------------------------------------------- export interface LibraryOptions { From 736e2b3811f7afd9267d97f6de5df8cc95ba2fb1 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:56:01 -0300 Subject: [PATCH 30/31] chore: fixed test --- packages/core/src/__tests__/controllers/ThemeController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/controllers/ThemeController.test.ts b/packages/core/src/__tests__/controllers/ThemeController.test.ts index 456db8f31..01a713658 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: {} }); }); From 370ca23029f8c2fce3ee64af53b623599dda2c57 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jan 2025 16:06:04 -0300 Subject: [PATCH 31/31] chore: fixed test --- packages/core/src/__tests__/controllers/ThemeController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/controllers/ThemeController.test.ts b/packages/core/src/__tests__/controllers/ThemeController.test.ts index 01a713658..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: 'undefined', + themeMode: undefined, themeVariables: {} }); });