From 36f9f28c85a6f62ab692c927300962375088ff02 Mon Sep 17 00:00:00 2001 From: Chris Ling <81092286+chrisling-dev@users.noreply.github.com> Date: Tue, 17 Sep 2024 14:39:26 +0800 Subject: [PATCH 1/2] feat: orange pill (#1175) * feat: orange pill * refactor: swap quests endpoint * fix: token * fix: remove unused --- apps/portal/package.json | 1 + .../components/SeparatedAccountSelector.tsx | 281 ++++++++++-------- .../widgets/chainflip-swap/FromAccount.tsx | 24 +- .../chainflip-swap/SwapTokensModal.tsx | 6 +- .../widgets/chainflip-swap/ToAccount.tsx | 37 ++- .../chainflip-swap/TokenAmountInput.tsx | 26 +- .../widgets/chainflip-swap/curated-tokens.tsx | 5 +- .../widgets/chainflip-swap/index.tsx | 6 + .../side-panel/details/SwapDetailsCard.tsx | 20 +- .../swap-modules/chainflip.swap-module.ts | 64 ++-- .../swap-modules/common.swap-module.ts | 49 ++- .../swap-modules/simpleswap-swap-module.ts | 48 ++- .../widgets/chainflip-swap/swaps.api.ts | 17 +- apps/portal/src/lib/btc.ts | 50 ++++ yarn.lock | 101 +++++++ 15 files changed, 525 insertions(+), 210 deletions(-) create mode 100644 apps/portal/src/lib/btc.ts diff --git a/apps/portal/package.json b/apps/portal/package.json index 999cc4790..415282014 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -55,6 +55,7 @@ "@visx/visx": "^3.1.2", "avail-js-sdk": "^0.2.13", "bignumber.js": "^9.1.1", + "bitcoinjs-lib": "^7.0.0-rc.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "csv-stringify": "^6.2.3", diff --git a/apps/portal/src/components/SeparatedAccountSelector.tsx b/apps/portal/src/components/SeparatedAccountSelector.tsx index dbf4e55c5..f8685f2fa 100644 --- a/apps/portal/src/components/SeparatedAccountSelector.tsx +++ b/apps/portal/src/components/SeparatedAccountSelector.tsx @@ -1,6 +1,7 @@ import AccountIcon from './molecules/AccountIcon' import { walletConnectionSideSheetOpenState } from './widgets/WalletConnectionSideSheet' -import { evmAccountsState, substrateAccountsState, type Account } from '@/domains/accounts' +import { evmAccountsState, substrateAccountsState } from '@/domains/accounts' +import { AccountWithBtc, isBtcAddress } from '@/lib/btc' import { cn } from '@/lib/utils' import { shortenAddress } from '@/util/format' import { isAddress as isSubstrateAddress, decodeAddress, encodeAddress } from '@polkadot/util-crypto' @@ -15,16 +16,17 @@ import { isAddress } from 'viem' type Props = { allowInput?: boolean - accountsType?: 'substrate' | 'ethereum' | 'all' + accountsType?: 'substrate' | 'ethereum' | 'all' | 'btc' /** Selected Account Address */ onAccountChange?: (address: string | null) => void - evmAccountsFilter?: (account: Account) => boolean + evmAccountsFilter?: (account: AccountWithBtc) => boolean showBalances?: { filter?: BalanceSearchQuery | BalanceSearchQuery[] output?: (address: string, addressBalances: Balances) => React.ReactNode } - substrateAccountsFilter?: (account: Account) => boolean + substrateAccountsFilter?: (account: AccountWithBtc) => boolean substrateAccountPrefix?: number + disableBtc?: boolean value?: string | null } @@ -35,7 +37,7 @@ const AccountRow: React.FC<{ substrateAccountPrefix?: number }> = ({ address, name, balance, substrateAccountPrefix }) => { const formattedAddress = useMemo(() => { - if (address.startsWith('0x') || substrateAccountPrefix === undefined) return address + if (address.startsWith('0x') || substrateAccountPrefix === undefined || isBtcAddress(address)) return address return encodeAddress(decodeAddress(address), substrateAccountPrefix) }, [address, substrateAccountPrefix]) @@ -60,6 +62,7 @@ const AccountRow: React.FC<{ ) } + export const SeparatedAccountSelector: React.FC = ({ accountsType = 'substrate', allowInput = false, @@ -69,35 +72,50 @@ export const SeparatedAccountSelector: React.FC = ({ substrateAccountsFilter, substrateAccountPrefix, value, + disableBtc = false, }) => { const defaultEvmAccounts = useRecoilValue(evmAccountsState) const defaultSubstrateAccounts = useRecoilValue(substrateAccountsState) - const balances = useBalances() const setWalletConnectionSideSheetOpen = useSetRecoilState(walletConnectionSideSheetOpenState) const [query, setQuery] = useState('') + const balances = useBalances() const filteredBalances = useMemo(() => { if (showBalances && showBalances.filter) return balances.find(showBalances.filter) return balances }, [showBalances, balances]) - const accountFromInput = useMemo((): Account | null => { + const accountFromInput = useMemo((): AccountWithBtc | null => { if (!allowInput) return null - if (isAddress(query)) - return { - address: query, - type: 'ethereum', - partOfPortfolio: false, - readonly: true, - } - if (isSubstrateAddress(query)) - return { - // computation will always be done in generic format - address: encodeAddress(decodeAddress(query), 42), - type: 'sr25519', - partOfPortfolio: false, - readonly: true, - } + + const btcAddress = isBtcAddress(query) + if (btcAddress) return btcAddress + + try { + if (isAddress(query)) + return { + address: query, + type: 'ethereum', + partOfPortfolio: false, + readonly: true, + } + } catch (e) { + // do nothing + } + + try { + if (isSubstrateAddress(query)) + return { + // computation will always be done in generic format + address: encodeAddress(decodeAddress(query), 42), + type: 'sr25519', + partOfPortfolio: false, + readonly: true, + } + } catch (e) { + // do nothing + } + return null }, [allowInput, query]) @@ -121,7 +139,9 @@ export const SeparatedAccountSelector: React.FC = ({ filtered.find(a => a.address.toLowerCase() === accountFromInput.address.toLowerCase()) ) return filtered - return [accountFromInput, ...filtered] + return [accountFromInput, ...filtered].filter( + a => a.type === 'sr25519' || a.type === 'ed25519' || a.type === 'ecdsa' + ) }, [accountFromInput, substrateAccountsFilter, defaultSubstrateAccounts]) const queriedEvmAccounts = useMemo(() => { @@ -145,15 +165,23 @@ export const SeparatedAccountSelector: React.FC = ({ ) }, [query, substrateAccountPrefix, substrateAccounts]) + const btcAccounts = useMemo(() => { + if (accountFromInput?.type === 'btc-base58' || accountFromInput?.type === 'btc-bench32') return [accountFromInput] + return [] + }, [accountFromInput]) + const selectedAccount = useMemo(() => { - const allowedAccounts = - accountsType === 'ethereum' - ? evmAccounts - : accountsType === 'substrate' - ? substrateAccounts - : [...evmAccounts, ...substrateAccounts] - return allowedAccounts.find(account => account.address === value) - }, [accountsType, evmAccounts, value, substrateAccounts]) + switch (accountsType) { + case 'ethereum': + return evmAccounts.find(account => account.address === value) + case 'substrate': + return substrateAccounts.find(account => account.address === value) + case 'all': + return [...evmAccounts, ...substrateAccounts].find(account => account.address === value) + case 'btc': + return btcAccounts.find(account => account.address === value) + } + }, [accountsType, evmAccounts, substrateAccounts, btcAccounts, value]) const handleInputChange = useCallback( (e: React.ChangeEvent) => { @@ -173,6 +201,78 @@ export const SeparatedAccountSelector: React.FC = ({ } }, [onAccountChange, selectedAccount, value]) + const renderNetworkAccounts = useCallback( + ({ + type, + accounts, + emptyQueryMessage, + emptyState, + queriedAccounts, + }: { + type: Required['accountsType'] + accounts: AccountWithBtc[] + emptyQueryMessage?: string + emptyState?: React.ReactNode + queriedAccounts: AccountWithBtc[] + }) => { + if (accountsType !== 'all' && type !== accountsType) return null + + return ( +
+ {accountsType === 'all' && ( +
+

Ethereum Accounts

+
+ )} + {accounts.length > 0 ? ( + queriedAccounts.length > 0 ? ( + queriedAccounts.map(account => ( + } + headlineContent={ + b.address.toLowerCase() === account.address.toLowerCase()) + ) ?? null + : null + } + /> + } + key={account.address} + value={account.address} + className="!w-full [&>div]:w-full " + /> + )) + ) : ( +
+

{emptyQueryMessage ?? 'No account found.'}

+
+ ) + ) : ( + emptyState ?? ( +
+

{emptyQueryMessage ?? 'No account found.'}

+
+ ) + )} +
+ ) + }, + [accountsType, filteredBalances, showBalances, substrateAccountPrefix] + ) + if (accountsType === 'btc' && disableBtc) + return ( + +

BTC accounts not supported.

+
+ ) + if (accountsType === 'ethereum' && !allowInput) { const evmAccount = evmAccounts[0] return ( @@ -215,96 +315,37 @@ export const SeparatedAccountSelector: React.FC = ({ onInputChange={handleInputChange} inputValue={query} > - {accountsType !== 'substrate' && ( - - {accountsType === 'all' && ( -
-

Ethereum Accounts

-
- )} - {evmAccounts.length > 0 ? ( - queriedEvmAccounts.length > 0 ? ( - queriedEvmAccounts.map(account => ( - } - headlineContent={ - b.address.toLowerCase() === account.address.toLowerCase()) - ) ?? null - : null - } - /> - } - key={account.address} - value={account.address} - className="!w-full [&>div]:w-full" - /> - )) - ) : ( -
-

No Ethereum account found.

-
- ) - ) : ( -
{ - setWalletConnectionSideSheetOpen(true) - }} - > -
- -
-

Connect Ethereum Wallet

-
- )} -
- )} - {accountsType !== 'ethereum' && ( -
- {accountsType === 'all' && ( -
-

Polkadot Accounts

-
- )} - {queriedSubstrateAccounts.length > 0 ? ( - queriedSubstrateAccounts.map(account => ( - } - headlineContent={ - b.address === account.address) - ) ?? null - : null - } - /> - } - className="!w-full [&>div]:w-full" - key={account.address} - value={account.address} - /> - )) - ) : ( -
-

No Polkadot account found.

+ {renderNetworkAccounts({ + accounts: evmAccounts, + queriedAccounts: queriedEvmAccounts, + type: 'ethereum', + emptyState: ( +
{ + setWalletConnectionSideSheetOpen(true) + }} + > +
+
- )} -
- )} +

