diff --git a/package.json b/package.json index dd651a93e6c..4eefc18489b 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "eth-url-parser": "^1.0.4", "ethers": "^6.11.1", "ethers5": "npm:ethers@5.7.2", + "eventemitter2": "5.0.1", "framer-motion": "^11.0.3", "friendly-challenge": "0.9.2", "grapheme-splitter": "^1.0.4", @@ -206,7 +207,8 @@ "styled-components": "^6.0.7", "uuid": "^9.0.0", "vaul": "^0.9.0", - "viem": "^1.16.6", + "viem": "^2.10.9", + "wagmi": "^2.9.2", "web-vitals": "^2.1.4" }, "devDependencies": { diff --git a/packages/caip/src/constants.ts b/packages/caip/src/constants.ts index 489b68b3c69..01aba5925d0 100644 --- a/packages/caip/src/constants.ts +++ b/packages/caip/src/constants.ts @@ -18,6 +18,8 @@ export const baseAssetId: AssetId = 'eip155:8453/slip44:60' export const foxOnGnosisAssetId: AssetId = 'eip155:100/erc20:0x21a42669643f45bc0e086b8fc2ed70c23d67509d' +export const foxOnArbitrumOneAssetId: AssetId = + 'eip155:42161/erc20:0xf929de51d91c77e42f5090069e0ad7a09e513c73' export const foxAssetId: AssetId = 'eip155:1/erc20:0xc770eefad204b5180df6a14ee197d99d808ee52d' export const foxatarAssetId: AssetId = 'eip155:137/erc721:0x2e727c425a11ce6b8819b3004db332c12d2af2a2' diff --git a/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts b/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts index 568b026c056..795185a925f 100644 --- a/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts +++ b/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts @@ -5,6 +5,7 @@ import { supportsThorchain } from '@shapeshiftoss/hdwallet-core' import type { BIP44Params } from '@shapeshiftoss/types' import { KnownChainIds } from '@shapeshiftoss/types' import * as unchained from '@shapeshiftoss/unchained-client' +import { bech32 } from 'bech32' import { ErrorHandler } from '../../error/ErrorHandler' import type { @@ -16,8 +17,9 @@ import type { GetFeeDataInput, SignAndBroadcastTransactionInput, SignTxInput, + ValidAddressResult, } from '../../types' -import { ChainAdapterDisplayName, CONTRACT_INTERACTION } from '../../types' +import { ChainAdapterDisplayName, CONTRACT_INTERACTION, ValidAddressResultType } from '../../types' import { toAddressNList } from '../../utils' import { bnOrZero } from '../../utils/bignumber' import { assertAddressNotSanctioned } from '../../utils/validateAddress' @@ -110,6 +112,26 @@ export class ChainAdapter extends CosmosSdkBaseAdapter { + const THORCHAIN_PREFIX = 'thor' + + try { + const decoded = bech32.decode(address) + if (decoded.prefix !== THORCHAIN_PREFIX) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + + const wordsLength = decoded.words.length + if (wordsLength !== 32) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + return { valid: true, result: ValidAddressResultType.Valid } + } catch (e) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + } + async signTransaction(signTxInput: SignTxInput): Promise { try { const { txToSign, wallet } = signTxInput diff --git a/scripts/generateAssetData/generateAssetData.ts b/scripts/generateAssetData/generateAssetData.ts index 334eb0f5e60..1986971eba8 100644 --- a/scripts/generateAssetData/generateAssetData.ts +++ b/scripts/generateAssetData/generateAssetData.ts @@ -3,6 +3,7 @@ import 'dotenv/config' import { avalancheAssetId, ethAssetId, + foxOnArbitrumOneAssetId, fromAssetId, gnosisAssetId, polygonAssetId, @@ -166,6 +167,21 @@ const generateAssetData = async () => { generatedAssetData, ) + // Temporary workaround to circumvent the fact that no lists have that asset currently + const foxOnArbitrumOne = { + assetId: 'eip155:42161/erc20:0xf929de51d91c77e42f5090069e0ad7a09e513c73', + chainId: 'eip155:42161', + name: 'FOX on Arbitrum One', + precision: 18, + color: '#3761F9', + icon: 'https://assets.coincap.io/assets/icons/256/fox.png', + symbol: 'FOX', + explorer: 'https://arbiscan.io', + explorerAddressLink: 'https://arbiscan.io/address/', + explorerTxLink: 'https://arbiscan.io/tx/', + } + assetsWithOverridesApplied[foxOnArbitrumOneAssetId] = foxOnArbitrumOne + await fs.promises.writeFile( path.join(__dirname, '../../src/lib/asset-service/service/generatedAssetData.json'), // beautify the file for github diff. diff --git a/src/AppProviders.tsx b/src/AppProviders.tsx index 5f3247af73f..c013b4c76c6 100644 --- a/src/AppProviders.tsx +++ b/src/AppProviders.tsx @@ -14,6 +14,7 @@ import { Provider as ReduxProvider } from 'react-redux' import { HashRouter } from 'react-router-dom' import { PersistGate } from 'redux-persist/integration/react' import { ScrollToTop } from 'Routes/ScrollToTop' +import { WagmiConfig } from 'wagmi' import { ChatwootWidget } from 'components/ChatWoot' import { AppProvider } from 'context/AppProvider/AppContext' import { BrowserRouterProvider } from 'context/BrowserRouterProvider/BrowserRouterProvider' @@ -27,6 +28,7 @@ import { KeepKeyProvider } from 'context/WalletProvider/KeepKeyProvider' import { WalletProvider } from 'context/WalletProvider/WalletProvider' import { getMixPanel } from 'lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from 'lib/mixpanel/types' +import { wagmiConfig } from 'lib/wagmi-config' import { ErrorPage } from 'pages/ErrorPage/ErrorPage' import { SplashScreen } from 'pages/SplashScreen/SplashScreen' import { persistor, store } from 'state/store' @@ -71,17 +73,19 @@ export function AppProviders({ children }: ProvidersProps) { - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index d7469676478..a2328aabf49 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -108,6 +108,7 @@ "search": "Search", "searchAsset": "Search for asset", "enterAmount": "Enter Amount", + "enterAddress": "Enter Address", "pageNotFound": "The page you're looking for cannot be found", "buySell": "Buy/Sell", "all": "All", @@ -116,6 +117,7 @@ "feesPlusSlippage": "Fees + Slippage", "best": "Best", "gasFee": "Gas Fee", + "approvalFee" : "Approval Fee", "consolidationFee": "Consolidation Fee", "fees": "Fees", "alternative": "Alternative", @@ -2441,7 +2443,7 @@ "rewardAddressHelper": "This is where rewards will be sent", "useWalletAddress": "Use wallet address", "useCustomAddress": "Use custom address", - "stakeWarning": "When you stake your FOX tokens, please be aware that there is a %{cooldownPeriod} lock-up period. This means that if you decide to unstake your tokens, you will need to wait 28 days before you can claim your funds.", + "stakeWarning": "When you stake your FOX tokens, please be aware that there is a %{cooldownPeriod} lock-up period. This means that if you decide to unstake your tokens, you will need to wait %{cooldownPeriod} before you can claim your funds.", "unstakeWarning": "Before you can claim your FOX, there is a %{cooldownPeriod} cool down period. After that time has elapsed you will be able to claim your unstaked amount.", "tooltips": { "stakeAmount": "This is the amount of FOX you will stake", diff --git a/src/components/MultiHopTrade/components/TradeAmountInput.tsx b/src/components/MultiHopTrade/components/TradeAmountInput.tsx index e41d2165975..7587602cb38 100644 --- a/src/components/MultiHopTrade/components/TradeAmountInput.tsx +++ b/src/components/MultiHopTrade/components/TradeAmountInput.tsx @@ -15,7 +15,8 @@ import type { AccountId, AssetId } from '@shapeshiftoss/caip' import noop from 'lodash/noop' import type { ElementType, FocusEvent, PropsWithChildren } from 'react' import React, { memo, useCallback, useMemo, useRef, useState } from 'react' -import type { FieldError } from 'react-hook-form' +import type { ControllerRenderProps, RegisterOptions } from 'react-hook-form' +import { Controller, type FieldError, useForm, useFormContext } from 'react-hook-form' import type { NumberFormatValues } from 'react-number-format' import NumberFormat from 'react-number-format' import { useTranslate } from 'react-polyglot' @@ -26,16 +27,30 @@ import { Balance } from 'components/DeFi/components/Balance' import { PercentOptionsButtonGroup } from 'components/DeFi/components/PercentOptionsButtonGroup' import { useLocaleFormatter } from 'hooks/useLocaleFormatter/useLocaleFormatter' import { bnOrZero } from 'lib/bignumber/bignumber' +import { selectMarketDataByAssetIdUserCurrency } from 'state/slices/selectors' +import { useAppSelector } from 'state/store' import { colors } from 'theme/colors' import { usePriceImpactColor } from '../hooks/usePriceImpactColor' +export type TradeAmountInputFormValues = { + amountFieldInput: string + amountCryptoPrecision: string + amountUserCurrency: string +} + +type RenderController = ({ + field, +}: { + field: ControllerRenderProps +}) => React.ReactElement + const cryptoInputStyle = { caretColor: colors.blue[200] } const buttonProps = { variant: 'unstyled', display: 'flex', height: 'auto', lineHeight: '1' } const boxProps = { px: 0, m: 0 } const numberFormatDisabled = { opacity: 1, cursor: 'not-allowed' } -const CryptoInput = (props: InputProps) => { +const AmountInput = (props: InputProps) => { const translate = useTranslate() return ( { placeholder={translate('common.enterAmount')} style={cryptoInputStyle} autoComplete='off' + errorBorderColor='red.500' {...props} /> ) } export type TradeAmountInputProps = { + amountFieldInputRules?: Omit< + RegisterOptions, + 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled' + > autoSelectHighestBalance?: boolean assetId?: AssetId accountId?: AccountId @@ -91,9 +111,17 @@ export type TradeAmountInputProps = { } & PropsWithChildren const defaultPercentOptions = [0.25, 0.5, 0.75, 1] +const defaultFormValues = { + amountFieldInput: '', + amountCryptoPrecision: '', + amountUserCurrency: '', +} +// TODO: While this is called "TradeAmountInput", its parent TradeAssetInput is consumed by everything under the sun but swapper +// Scrutinize this and rename all Trade references here, or at the very least in the parent to something more generic for sanity export const TradeAmountInput: React.FC = memo( ({ + amountFieldInputRules, assetId, accountId, assetSymbol, @@ -137,6 +165,20 @@ export const TradeAmountInput: React.FC = memo( const focusBg = useColorModeValue('gray.50', 'gray.900') const focusBorder = useColorModeValue('blue.500', 'blue.400') + const assetMarketDataUserCurrency = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, assetId ?? ''), + ) + + // Local controller in case consumers don't have a form context, which is the case for all current consumers currently except RFOX + const _methods = useForm({ + defaultValues: defaultFormValues, + mode: 'onChange', + shouldUnregister: true, + }) + const methods = useFormContext() + const control = methods?.control ?? _methods.control + const setValue = methods?.setValue ?? _methods.setValue + // Lower the decimal places when the integer is greater than 8 significant digits for better UI const cryptoAmountIntegerCount = bnOrZero(bnOrZero(cryptoAmount).toFixed(0)).precision(true) const formattedCryptoAmount = useMemo( @@ -172,12 +214,72 @@ export const TradeAmountInput: React.FC = memo( const oppositeCurrency = useMemo(() => { return isFiat ? ( - + ) : ( ) }, [assetSymbol, cryptoAmount, fiatAmount, isFiat]) + const renderController: RenderController = useCallback( + ({ field: { onChange } }) => { + return ( + { + // Controller onChange + onChange(values.value) + handleValueChange(values) + + const value = values.value + if (isFiat) { + setValue('amountUserCurrency', value) + const _cryptoAmount = bnOrZero(value) + .div(assetMarketDataUserCurrency.price) + .toFixed() + setValue('amountCryptoPrecision', _cryptoAmount) + } else { + setValue('amountCryptoPrecision', value) + setValue( + 'amountUserCurrency', + bnOrZero(value).times(assetMarketDataUserCurrency.price).toFixed(), + ) + } + }} + onChange={handleOnChange} + onBlur={handleOnBlur} + onFocus={handleOnFocus} + /> + ) + }, + [ + assetMarketDataUserCurrency.price, + fiatAmount, + formattedCryptoAmount, + handleOnBlur, + handleOnChange, + handleOnFocus, + handleValueChange, + isFiat, + isReadOnly, + localeParts.decimal, + localeParts.group, + localeParts.postfix, + localeParts.prefix, + setValue, + ], + ) + const accountDropdownLabel = useMemo( () => ( = memo( - {RightComponent && } diff --git a/src/contracts/abis/FoxStakingV1.ts b/src/contracts/abis/FoxStakingV1.ts new file mode 100644 index 00000000000..cfe8c18f786 --- /dev/null +++ b/src/contracts/abis/FoxStakingV1.ts @@ -0,0 +1,478 @@ +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// FoxStakingV1 +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const foxStakingV1Abi = [ + { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, + { + type: 'function', + inputs: [], + name: 'UPGRADE_INTERFACE_VERSION', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: 'total', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'cooldownPeriod', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'foxToken', + outputs: [{ name: '', internalType: 'contract IERC20', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'account', internalType: 'address', type: 'address' }, + { name: 'index', internalType: 'uint256', type: 'uint256' }, + ], + name: 'getUnstakingInfo', + outputs: [ + { + name: '', + internalType: 'struct UnstakingInfo', + type: 'tuple', + components: [ + { + name: 'unstakingBalance', + internalType: 'uint256', + type: 'uint256', + }, + { name: 'cooldownExpiry', internalType: 'uint256', type: 'uint256' }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'getUnstakingInfoCount', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'foxTokenAddress', internalType: 'address', type: 'address' }], + name: 'initialize', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'pause', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'pauseStaking', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'pauseUnstaking', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'pauseWithdrawals', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'paused', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'proxiableUUID', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newCooldownPeriod', internalType: 'uint256', type: 'uint256' }], + name: 'setCooldownPeriod', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'runeAddress', internalType: 'string', type: 'string' }], + name: 'setRuneAddress', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'runeAddress', internalType: 'string', type: 'string' }, + ], + name: 'stake', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'stakingInfo', + outputs: [ + { name: 'stakingBalance', internalType: 'uint256', type: 'uint256' }, + { name: 'unstakingBalance', internalType: 'uint256', type: 'uint256' }, + { name: 'runeAddress', internalType: 'string', type: 'string' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'stakingPaused', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'unpause', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'unpauseStaking', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'unpauseUnstaking', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'unpauseWithdrawals', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'amount', internalType: 'uint256', type: 'uint256' }], + name: 'unstake', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'unstakingPaused', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'newImplementation', internalType: 'address', type: 'address' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'upgradeToAndCall', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [], + name: 'version', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'index', internalType: 'uint256', type: 'uint256' }], + name: 'withdraw', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'withdraw', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'withdrawalsPaused', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'version', + internalType: 'uint64', + type: 'uint64', + indexed: false, + }, + ], + name: 'Initialized', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'previousOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'newOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'OwnershipTransferred', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: false, + }, + ], + name: 'Paused', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'oldRuneAddress', + internalType: 'string', + type: 'string', + indexed: true, + }, + { + name: 'newRuneAddress', + internalType: 'string', + type: 'string', + indexed: true, + }, + ], + name: 'SetRuneAddress', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'runeAddress', + internalType: 'string', + type: 'string', + indexed: true, + }, + ], + name: 'Stake', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: false, + }, + ], + name: 'Unpaused', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'cooldownExpiry', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Unstake', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'newCooldownPeriod', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'UpdateCooldownPeriod', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'implementation', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'Upgraded', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Withdraw', + }, + { + type: 'error', + inputs: [{ name: 'target', internalType: 'address', type: 'address' }], + name: 'AddressEmptyCode', + }, + { + type: 'error', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'AddressInsufficientBalance', + }, + { + type: 'error', + inputs: [{ name: 'implementation', internalType: 'address', type: 'address' }], + name: 'ERC1967InvalidImplementation', + }, + { type: 'error', inputs: [], name: 'ERC1967NonPayable' }, + { type: 'error', inputs: [], name: 'EnforcedPause' }, + { type: 'error', inputs: [], name: 'ExpectedPause' }, + { type: 'error', inputs: [], name: 'FailedInnerCall' }, + { type: 'error', inputs: [], name: 'InvalidInitialization' }, + { type: 'error', inputs: [], name: 'NotInitializing' }, + { + type: 'error', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'OwnableInvalidOwner', + }, + { + type: 'error', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'OwnableUnauthorizedAccount', + }, + { + type: 'error', + inputs: [{ name: 'token', internalType: 'address', type: 'address' }], + name: 'SafeERC20FailedOperation', + }, + { type: 'error', inputs: [], name: 'UUPSUnauthorizedCallContext' }, + { + type: 'error', + inputs: [{ name: 'slot', internalType: 'bytes32', type: 'bytes32' }], + name: 'UUPSUnsupportedProxiableUUID', + }, +] as const diff --git a/src/contracts/constants.ts b/src/contracts/constants.ts index bbdb1a8994a..c02b2b6659c 100644 --- a/src/contracts/constants.ts +++ b/src/contracts/constants.ts @@ -31,3 +31,6 @@ export const UNISWAP_V2_ROUTER_02_CONTRACT_ADDRESS = // Checksummed addresses - used to check against unchained Txs export const THOR_ROUTER_CONTRACT_ADDRESS_ETHEREUM = '0xd37bbe5744d730a1d98d8dc97c42f0ca46ad7146' + +// RFOX on Arbitrum ERC1967Proxy contract address +export const RFOX_PROXY_CONTRACT_ADDRESS = '0x0c66f315542fdec1d312c415b14eef614b0910ef' diff --git a/src/contracts/contractManager.ts b/src/contracts/contractManager.ts index 64fd9a1ae96..ca34a0c3d26 100644 --- a/src/contracts/contractManager.ts +++ b/src/contracts/contractManager.ts @@ -75,7 +75,7 @@ export const getOrCreateContractByAddress = ( const contract = getContract({ abi: contractAbi, address, - publicClient: viemEthMainnetClient, + client: viemEthMainnetClient, }) as KnownContractByAddress definedContracts.push({ contract, address } as unknown as DefinedContract) return contract @@ -100,8 +100,7 @@ export const getOrCreateContractByType = ({ const contract = getContract({ abi: CONTRACT_TYPE_TO_ABI[type], address: address as Address, - - publicClient, + client: publicClient, }) definedContracts.push({ contract, diff --git a/src/contracts/types.ts b/src/contracts/types.ts index ec7698829b4..648ed98ace3 100644 --- a/src/contracts/types.ts +++ b/src/contracts/types.ts @@ -1,4 +1,4 @@ -import type { GetContractReturnType, PublicClient, WalletClient } from 'viem' +import type { Address, GetContractReturnType, PublicClient } from 'viem' import type { FoxEthStakingContractAddress } from 'state/slices/opportunitiesSlice/constants' import type { @@ -18,13 +18,13 @@ export enum ContractType { export type KnownContractByAddress = GetContractReturnType< (typeof CONTRACT_ADDRESS_TO_ABI)[T], PublicClient, - WalletClient + Address > export type KnownContractByType = GetContractReturnType< (typeof CONTRACT_TYPE_TO_ABI)[T], PublicClient, - WalletClient + Address > export type KnownContractAddress = diff --git a/src/lib/asset-service/service/generatedAssetData.json b/src/lib/asset-service/service/generatedAssetData.json index e80bd709fd8..33b1aaf413b 100644 --- a/src/lib/asset-service/service/generatedAssetData.json +++ b/src/lib/asset-service/service/generatedAssetData.json @@ -158477,5 +158477,17 @@ "explorerAddressLink": "https://basescan.org/address/", "explorerTxLink": "https://basescan.org/tx/", "relatedAssetKey": "eip155:1/slip44:60" + }, + "eip155:42161/erc20:0xf929de51d91c77e42f5090069e0ad7a09e513c73": { + "assetId": "eip155:42161/erc20:0xf929de51d91c77e42f5090069e0ad7a09e513c73", + "chainId": "eip155:42161", + "name": "FOX on Arbitrum One", + "precision": 18, + "color": "#3761F9", + "icon": "https://assets.coincap.io/assets/icons/256/fox.png", + "symbol": "FOX", + "explorer": "https://arbiscan.io", + "explorerAddressLink": "https://arbiscan.io/address/", + "explorerTxLink": "https://arbiscan.io/tx/" } -} \ No newline at end of file +} diff --git a/src/lib/market-service/coingecko/coingecko.ts b/src/lib/market-service/coingecko/coingecko.ts index db8b8ae80ab..f933014aa4a 100644 --- a/src/lib/market-service/coingecko/coingecko.ts +++ b/src/lib/market-service/coingecko/coingecko.ts @@ -1,4 +1,4 @@ -import { adapters } from '@shapeshiftoss/caip' +import { adapters, foxAssetId, foxOnArbitrumOneAssetId } from '@shapeshiftoss/caip' import type { FindAllMarketArgs, HistoryData, @@ -88,7 +88,10 @@ export class CoinGeckoMarketService implements MarketService { } } - async findByAssetId({ assetId }: MarketDataArgs): Promise { + async findByAssetId({ assetId: _assetId }: MarketDataArgs): Promise { + // Monkey patch Arb FOX to mainnet FOX until we have market-data for it, similar to + // what FOXy did in https://github.com/shapeshift/lib/pull/830/files#diff-8d0028d46769c562695ae0eadad8c284637d6a3e45a71a01398c923ae912cf62 + const assetId = _assetId === foxOnArbitrumOneAssetId ? foxAssetId : _assetId if (!adapters.assetIdToCoingecko(assetId)) return null const url = adapters.makeCoingeckoAssetUrl(assetId) diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/getBestAggregator.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/getBestAggregator.ts index a7f37fc951c..43ca4ece54f 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/getBestAggregator.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/getBestAggregator.ts @@ -5,7 +5,7 @@ import { Err, Ok } from '@sniptt/monads' import type { Token } from '@uniswap/sdk-core' import type { FeeAmount } from '@uniswap/v3-sdk' import assert from 'assert' -import type { Address, GetContractReturnType, PublicClient, WalletClient } from 'viem' +import type { Address, GetContractReturnType, PublicClient } from 'viem' import { getContract } from 'viem' import { viemClientByChainId } from 'lib/viem-client' @@ -47,11 +47,11 @@ export const getBestAggregator = async ( buyToken.address, ) - const quoterContract: GetContractReturnType = + const quoterContract: GetContractReturnType = getContract({ abi: QuoterAbi, address: UNI_V3_ETHEREUM_QUOTER_ADDRESS, - publicClient, + client: publicClient, }) const quotedAmountOutByPool = await getQuotedAmountOutByPool( diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/longTailHelpers.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/longTailHelpers.ts index 7baccfcbd9e..4ec7676a7a5 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/longTailHelpers.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/longTailHelpers.ts @@ -2,7 +2,7 @@ import { avalancheChainId, bscChainId, ethChainId, fromAssetId } from '@shapeshi import type { Asset } from '@shapeshiftoss/types' import { Token } from '@uniswap/sdk-core' import { computePoolAddress, FeeAmount } from '@uniswap/v3-sdk' -import type { GetContractReturnType, WalletClient } from 'viem' +import type { GetContractReturnType } from 'viem' import { type Address, getAddress, getContract, type PublicClient } from 'viem' import { IUniswapV3PoolABI } from '../getThorTradeQuote/abis/IUniswapV3PoolAbi' @@ -127,7 +127,7 @@ export const getContractDataByPool = ( const poolContract = getContract({ abi: IUniswapV3PoolABI, address, - publicClient, + client: publicClient, }) const tokenIn = token0Address === tokenAAddress ? token0Address : token1Address const tokenOut = token1Address === tokenBAddress ? token1Address : token0Address @@ -146,7 +146,7 @@ export const getContractDataByPool = ( export const getQuotedAmountOutByPool = async ( poolContracts: Map, sellAmount: bigint, - quoterContract: GetContractReturnType, + quoterContract: GetContractReturnType, ): Promise> => { const results = await Promise.allSettled( Array.from(poolContracts.entries()).map(async ([poolContract, data]) => { diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts new file mode 100644 index 00000000000..e65fcf33808 --- /dev/null +++ b/src/lib/utils/time.ts @@ -0,0 +1,18 @@ +import dayjs from 'dayjs' +import durationPlugin from 'dayjs/plugin/duration' + +export const formatSecondsToDuration = (seconds: number) => { + dayjs.extend(durationPlugin) + const duration = dayjs.duration(seconds, 'seconds') + const hours = duration.asHours() + const days = duration.asDays() + const months = duration.asMonths() + + if (hours < 24) { + return `${Math.floor(hours)} hour${Math.floor(hours) !== 1 ? 's' : ''}` + } else if (days < 31) { + return `${Math.floor(days)} day${Math.floor(days) !== 1 ? 's' : ''}` + } else { + return `${Math.floor(months)} month${Math.floor(months) !== 1 ? 's' : ''}` + } +} diff --git a/src/lib/viem-client.ts b/src/lib/viem-client.ts index ff6a08b38fb..d45a017daf6 100644 --- a/src/lib/viem-client.ts +++ b/src/lib/viem-client.ts @@ -3,7 +3,7 @@ import type { EvmChainId } from '@shapeshiftoss/chain-adapters' import { KnownChainIds } from '@shapeshiftoss/types' import assert from 'assert' import { getConfig } from 'config' -import type { PublicClient } from 'viem' +import type { Chain, PublicClient, Transport } from 'viem' import { createPublicClient, http } from 'viem' import { arbitrum, @@ -62,7 +62,7 @@ export const viemBaseClient = createPublicClient({ transport: http(getConfig().REACT_APP_BASE_NODE_URL), }) -export const viemClientByChainId: Record = { +export const viemClientByChainId: Record> = { [KnownChainIds.EthereumMainnet]: viemEthMainnetClient, [KnownChainIds.BnbSmartChainMainnet]: viemBscClient, [KnownChainIds.AvalancheMainnet]: viemAvalancheClient, @@ -70,13 +70,27 @@ export const viemClientByChainId: Record = { [KnownChainIds.ArbitrumNovaMainnet]: viemArbitrumNovaClient, [KnownChainIds.GnosisMainnet]: viemGnosisClient, [KnownChainIds.PolygonMainnet]: viemPolygonClient, - // cast required due to typescript shenanigans + // cast required for these due to typescript shenanigans // https://github.com/wagmi-dev/viem/issues/1018 - [KnownChainIds.OptimismMainnet]: viemOptimismClient as PublicClient, - [KnownChainIds.BaseMainnet]: viemBaseClient as PublicClient, + [KnownChainIds.OptimismMainnet]: viemOptimismClient as PublicClient, + [KnownChainIds.BaseMainnet]: viemBaseClient as PublicClient, } -export const assertGetViemClient = (chainId: ChainId): PublicClient => { +export const viemClientByNetworkId: Record> = { + [mainnet.id]: viemEthMainnetClient, + [bsc.id]: viemBscClient, + [avalanche.id]: viemAvalancheClient, + [arbitrum.id]: viemArbitrumClient, + [arbitrumNova.id]: viemArbitrumNovaClient, + [gnosis.id]: viemGnosisClient, + [polygon.id]: viemPolygonClient, + // cast required for these due to typescript shenanigans + // https://github.com/wagmi-dev/viem/issues/1018 + [optimism.id]: viemOptimismClient as PublicClient, + [base.id]: viemBaseClient as PublicClient, +} + +export const assertGetViemClient = (chainId: ChainId): PublicClient => { const publicClient = viemClientByChainId[chainId as EvmChainId] assert(publicClient !== undefined, `no public client found for chainId '${chainId}'`) return publicClient diff --git a/src/lib/wagmi-config.ts b/src/lib/wagmi-config.ts new file mode 100644 index 00000000000..c71118abaa5 --- /dev/null +++ b/src/lib/wagmi-config.ts @@ -0,0 +1,28 @@ +import { + arbitrum, + arbitrumNova, + avalanche, + base, + bsc, + gnosis, + mainnet, + optimism, + polygon, +} from 'viem/chains' +import { createConfig } from 'wagmi' + +import { viemClientByNetworkId } from './viem-client' + +declare module 'wagmi' { + interface Register { + config: typeof wagmiConfig + } +} + +export const wagmiConfig = createConfig({ + chains: [arbitrum, arbitrumNova, avalanche, base, bsc, gnosis, mainnet, optimism, polygon], + // @ts-ignore wagmi is drunk https://github.com/wevm/viem/blob/12d9244c6c6f77ecda30f9014b383e5500e7bff9/src/types/chain.ts#L25 + client({ chain }) { + return viemClientByNetworkId[chain.id]! + }, +}) diff --git a/src/pages/RFOX/components/AddressSelection.tsx b/src/pages/RFOX/components/AddressSelection.tsx index e555d04a7b6..a58f35ba5bc 100644 --- a/src/pages/RFOX/components/AddressSelection.tsx +++ b/src/pages/RFOX/components/AddressSelection.tsx @@ -1,39 +1,99 @@ import { + Box, Button, Flex, FormControl, FormHelperText, FormLabel, Input, - Select, Stack, } from '@chakra-ui/react' -import { type FC, useCallback, useMemo, useState } from 'react' +import { fromAccountId, thorchainAssetId, thorchainChainId } from '@shapeshiftoss/caip' +import type { FC } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useForm, useFormContext } from 'react-hook-form' import { useTranslate } from 'react-polyglot' +import { AccountDropdown } from 'components/AccountDropdown/AccountDropdown' +import type { TradeAmountInputFormValues } from 'components/MultiHopTrade/components/TradeAmountInput' +import { validateAddress } from 'lib/address/address' type AddressSelectionProps = { + onRuneAddressChange: (address: string | undefined) => void isNewAddress?: boolean } -export const AddressSelection: FC = ({ isNewAddress }) => { +const boxProps = { + width: 'full', +} + +export type StakeValues = { + manualRuneAddress: string | undefined +} & TradeAmountInputFormValues + +export const AddressSelection: FC = ({ + onRuneAddressChange: handleRuneAddressChange, + isNewAddress, +}) => { const translate = useTranslate() + + // Local controller in case consumers don't have a form context + const _methods = useForm() + const methods = useFormContext() + + const register = methods?.register ?? _methods.register + const formState = methods?.formState ?? _methods.formState + const { errors } = formState + const [isManualAddress, setIsManualAddress] = useState(false) + const handleAccountIdChange = useCallback( + (accountId: string) => { + handleRuneAddressChange(fromAccountId(accountId).account) + }, + [handleRuneAddressChange], + ) + const handleToggleInputMethod = useCallback(() => { + handleRuneAddressChange(undefined) setIsManualAddress(!isManualAddress) - }, [isManualAddress]) + }, [isManualAddress, handleRuneAddressChange]) - const renderSelection = useMemo(() => { + const accountSelection = useMemo(() => { if (isManualAddress) { - return + return ( + { + const isValid = await validateAddress({ + maybeAddress: address ?? '', + chainId: thorchainChainId, + }) + + if (!isValid) { + handleRuneAddressChange(undefined) + return translate('common.invalidAddress') + } + + handleRuneAddressChange(address) + }, + })} + placeholder={translate('common.enterAddress')} + autoFocus + defaultValue='' + /> + ) } + return ( - + ) - }, [isManualAddress]) + }, [handleAccountIdChange, isManualAddress, handleRuneAddressChange, register, translate]) const addressSelectionLabel = useMemo( () => @@ -48,7 +108,7 @@ export const AddressSelection: FC = ({ isNewAddress }) => ) return ( - + @@ -60,7 +120,7 @@ export const AddressSelection: FC = ({ isNewAddress }) => : translate('RFOX.useCustomAddress')} - {renderSelection} + {accountSelection} {addressSelectionDescription} diff --git a/src/pages/RFOX/components/ChangeAddress/ChangeAddressInput.tsx b/src/pages/RFOX/components/ChangeAddress/ChangeAddressInput.tsx index a0374cf2fcb..5fc6cdeb657 100644 --- a/src/pages/RFOX/components/ChangeAddress/ChangeAddressInput.tsx +++ b/src/pages/RFOX/components/ChangeAddress/ChangeAddressInput.tsx @@ -30,6 +30,10 @@ export const ChangeAddressInput: FC = ({ headerComponen setNewAddress('1234') }, [history]) + const handleRuneAddressChange = useCallback((_address: string | undefined) => { + console.info('TODO: implement me') + }, []) + if (!asset) return null return ( @@ -50,7 +54,7 @@ export const ChangeAddressInput: FC = ({ headerComponen 1234 - + Loading... @@ -45,17 +46,47 @@ export const Stake: React.FC = ({ headerComponent }) => { export const StakeRoutes: React.FC = ({ headerComponent }) => { const location = useLocation() + const [runeAddress, setRuneAddress] = useState() + const [confirmedQuote, setConfirmedQuote] = useState() + const [stakeTxid, setStakeTxid] = useState() + const renderStakeInput = useCallback(() => { - return - }, [headerComponent]) + return ( + + ) + }, [headerComponent, runeAddress]) const renderStakeConfirm = useCallback(() => { - return - }, [headerComponent]) + if (!confirmedQuote) return null + + return ( + + ) + }, [confirmedQuote, headerComponent, stakeTxid]) const renderStakeStatus = useCallback(() => { - return - }, [headerComponent]) + if (!confirmedQuote) return null + if (!stakeTxid) return null + + return ( + + ) + }, [confirmedQuote, headerComponent, stakeTxid]) return ( diff --git a/src/pages/RFOX/components/Stake/StakeConfirm.tsx b/src/pages/RFOX/components/Stake/StakeConfirm.tsx index 8809b6fc1ed..2e7c6fee17e 100644 --- a/src/pages/RFOX/components/Stake/StakeConfirm.tsx +++ b/src/pages/RFOX/components/Stake/StakeConfirm.tsx @@ -1,4 +1,4 @@ -import { ArrowBackIcon } from '@chakra-ui/icons' +import { ArrowBackIcon, ExternalLinkIcon } from '@chakra-ui/icons' import { Button, Card, @@ -7,41 +7,443 @@ import { CardHeader, Flex, IconButton, + Link, + Skeleton, Stack, + Text, + useToast, } from '@chakra-ui/react' -import { foxAssetId } from '@shapeshiftoss/caip' -import { useCallback, useMemo } from 'react' +import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip' +import { CONTRACT_INTERACTION } from '@shapeshiftoss/chain-adapters' +import { TxStatus } from '@shapeshiftoss/unchained-client' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { erc20ABI } from 'contracts/abis/ERC20ABI' +import { foxStakingV1Abi } from 'contracts/abis/FoxStakingV1' +import { RFOX_PROXY_CONTRACT_ADDRESS } from 'contracts/constants' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' +import { reactQueries } from 'react-queries' +import { useAllowance } from 'react-queries/hooks/useAllowance' import { useHistory } from 'react-router' +import { arbitrum } from 'viem/chains' +import { encodeFunctionData, getAddress } from 'viem/utils' +import { useReadContract } from 'wagmi' import { Amount } from 'components/Amount/Amount' import { AssetIcon } from 'components/AssetIcon' import type { RowProps } from 'components/Row/Row' import { Row } from 'components/Row/Row' import { SlideTransition } from 'components/SlideTransition' import { Timeline, TimelineItem } from 'components/Timeline/Timeline' +import { useWallet } from 'hooks/useWallet/useWallet' +import { bnOrZero } from 'lib/bignumber/bignumber' +import { fromBaseUnit } from 'lib/math' import { middleEllipsis } from 'lib/utils' -import { selectAssetById } from 'state/slices/selectors' +import { + assertGetEvmChainAdapter, + buildAndBroadcast, + createBuildCustomTxInput, +} from 'lib/utils/evm' +import { + selectAccountNumberByAccountId, + selectAssetById, + selectFeeAssetByChainId, + selectMarketDataByAssetIdUserCurrency, + selectTxById, +} from 'state/slices/selectors' +import { serializeTxIndex } from 'state/slices/txHistorySlice/utils' import { useAppSelector } from 'state/store' +import type { RfoxStakingQuote } from './types' import { StakeRoutePaths, type StakeRouteProps } from './types' -const CustomRow: React.FC = props => const backIcon = -export const StakeConfirm: React.FC = () => { + +const CustomRow: React.FC = props => + +type StakeConfirmProps = { + confirmedQuote: RfoxStakingQuote + stakeTxid: string | undefined + setStakeTxid: (txId: string) => void +} +export const StakeConfirm: React.FC = ({ + stakeTxid, + setStakeTxid, + confirmedQuote, +}) => { + const toast = useToast() + const queryClient = useQueryClient() + const wallet = useWallet().state.wallet const history = useHistory() const translate = useTranslate() - const asset = useAppSelector(state => selectAssetById(state, foxAssetId)) + + const [approvalTxId, setApprovalTxId] = useState() + + const stakingAsset = useAppSelector(state => + selectAssetById(state, confirmedQuote.stakingAssetId), + ) + const feeAsset = useAppSelector(state => + selectFeeAssetByChainId(state, fromAssetId(confirmedQuote.stakingAssetId).chainId), + ) + + const feeAssetMarketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, feeAsset?.assetId ?? ''), + ) + const stakingAssetMarketDataUserCurrency = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, confirmedQuote.stakingAssetId), + ) + + const stakingAssetAccountNumberFilter = useMemo(() => { + return { + assetId: confirmedQuote.stakingAssetId, + accountId: confirmedQuote.stakingAssetAccountId, + } + }, [confirmedQuote.stakingAssetAccountId, confirmedQuote.stakingAssetId]) + const stakingAssetAccountNumber = useAppSelector(state => + selectAccountNumberByAccountId(state, stakingAssetAccountNumberFilter), + ) + const stakingAssetAccountAddress = useMemo( + () => fromAccountId(confirmedQuote.stakingAssetAccountId).account, + [confirmedQuote.stakingAssetAccountId], + ) + + const stakingAmountCryptoPrecision = useMemo( + () => fromBaseUnit(confirmedQuote.stakingAmountCryptoBaseUnit, stakingAsset?.precision ?? 0), + [confirmedQuote.stakingAmountCryptoBaseUnit, stakingAsset?.precision], + ) + + const stakeAmountUserCurrency = useMemo( + () => + bnOrZero(stakingAmountCryptoPrecision) + .times(stakingAssetMarketDataUserCurrency.price) + .toFixed(), + [stakingAmountCryptoPrecision, stakingAssetMarketDataUserCurrency.price], + ) + + const { + data: userStakingBalanceOfCryptoBaseUnit, + isSuccess: isUserStakingBalanceOfCryptoBaseUnitSuccess, + queryKey: userStakingBalanceOfCryptoBaseUnitQueryKey, + } = useReadContract({ + abi: foxStakingV1Abi, + address: RFOX_PROXY_CONTRACT_ADDRESS, + functionName: 'stakingInfo', + args: [getAddress(stakingAssetAccountAddress)], // actually defined, see enabled below + chainId: arbitrum.id, + query: { + enabled: Boolean(stakingAssetAccountAddress), + select: ([stakingBalance]) => stakingBalance.toString(), + }, + }) + + const { + data: newContractBalanceOfCryptoBaseUnit, + isSuccess: isNewContractBalanceOfCryptoBaseUnitSuccess, + queryKey: newContractBalanceOfCryptoBaseUnitQueryKey, + } = useReadContract({ + abi: erc20ABI, + address: getAddress(fromAssetId(confirmedQuote.stakingAssetId).assetReference), + functionName: 'balanceOf', + args: [getAddress(RFOX_PROXY_CONTRACT_ADDRESS)], + chainId: arbitrum.id, + query: { + select: data => + bnOrZero(data.toString()).plus(confirmedQuote.stakingAmountCryptoBaseUnit).toFixed(), + }, + }) + + const newShareOfPoolPercentage = useMemo( + () => + bnOrZero(confirmedQuote.stakingAmountCryptoBaseUnit) + .plus(userStakingBalanceOfCryptoBaseUnit ?? 0) + .div(newContractBalanceOfCryptoBaseUnit ?? 0) + .toFixed(4), + [ + confirmedQuote.stakingAmountCryptoBaseUnit, + newContractBalanceOfCryptoBaseUnit, + userStakingBalanceOfCryptoBaseUnit, + ], + ) + + // Approval/Allowance bits + + const { data: allowanceDataCryptoBaseUnit, isLoading: isAllowanceDataLoading } = useAllowance({ + assetId: stakingAsset?.assetId, + spender: RFOX_PROXY_CONTRACT_ADDRESS, + from: fromAccountId(confirmedQuote.stakingAssetAccountId).account, + }) + + const isApprovalRequired = useMemo( + () => bnOrZero(allowanceDataCryptoBaseUnit).lt(confirmedQuote.stakingAmountCryptoBaseUnit), + [allowanceDataCryptoBaseUnit, confirmedQuote.stakingAmountCryptoBaseUnit], + ) + + const approvalCallData = useMemo(() => { + return encodeFunctionData({ + abi: erc20ABI, + functionName: 'approve', + args: [RFOX_PROXY_CONTRACT_ADDRESS, BigInt(confirmedQuote.stakingAmountCryptoBaseUnit)], + }) + }, [confirmedQuote.stakingAmountCryptoBaseUnit]) + + const isGetApprovalFeesEnabled = useMemo( + () => + Boolean( + isApprovalRequired && + stakingAssetAccountNumber !== undefined && + feeAsset && + feeAssetMarketData && + wallet, + ), + [feeAsset, feeAssetMarketData, isApprovalRequired, stakingAssetAccountNumber, wallet], + ) + + const { + data: approvalFees, + isLoading: isGetApprovalFeesLoading, + isSuccess: isGetApprovalFeesSuccess, + } = useQuery({ + ...reactQueries.common.evmFees({ + value: '0', + accountNumber: stakingAssetAccountNumber!, // see isGetApprovalFeesEnabled + feeAsset: feeAsset!, // see isGetApprovalFeesEnabled + feeAssetMarketData: feeAssetMarketData!, // see isGetApprovalFeesEnabled + to: fromAssetId(confirmedQuote.stakingAssetId).assetReference, + from: stakingAssetAccountAddress, + data: approvalCallData, + wallet: wallet!, // see isGetApprovalFeesEnabled + }), + staleTime: 30_000, + enabled: isGetApprovalFeesEnabled, + // Ensures fees are refetched at an interval, including when the app is in the background + refetchIntervalInBackground: true, + // Yeah this is arbitrary but come on, Arb is cheap + refetchInterval: 15_000, + }) + + const serializedApprovalTxIndex = useMemo(() => { + if (!(approvalTxId && stakingAssetAccountAddress && confirmedQuote.stakingAssetAccountId)) + return '' + return serializeTxIndex( + confirmedQuote.stakingAssetAccountId, + approvalTxId, + stakingAssetAccountAddress, + ) + }, [approvalTxId, confirmedQuote.stakingAssetAccountId, stakingAssetAccountAddress]) + + const approvalTx = useAppSelector(gs => selectTxById(gs, serializedApprovalTxIndex)) + + const { + mutate: sendApprovalTx, + isPending: isApprovalMutationPending, + isSuccess: isApprovalMutationSuccess, + } = useMutation({ + ...reactQueries.mutations.approve({ + assetId: confirmedQuote.stakingAssetId, + spender: RFOX_PROXY_CONTRACT_ADDRESS, + from: stakingAssetAccountAddress, + amount: confirmedQuote.stakingAmountCryptoBaseUnit, + wallet, + accountNumber: stakingAssetAccountNumber, + }), + onSuccess: (txId: string) => { + setApprovalTxId(txId) + toast({ + title: translate('modals.send.transactionSent'), + description: ( + + {feeAsset?.explorerTxLink && ( + + {translate('modals.status.viewExplorer')} + + )} + + ), + status: 'success', + duration: 9000, + isClosable: true, + position: 'top-right', + }) + }, + }) + + const handleApprove = useCallback(() => sendApprovalTx(undefined), [sendApprovalTx]) + + const isApprovalTxPending = useMemo( + () => + isApprovalMutationPending || + (isApprovalMutationSuccess && approvalTx?.status !== TxStatus.Confirmed), + [approvalTx?.status, isApprovalMutationPending, isApprovalMutationSuccess], + ) + + const isApprovalTxSuccess = useMemo( + () => approvalTx?.status === TxStatus.Confirmed, + [approvalTx?.status], + ) + + // The approval Tx may be confirmed, but that's not enough to know we're ready to stake + // Allowance then needs to be succesfully refetched - failure to wait for it will result in jumpy states between + // the time the Tx is confirmed, and the time the allowance is succesfully refetched + // This allows us to detect such transition state + const isTransitioning = useMemo(() => { + // If we don't have a success Tx, we know we're not transitioning + if (!isApprovalTxSuccess) return false + // We have a success approval Tx, but approval is still required, meaning we haven't re-rendered with the updated allowance just yet + if (isApprovalRequired) return true + + // Allowance has been updated, we've finished transitioning + return false + }, [isApprovalRequired, isApprovalTxSuccess]) + + console.log({ isApprovalRequired, isTransitioning }) + + useEffect(() => { + if (!approvalTx) return + if (isApprovalTxPending) return + ;(async () => { + await queryClient.invalidateQueries( + reactQueries.common.allowanceCryptoBaseUnit( + stakingAsset?.assetId, + RFOX_PROXY_CONTRACT_ADDRESS, + stakingAssetAccountAddress, + ), + ) + })() + }, [ + approvalTx, + stakingAsset?.assetId, + isApprovalTxPending, + stakingAssetAccountAddress, + queryClient, + ]) + + // Stake bits + + const stakeCallData = useMemo(() => { + return encodeFunctionData({ + abi: foxStakingV1Abi, + functionName: 'stake', + args: [BigInt(confirmedQuote.stakingAmountCryptoBaseUnit), confirmedQuote.runeAddress], + }) + }, [confirmedQuote.runeAddress, confirmedQuote.stakingAmountCryptoBaseUnit]) + + const isGetStakeFeesEnabled = useMemo( + () => + Boolean( + stakingAssetAccountNumber !== undefined && + wallet && + stakingAsset && + !isApprovalRequired && + feeAsset && + feeAssetMarketData, + ), + [ + stakingAssetAccountNumber, + wallet, + stakingAsset, + isApprovalRequired, + feeAsset, + feeAssetMarketData, + ], + ) + + const { + data: stakeFees, + isLoading: isStakeFeesLoading, + isSuccess: isStakeFeesSuccess, + } = useQuery({ + ...reactQueries.common.evmFees({ + to: RFOX_PROXY_CONTRACT_ADDRESS, + from: stakingAssetAccountAddress, + accountNumber: stakingAssetAccountNumber!, // see isGetStakeFeesEnabled + data: stakeCallData!, // see isGetStakeFeesEnabled + value: '0', // contract call + wallet: wallet!, // see isGetStakeFeesEnabled + feeAsset: feeAsset!, // see isGetStakeFeesEnabled + feeAssetMarketData: feeAssetMarketData!, // see isGetStakeFeesEnabled + }), + staleTime: 30_000, + enabled: isGetStakeFeesEnabled, + // Ensures fees are refetched at an interval, including when the app is in the background + refetchIntervalInBackground: true, + // Yeah this is arbitrary but come on, Arb is cheap + refetchInterval: 15_000, + }) + + const serializedStakeTxIndex = useMemo(() => { + if (!(stakeTxid && stakingAssetAccountAddress && confirmedQuote.stakingAssetAccountId)) + return '' + return serializeTxIndex( + confirmedQuote.stakingAssetAccountId, + stakeTxid, + stakingAssetAccountAddress, + ) + }, [confirmedQuote.stakingAssetAccountId, stakeTxid, stakingAssetAccountAddress]) + + const { + mutateAsync: handleStake, + isPending: isStakeMutationPending, + isSuccess: isStakeMutationSuccess, + } = useMutation({ + mutationFn: async () => { + if (!wallet || stakingAssetAccountNumber === undefined || !stakingAsset) return + + const adapter = assertGetEvmChainAdapter(stakingAsset.chainId) + + const buildCustomTxInput = await createBuildCustomTxInput({ + accountNumber: stakingAssetAccountNumber, + adapter, + data: stakeCallData, + value: '0', + to: RFOX_PROXY_CONTRACT_ADDRESS, + wallet, + }) + + const txId = await buildAndBroadcast({ + adapter, + buildCustomTxInput, + receiverAddress: CONTRACT_INTERACTION, // no receiver for this contract call + }) + + return txId + }, + onSuccess: (txId: string | undefined) => { + if (!txId) return + + setStakeTxid(txId) + }, + }) + + const stakeTx = useAppSelector(gs => selectTxById(gs, serializedStakeTxIndex)) + const isStakeTxPending = useMemo( + () => isStakeMutationPending || (isStakeMutationSuccess && !stakeTx), + [isStakeMutationPending, isStakeMutationSuccess, stakeTx], + ) const handleGoBack = useCallback(() => { history.push(StakeRoutePaths.Input) }, [history]) - const handleSubmit = useCallback(() => { + const handleSubmit = useCallback(async () => { + if (isApprovalRequired) return handleApprove() + + await handleStake() + + await queryClient.invalidateQueries({ queryKey: userStakingBalanceOfCryptoBaseUnitQueryKey }) + await queryClient.invalidateQueries({ queryKey: newContractBalanceOfCryptoBaseUnitQueryKey }) + history.push(StakeRoutePaths.Status) - }, [history]) + }, [ + handleApprove, + handleStake, + history, + isApprovalRequired, + newContractBalanceOfCryptoBaseUnitQueryKey, + queryClient, + userStakingBalanceOfCryptoBaseUnitQueryKey, + ]) const stakeCards = useMemo(() => { - if (!asset) return null + if (!stakingAsset) return null return ( = () => { flex={1} mx={-2} > - + - - + + ) - }, [asset]) + }, [stakeAmountUserCurrency, stakingAmountCryptoPrecision, stakingAsset]) return ( @@ -76,34 +478,42 @@ export const StakeConfirm: React.FC = () => { {stakeCards} - - - {translate('RFOX.shapeShiftFee')} - Free - - - - - {translate('RFOX.approvalFee')} - - - - - - - - {translate('RFOX.networkFee')} - - - - - + {isApprovalRequired ? ( + + + {translate('common.approvalFee')} + + + + + + + + ) : ( + + + {translate('RFOX.networkFee')} + + + + + + + + )} {translate('RFOX.shareOfPool')} - - - + + + + + @@ -120,7 +530,7 @@ export const StakeConfirm: React.FC = () => { > {translate('RFOX.thorchainRewardAddress')} - {middleEllipsis('123455667765')} + {middleEllipsis(confirmedQuote.runeAddress)} = () => { bg='background.surface.raised.accent' borderBottomRadius='xl' > - diff --git a/src/pages/RFOX/components/Stake/StakeInput.tsx b/src/pages/RFOX/components/Stake/StakeInput.tsx index c60e800b294..ee02f183d28 100644 --- a/src/pages/RFOX/components/Stake/StakeInput.tsx +++ b/src/pages/RFOX/components/Stake/StakeInput.tsx @@ -1,8 +1,19 @@ -import { Button, CardFooter, Collapse, Stack } from '@chakra-ui/react' -import { foxAssetId } from '@shapeshiftoss/caip' -import { useCallback, useMemo, useState } from 'react' +import { Button, CardFooter, Collapse, Skeleton, Stack } from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { foxOnArbitrumOneAssetId, fromAccountId, fromAssetId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' +import { erc20ABI } from 'contracts/abis/ERC20ABI' +import { foxStakingV1Abi } from 'contracts/abis/FoxStakingV1' +import { RFOX_PROXY_CONTRACT_ADDRESS } from 'contracts/constants' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { FormProvider, useForm, useWatch } from 'react-hook-form' import { useTranslate } from 'react-polyglot' +import { reactQueries } from 'react-queries' +import { useAllowance } from 'react-queries/hooks/useAllowance' import { useHistory } from 'react-router' +import { encodeFunctionData } from 'viem' +import { arbitrum } from 'viem/chains' +import { useReadContract } from 'wagmi' import { Amount } from 'components/Amount/Amount' import { TradeAssetSelect } from 'components/AssetSelection/AssetSelection' import { FormDivider } from 'components/FormDivider' @@ -10,11 +21,26 @@ import { TradeAssetInput } from 'components/MultiHopTrade/components/TradeAssetI import { Row } from 'components/Row/Row' import { SlideTransition } from 'components/SlideTransition' import { WarningAcknowledgement } from 'components/WarningAcknowledgement/WarningAcknowledgement' -import { selectAssetById } from 'state/slices/selectors' -import { useAppSelector } from 'state/store' +import { useToggle } from 'hooks/useToggle/useToggle' +import { useWallet } from 'hooks/useWallet/useWallet' +import { bnOrZero } from 'lib/bignumber/bignumber' +import { fromBaseUnit, toBaseUnit } from 'lib/math' +import { formatSecondsToDuration } from 'lib/utils/time' +import { marketApi } from 'state/slices/marketDataSlice/marketDataSlice' +import { + selectAccountNumberByAccountId, + selectAssetById, + selectFeeAssetByChainId, + selectFirstAccountIdByChainId, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from 'state/slices/selectors' +import { useAppDispatch, useAppSelector } from 'state/store' +import type { StakeValues } from '../AddressSelection' import { AddressSelection } from '../AddressSelection' import { StakeSummary } from './components/StakeSummary' +import type { RfoxStakingQuote } from './types' import { StakeRoutePaths, type StakeRouteProps } from './types' const formControlProps = { @@ -25,116 +51,493 @@ const formControlProps = { paddingTop: 0, } -export const StakeInput: React.FC = ({ headerComponent }) => { +type StakeInputProps = { + stakingAssetId?: AssetId + onRuneAddressChange: (address: string | undefined) => void + runeAddress: string | undefined + setConfirmedQuote: (quote: RfoxStakingQuote | undefined) => void +} + +const defaultFormValues = { + amountFieldInput: '', + amountCryptoPrecision: '', + amountUserCurrency: '', + manualRuneAddress: '', +} + +export const StakeInput: React.FC = ({ + stakingAssetId = foxOnArbitrumOneAssetId, + headerComponent, + onRuneAddressChange, + runeAddress, + setConfirmedQuote, +}) => { + const wallet = useWallet().state.wallet + const dispatch = useAppDispatch() const translate = useTranslate() const history = useHistory() - const asset = useAppSelector(state => selectAssetById(state, foxAssetId)) + + const methods = useForm({ + defaultValues: defaultFormValues, + mode: 'onChange', + shouldUnregister: true, + }) + + const { + formState: { errors }, + control, + trigger, + } = methods + + const stakingAsset = useAppSelector(state => selectAssetById(state, stakingAssetId)) + const feeAsset = useAppSelector(state => + selectFeeAssetByChainId(state, fromAssetId(stakingAssetId).chainId), + ) + + // TODO(gomes): make this programmatic when we implement multi-account + const stakingAssetAccountId = useAppSelector(state => + selectFirstAccountIdByChainId(state, stakingAsset?.chainId ?? ''), + ) + const stakingAssetAccountNumberFilter = useMemo(() => { + return { + assetId: stakingAssetId, + accountId: stakingAssetAccountId, + } + }, [stakingAssetAccountId, stakingAssetId]) + const stakingAssetAccountNumber = useAppSelector(state => + selectAccountNumberByAccountId(state, stakingAssetAccountNumberFilter), + ) + + const feeAssetMarketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, feeAsset?.assetId ?? ''), + ) + const stakingAssetMarketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, stakingAsset?.assetId ?? ''), + ) const [showWarning, setShowWarning] = useState(false) + const [collapseIn, setCollapseIn] = useState(false) const percentOptions = useMemo(() => [1], []) - const [cryptoAmount, setCryptoAmount] = useState('') - const [fiatAmount, setFiatAmount] = useState('') - const handleAccountIdChange = useCallback(() => {}, []) + const amountCryptoPrecision = useWatch({ + control, + name: 'amountCryptoPrecision', + }) + const amountUserCurrency = useWatch({ + control, + name: 'amountUserCurrency', + }) - // @TODO: Need to add a fiat toggle for the input field - const hasEnteredValue = useMemo(() => !!fiatAmount || !!cryptoAmount, [cryptoAmount, fiatAmount]) + const [isFiat, handleToggleIsFiat] = useToggle(false) - const handleChange = useCallback((value: string, isFiat?: boolean) => { - if (isFiat) { - setFiatAmount(value) - } else { - setCryptoAmount(value) - } - }, []) + const isValidStakingAmount = useMemo( + () => bnOrZero(amountUserCurrency).plus(amountCryptoPrecision).gt(0), + [amountCryptoPrecision, amountUserCurrency], + ) + + useEffect(() => { + // hydrate FOX market data in case the user doesn't hold it + dispatch(marketApi.endpoints.findByAssetIds.initiate([stakingAssetId])) + }, [dispatch, stakingAssetId]) + useEffect(() => { + // Only set this once, never collapse out + if (collapseIn) return + if (isValidStakingAmount) setCollapseIn(true) + }, [collapseIn, isValidStakingAmount]) + + const stakingAssetBalanceFilter = useMemo( + () => ({ + accountId: stakingAssetAccountId ?? '', + assetId: stakingAssetId, + }), + [stakingAssetAccountId, stakingAssetId], + ) + const stakingAssetBalanceCryptoPrecision = useAppSelector(state => + selectPortfolioCryptoPrecisionBalanceByFilter(state, stakingAssetBalanceFilter), + ) + + const stakingAssetFiatBalance = bnOrZero(stakingAssetBalanceCryptoPrecision) + .times(stakingAssetMarketData.price) + .toString() + + const validateHasEnoughBalance = useCallback( + (input: string) => { + if (bnOrZero(input).lte(0)) return true + + const hasEnoughBalance = bnOrZero(input).lte( + bnOrZero(isFiat ? stakingAssetFiatBalance : stakingAssetBalanceCryptoPrecision), + ) + + return hasEnoughBalance + }, + [isFiat, stakingAssetBalanceCryptoPrecision, stakingAssetFiatBalance], + ) + + const hasEnoughBalance = useMemo( + () => validateHasEnoughBalance(isFiat ? amountUserCurrency : amountCryptoPrecision), + [amountCryptoPrecision, amountUserCurrency, isFiat, validateHasEnoughBalance], + ) + + const { data: cooldownPeriod } = useReadContract({ + abi: foxStakingV1Abi, + address: RFOX_PROXY_CONTRACT_ADDRESS, + functionName: 'cooldownPeriod', + chainId: arbitrum.id, + query: { + staleTime: Infinity, + select: data => formatSecondsToDuration(Number(data)), + }, + }) + + const callData = useMemo(() => { + if (!(isValidStakingAmount && runeAddress)) return + + return encodeFunctionData({ + abi: foxStakingV1Abi, + functionName: 'stake', + args: [BigInt(toBaseUnit(amountCryptoPrecision, stakingAsset?.precision ?? 0)), runeAddress], + }) + }, [amountCryptoPrecision, isValidStakingAmount, runeAddress, stakingAsset?.precision]) + + const { data: allowanceDataCryptoBaseUnit, isSuccess: isAllowanceDataSuccess } = useAllowance({ + assetId: stakingAsset?.assetId, + spender: RFOX_PROXY_CONTRACT_ADDRESS, + from: stakingAssetAccountId ? fromAccountId(stakingAssetAccountId).account : undefined, + }) + + const allowanceCryptoPrecision = useMemo(() => { + if (!allowanceDataCryptoBaseUnit) return + if (!stakingAsset) return + + return fromBaseUnit(allowanceDataCryptoBaseUnit, stakingAsset?.precision) + }, [allowanceDataCryptoBaseUnit, stakingAsset]) + + const isApprovalRequired = useMemo( + () => isAllowanceDataSuccess && bnOrZero(allowanceCryptoPrecision).lt(amountCryptoPrecision), + [allowanceCryptoPrecision, amountCryptoPrecision, isAllowanceDataSuccess], + ) + + const isGetStakeFeesEnabled = useMemo( + () => + Boolean( + hasEnoughBalance && + stakingAssetAccountId && + stakingAssetAccountNumber !== undefined && + isValidStakingAmount && + wallet && + stakingAsset && + runeAddress && + callData && + isAllowanceDataSuccess && + !isApprovalRequired && + feeAsset && + feeAssetMarketData && + !Boolean(errors.amountFieldInput), + ), + [ + hasEnoughBalance, + stakingAssetAccountId, + stakingAssetAccountNumber, + isValidStakingAmount, + wallet, + stakingAsset, + runeAddress, + callData, + isAllowanceDataSuccess, + isApprovalRequired, + feeAsset, + feeAssetMarketData, + errors.amountFieldInput, + ], + ) + + const { + data: stakeFees, + isLoading: isStakeFeesLoading, + isSuccess: isStakeFeesSuccess, + } = useQuery({ + ...reactQueries.common.evmFees({ + to: RFOX_PROXY_CONTRACT_ADDRESS, + from: stakingAssetAccountId ? fromAccountId(stakingAssetAccountId).account : '', // see isGetStakeFeesEnabled + accountNumber: stakingAssetAccountNumber!, // see isGetStakeFeesEnabled + data: callData!, // see isGetStakeFeesEnabled + value: '0', // contract call + wallet: wallet!, // see isGetStakeFeesEnabled + feeAsset: feeAsset!, // see isGetStakeFeesEnabled + feeAssetMarketData: feeAssetMarketData!, // see isGetStakeFeesEnabled + }), + staleTime: 30_000, + enabled: isGetStakeFeesEnabled, + // Ensures fees are refetched at an interval, including when the app is in the background + refetchIntervalInBackground: true, + // Yeah this is arbitrary but come on, Arb is cheap + refetchInterval: 15_000, + }) + + const approvalCallData = useMemo(() => { + return encodeFunctionData({ + abi: erc20ABI, + functionName: 'approve', + args: [ + RFOX_PROXY_CONTRACT_ADDRESS, + BigInt(toBaseUnit(amountCryptoPrecision, stakingAsset?.precision ?? 0)), + ], + }) + }, [amountCryptoPrecision, stakingAsset?.precision]) + + const isGetApprovalFeesEnabled = useMemo( + () => + Boolean( + hasEnoughBalance && + stakingAssetAccountId && + isApprovalRequired && + stakingAssetAccountId && + wallet && + feeAsset && + feeAssetMarketData && + !Boolean(errors.amountFieldInput), + ), + [ + errors.amountFieldInput, + feeAsset, + feeAssetMarketData, + hasEnoughBalance, + isApprovalRequired, + stakingAssetAccountId, + wallet, + ], + ) + + const { + data: approvalFees, + isLoading: isGetApprovalFeesLoading, + isSuccess: isGetApprovalFeesSuccess, + } = useQuery({ + ...reactQueries.common.evmFees({ + value: '0', + accountNumber: stakingAssetAccountNumber!, // see isGetApprovalFeesEnabled + feeAsset: feeAsset!, // see isGetApprovalFeesEnabled + feeAssetMarketData: feeAssetMarketData!, // see isGetApprovalFeesEnabled + to: fromAssetId(foxOnArbitrumOneAssetId).assetReference, + from: stakingAssetAccountId ? fromAccountId(stakingAssetAccountId).account : '', // see isGetApprovalFeesEnabled + data: approvalCallData, + wallet: wallet!, // see isGetApprovalFeesEnabled + }), + staleTime: 30_000, + enabled: isGetApprovalFeesEnabled, + // Ensures fees are refetched at an interval, including when the app is in the background + refetchIntervalInBackground: true, + // Yeah this is arbitrary but come on, Arb is cheap + refetchInterval: 15_000, + }) + + // TODO(gomes): implement me when we have multi-account here + const handleAccountIdChange = useCallback(() => {}, []) + + const handleRuneAddressChange = useCallback( + (address: string | undefined) => { + onRuneAddressChange(address) + }, + [onRuneAddressChange], + ) const handleWarning = useCallback(() => { setShowWarning(true) }, []) const handleSubmit = useCallback(() => { + if (!(stakingAssetAccountId && runeAddress && isValidStakingAmount)) return + + setConfirmedQuote({ + stakingAssetAccountId, + stakingAssetId, + stakingAmountCryptoBaseUnit: toBaseUnit(amountCryptoPrecision, stakingAsset?.precision ?? 0), + + runeAddress, + }) history.push(StakeRoutePaths.Confirm) - }, [history]) + }, [ + stakingAsset?.precision, + amountCryptoPrecision, + history, + isValidStakingAmount, + runeAddress, + setConfirmedQuote, + stakingAssetAccountId, + stakingAssetId, + ]) const assetSelectComponent = useMemo(() => { - return - }, [asset?.assetId]) + return ( + + ) + }, [stakingAsset?.assetId]) + + const feeAssetBalanceFilter = useMemo( + () => ({ + accountId: stakingAssetAccountId ?? '', + assetId: feeAsset?.assetId, + }), + [feeAsset?.assetId, stakingAssetAccountId], + ) + const feeAssetBalanceCryptoPrecision = useAppSelector(state => + selectPortfolioCryptoPrecisionBalanceByFilter(state, feeAssetBalanceFilter), + ) + + const validateHasEnoughFeeBalance = useCallback( + (input: string) => { + if (bnOrZero(input).isZero()) return true + if (bnOrZero(feeAssetBalanceCryptoPrecision).isZero()) return false + + const fees = approvalFees || stakeFees - if (!asset) return null + const hasEnoughFeeBalance = bnOrZero(fees?.networkFeeCryptoBaseUnit).lte( + toBaseUnit(feeAssetBalanceCryptoPrecision, feeAsset?.precision ?? 0), + ) + + if (!hasEnoughFeeBalance) return false + + return true + }, + [approvalFees, feeAsset?.precision, feeAssetBalanceCryptoPrecision, stakeFees], + ) + // Trigger re-validation since react-hook-form validation methods are fired onChange and not in a component-reactive manner + useEffect(() => { + trigger('amountFieldInput') + }, [ + approvalFees, + feeAsset?.precision, + feeAsset?.symbol, + feeAssetBalanceCryptoPrecision, + amountCryptoPrecision, + amountUserCurrency, + stakeFees, + trigger, + ]) + + const amountFieldInputRules = useMemo(() => { + return { + defaultValue: '', + validate: { + hasEnoughBalance: (input: string) => + validateHasEnoughBalance(input) || translate('common.insufficientFunds'), + hasEnoughFeeBalance: (input: string) => + validateHasEnoughFeeBalance(input) || + translate('modals.send.errors.notEnoughNativeToken', { asset: feeAsset?.symbol }), + }, + } + }, [feeAsset?.symbol, translate, validateHasEnoughBalance, validateHasEnoughFeeBalance]) + + if (!stakingAsset) return null return ( - - {headerComponent} - - - - - - - - {translate('common.gasFee')} - - - - - - {translate('common.fees')} - - - - - - - - - - + + + ) diff --git a/src/pages/RFOX/components/Stake/StakeStatus.tsx b/src/pages/RFOX/components/Stake/StakeStatus.tsx index 54a9c9f8100..49845ea9f84 100644 --- a/src/pages/RFOX/components/Stake/StakeStatus.tsx +++ b/src/pages/RFOX/components/Stake/StakeStatus.tsx @@ -1,8 +1,9 @@ import { CheckCircleIcon, WarningIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Center, Heading, Stack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Center, Heading, Link, Stack } from '@chakra-ui/react' +import { fromAccountId } from '@shapeshiftoss/caip' import { TxStatus } from '@shapeshiftoss/unchained-client' import { AnimatePresence } from 'framer-motion' -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useHistory } from 'react-router' import { CircularProgress } from 'components/CircularProgress/CircularProgress' @@ -10,7 +11,13 @@ import { SlideTransition } from 'components/SlideTransition' import { SlideTransitionY } from 'components/SlideTransitionY' import { Text } from 'components/Text' import type { TextPropTypes } from 'components/Text/Text' +import { getTxLink } from 'lib/getTxLink' +import { fromBaseUnit } from 'lib/math' +import { selectAssetById, selectTxById } from 'state/slices/selectors' +import { serializeTxIndex } from 'state/slices/txHistorySlice/utils' +import { useAppSelector } from 'state/store' +import type { RfoxStakingQuote } from './types' import { StakeRoutePaths, type StakeRouteProps } from './types' type BodyContent = { @@ -20,8 +27,14 @@ type BodyContent = { element: JSX.Element } -export const StakeStatus: React.FC = () => { - const [status, setStatus] = useState(TxStatus.Pending) +type StakeStatusProps = { + confirmedQuote: RfoxStakingQuote + txId: string +} +export const StakeStatus: React.FC = ({ + confirmedQuote, + txId, +}) => { const history = useHistory() const translate = useTranslate() @@ -29,44 +42,72 @@ export const StakeStatus: React.FC = () => { history.push(StakeRoutePaths.Input) }, [history]) - const handleFakeStatus = useCallback(() => { - setStatus(TxStatus.Confirmed) - }, []) + const stakingAssetAccountAddress = useMemo( + () => fromAccountId(confirmedQuote.stakingAssetAccountId).account, + [confirmedQuote.stakingAssetAccountId], + ) + const stakingAsset = useAppSelector(state => + selectAssetById(state, confirmedQuote.stakingAssetId), + ) + const stakingAmountCryptoPrecision = useMemo( + () => fromBaseUnit(confirmedQuote.stakingAmountCryptoBaseUnit, stakingAsset?.precision ?? 0), + [confirmedQuote.stakingAmountCryptoBaseUnit, stakingAsset?.precision], + ) + + const serializedTxIndex = useMemo(() => { + return serializeTxIndex(confirmedQuote.stakingAssetAccountId, txId, stakingAssetAccountAddress) + }, [txId, confirmedQuote.stakingAssetAccountId, stakingAssetAccountAddress]) + + const tx = useAppSelector(state => selectTxById(state, serializedTxIndex)) const bodyContent: BodyContent | null = useMemo(() => { - switch (status) { + if (!stakingAsset) return null + + switch (tx?.status) { + case undefined: case TxStatus.Pending: return { key: TxStatus.Pending, title: 'pools.waitingForConfirmation', - body: ['RFOX.stakePending', { amount: '1,500', symbol: 'FOX' }], + body: [ + 'RFOX.stakePending', + { amount: stakingAmountCryptoPrecision, symbol: stakingAsset.symbol }, + ], element: , } case TxStatus.Confirmed: return { key: TxStatus.Confirmed, title: 'common.success', - body: ['RFOX.stakeSuccess', { amount: '1,500', symbol: 'FOX' }], + body: [ + 'RFOX.stakeSuccess', + { amount: stakingAmountCryptoPrecision, symbol: stakingAsset.symbol }, + ], element: , } case TxStatus.Failed: return { key: TxStatus.Failed, title: 'common.somethingWentWrong', - body: 'Show error message here', + body: 'common.somethingWentWrongBody', element: , } default: return null } - }, [status]) + }, [tx?.status, stakingAmountCryptoPrecision, stakingAsset]) + + const txLink = useMemo( + () => getTxLink({ txId, defaultExplorerBaseUrl: stakingAsset?.explorerTxLink ?? '' }), + [stakingAsset?.explorerTxLink, txId], + ) return ( {bodyContent && ( - +
{bodyContent.element} @@ -79,7 +120,7 @@ export const StakeStatus: React.FC = () => { )} -