Connect Ethereum Wallet

+
+ ), + emptyQueryMessage: 'No Ethereum account found.', + })} + {renderNetworkAccounts({ + type: 'substrate', + accounts: substrateAccounts, + queriedAccounts: queriedSubstrateAccounts, + emptyQueryMessage: 'No Polkadot account found.', + })} + {renderNetworkAccounts({ + type: 'btc', + accounts: btcAccounts, + queriedAccounts: btcAccounts, + emptyQueryMessage: 'Please provide a valid Bitcoin address.', + })} ) } diff --git a/apps/portal/src/components/widgets/chainflip-swap/FromAccount.tsx b/apps/portal/src/components/widgets/chainflip-swap/FromAccount.tsx index 0f4074990..191aeff16 100644 --- a/apps/portal/src/components/widgets/chainflip-swap/FromAccount.tsx +++ b/apps/portal/src/components/widgets/chainflip-swap/FromAccount.tsx @@ -12,7 +12,6 @@ import { import { SeparatedAccountSelector } from '@/components/SeparatedAccountSelector' import { selectedCurrencyState } from '@/domains/balances' import { cn } from '@/lib/utils' -import { useTokens } from '@talismn/balances-react' import { Decimal } from '@talismn/math' import { Surface } from '@talismn/ui' import { Wallet } from '@talismn/web-icons' @@ -39,12 +38,6 @@ export const FromAccount: React.FC = ({ fastBalance }) => { const [toEvmAddress, setToEvmAddress] = useAtom(toEvmAddressAtom) const [toSubstrateAddress, setToSubstrateAddress] = useAtom(toSubstrateAddressAtom) - const tokens = useTokens() - const token = useMemo(() => { - if (!fromAsset) return null - return tokens[fromAsset.id] - }, [fromAsset, tokens]) - const onChangeAddress = useCallback( (address: string | null) => { if (!address) return @@ -68,11 +61,15 @@ export const FromAccount: React.FC = ({ fastBalance }) => { ] ) + const isSwappingFromBtc = useMemo(() => { + return fromAsset?.id === 'btc-native' + }, [fromAsset]) + const shouldShowToAccount = useMemo(() => { - if (!fromAsset || !toAsset) return false + if (!fromAsset || !toAsset || isSwappingFromBtc) return false if (fromAsset.networkType !== toAsset.networkType) return true return toAddress?.toLowerCase() !== fromAddress?.toLowerCase() - }, [fromAddress, fromAsset, toAddress, toAsset]) + }, [fromAddress, fromAsset, isSwappingFromBtc, toAddress, toAsset]) return ( @@ -107,17 +104,20 @@ export const FromAccount: React.FC = ({ fastBalance }) => {

Destination

- {!!fromAsset && ( + {!!fromAsset && !isSwappingFromBtc && (

Origin Account

!a.readonly} evmAccountsFilter={a => !!a.canSignEvm} diff --git a/apps/portal/src/components/widgets/chainflip-swap/SwapTokensModal.tsx b/apps/portal/src/components/widgets/chainflip-swap/SwapTokensModal.tsx index e0442bdaa..2a0eaca08 100644 --- a/apps/portal/src/components/widgets/chainflip-swap/SwapTokensModal.tsx +++ b/apps/portal/src/components/widgets/chainflip-swap/SwapTokensModal.tsx @@ -186,7 +186,11 @@ export const SwapTokensModal: React.FC = ({ > { @@ -12,12 +17,7 @@ export const ToAccount: React.FC = () => { const toAddress = useAtomValue(toAddressAtom) const setEvmAddress = useSetAtom(toEvmAddressAtom) const setSubstrate = useSetAtom(toSubstrateAddressAtom) - const tokens = useTokens() - - const token = useMemo(() => { - if (!toAsset) return null - return tokens[toAsset.id] - }, [toAsset, tokens]) + const setBtcAddress = useSetAtom(toBtcAddressAtom) return (
@@ -25,16 +25,31 @@ export const ToAccount: React.FC = () => { {toAsset && ( !a.readonly} substrateAccountPrefix={0} value={toAddress} onAccountChange={address => { if (address) { - isAddress(address) ? setEvmAddress(address) : setSubstrate(address) + if (isBtcAddress(address)) { + setBtcAddress(address) + } else if (isAddress(address)) { + setEvmAddress(address) + } else { + setSubstrate(address) + } } else { setEvmAddress(null) setSubstrate(null) + setBtcAddress(null) } }} /> diff --git a/apps/portal/src/components/widgets/chainflip-swap/TokenAmountInput.tsx b/apps/portal/src/components/widgets/chainflip-swap/TokenAmountInput.tsx index 221e4bff4..e6948c769 100644 --- a/apps/portal/src/components/widgets/chainflip-swap/TokenAmountInput.tsx +++ b/apps/portal/src/components/widgets/chainflip-swap/TokenAmountInput.tsx @@ -2,7 +2,7 @@ import { SwapTokensModal } from './SwapTokensModal' import { SwappableAssetWithDecimals } from './swap-modules/common.swap-module' import { selectedCurrencyState } from '@/domains/balances' import { cn } from '@/lib/utils' -import { useTokenRates } from '@talismn/balances-react' +import { useTokenRates, useTokens } from '@talismn/balances-react' import { Decimal } from '@talismn/math' import { CircularProgressIndicator, TextInput, Tooltip } from '@talismn/ui' import { HelpCircle } from 'lucide-react' @@ -24,6 +24,7 @@ type Props = { disabled?: boolean balances?: Record hideBalance?: boolean + disableBtc?: boolean } const hardcodedGasBufferByTokenSymbol: Record = { @@ -36,6 +37,7 @@ export const TokenAmountInput: React.FC = ({ assets, availableBalance, balances, + disableBtc, hideBalance, leadingLabel, onChangeAsset, @@ -49,6 +51,7 @@ export const TokenAmountInput: React.FC = ({ const [input, setInput] = useState((amount?.planck ?? 0n) > 0n ? amount?.toString() ?? '' : '') const currency = useRecoilValue(selectedCurrencyState) + const tokens = useTokens() const rates = useTokenRates() const shouldDisplayBalance = useMemo(() => { if (hideBalance || !selectedAsset) return false @@ -87,14 +90,20 @@ export const TokenAmountInput: React.FC = ({ [onChangeAmount, parseInput] ) + const bestGuessRate = useMemo(() => { + if (!selectedAsset) return null + const confirmedRate = rates[selectedAsset.id] + if (confirmedRate) return confirmedRate + return Object.entries(rates ?? {}).find(([id]) => tokens[id]?.symbol === selectedAsset.symbol)?.[1] + }, [selectedAsset, rates, tokens]) + const usdValue = useMemo(() => { if (!selectedAsset) return null - const rate = rates[selectedAsset.id] - if (!rate || amount === undefined) return null - const rateInCurrency = rate[currency] + if (!bestGuessRate || amount === undefined) return null + const rateInCurrency = bestGuessRate[currency] if (!rateInCurrency) return null return +amount?.toString() * rateInCurrency - }, [amount, currency, rates, selectedAsset]) + }, [amount, bestGuessRate, currency, selectedAsset]) const insufficientBalance = useMemo(() => { if (availableBalance === undefined || !amount) return false @@ -141,7 +150,8 @@ export const TokenAmountInput: React.FC = ({ containerClassName={cn( '[&>div:nth-child(2)]:!py-[8px] [&>div]:!pr-[8px] [&>div:nth-child(2)]:border [&>div:nth-child(2)]:border-red-500/0', { - '[&>div:nth-child(2)]:border-red-400 ': insufficientBalance, + '[&>div:nth-child(2)]:border-red-400 ': + insufficientBalance || (disableBtc && selectedAsset?.id === 'btc-native'), } )} trailingLabel={ @@ -190,6 +200,10 @@ export const TokenAmountInput: React.FC = ({
+ ) : disableBtc && selectedAsset?.id === 'btc-native' ? ( +

+ Swapping from BTC not supported. +

) : null}
} diff --git a/apps/portal/src/components/widgets/chainflip-swap/curated-tokens.tsx b/apps/portal/src/components/widgets/chainflip-swap/curated-tokens.tsx index 81540725e..753ec553e 100644 --- a/apps/portal/src/components/widgets/chainflip-swap/curated-tokens.tsx +++ b/apps/portal/src/components/widgets/chainflip-swap/curated-tokens.tsx @@ -1,10 +1,13 @@ export const popularTokens: string[] = [ + 'btc-native', 'polkadot-substrate-native', - '1-evm-native', 'bittensor-substrate-native', + '1-evm-native', + '1-evm-erc20-0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', '1-evm-erc20-0xdac17f958d2ee523a2206206994597c13d831ec7', '1-evm-erc20-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', '42161-evm-native', + '42161-evm-erc20-0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', '42161-evm-erc20-0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', '42161-evm-erc20-0xaf88d065e77c8cc2239327c5edb3a432268e5831', '169-evm-native', diff --git a/apps/portal/src/components/widgets/chainflip-swap/index.tsx b/apps/portal/src/components/widgets/chainflip-swap/index.tsx index 644bbc6cb..c1b4670b3 100644 --- a/apps/portal/src/components/widgets/chainflip-swap/index.tsx +++ b/apps/portal/src/components/widgets/chainflip-swap/index.tsx @@ -145,6 +145,7 @@ export const ChainFlipSwap: React.FC = () => {

Select Asset

{ availableBalance={fastBalance?.balance?.transferrable} stayAliveBalance={fastBalance?.balance?.stayAlive} onChangeAsset={handleChangeFromAsset} + disableBtc />
{ + ) : fromAsset?.networkType === 'btc' ? ( + ) : fromAsset?.networkType === 'evm' && ethAccounts.length === 0 ? (