diff --git a/src/app/common/hooks/use-bitcoin-contracts.ts b/src/app/common/hooks/use-bitcoin-contracts.ts index 0526bee6110..aec2a6700a7 100644 --- a/src/app/common/hooks/use-bitcoin-contracts.ts +++ b/src/app/common/hooks/use-bitcoin-contracts.ts @@ -4,17 +4,17 @@ import { RpcErrorCode } from '@btckit/types'; import { JsDLCInterface } from '@dlc-link/dlc-tools'; import { bytesToHex } from '@stacks/common'; -import { BITCOIN_API_BASE_URL_MAINNET, BITCOIN_API_BASE_URL_TESTNET } from '@shared/constants'; import { deriveAddressIndexKeychainFromAccount, extractAddressIndexFromPath, } from '@shared/crypto/bitcoin/bitcoin.utils'; -import { createMoneyFromDecimal } from '@shared/models/money.model'; +import { Money, createMoneyFromDecimal } from '@shared/models/money.model'; import { RouteUrls } from '@shared/route-urls'; import { BitcoinContractResponseStatus } from '@shared/rpc/methods/accept-bitcoin-contract'; import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; import { makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; +import { checkDlcLinkAttestorHealth } from '@app/query/bitcoin/contract/check-dlc-link-attestor-health'; import { sendAcceptedBitcoinContractOfferToProtocolWallet } from '@app/query/bitcoin/contract/send-accepted-bitcoin-contract-offer'; import { useCalculateBitcoinFiatValue, @@ -22,14 +22,14 @@ import { } from '@app/query/common/market-data/market-data.hooks'; import { useCurrentAccountIndex } from '@app/store/accounts/account'; import { - useCurrentAccountNativeSegwitSigner, + useCurrentAccountNativeSegwitIndexZeroSigner, useNativeSegwitAccountBuilder, } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; import { initialSearchParams } from '../initial-search-params'; import { i18nFormatCurrency } from '../money/format-money'; import { satToBtc } from '../money/unit-conversion'; -import { whenBitcoinNetwork } from '../utils'; import { useDefaultRequestParams } from './use-default-request-search-params'; export interface SimplifiedBitcoinContract { @@ -44,6 +44,13 @@ interface CounterpartyWalletDetails { counterpartyWalletIcon: string; } +export interface BitcoinContractListItem { + id: string; + state: string; + acceptorCollateral: string; + txId: string; +} + export interface BitcoinContractOfferDetails { simplifiedBitcoinContract: SimplifiedBitcoinContract; counterpartyWalletDetails: CounterpartyWalletDetails; @@ -54,18 +61,16 @@ export function useBitcoinContracts() { const defaultParams = useDefaultRequestParams(); const bitcoinMarketData = useCryptoCurrencyMarketData('BTC'); const calculateFiatValue = useCalculateBitcoinFiatValue(); - const getNativeSegwitSigner = useCurrentAccountNativeSegwitSigner(); + const bitcoinAccountDetails = useCurrentAccountNativeSegwitIndexZeroSigner(); const currentIndex = useCurrentAccountIndex(); const nativeSegwitPrivateKeychain = useNativeSegwitAccountBuilder()?.(currentIndex); + const currentBitcoinNetwork = useCurrentNetwork(); async function getBitcoinContractInterface( attestorURLs: string[] ): Promise { - const bitcoinAccountDetails = getNativeSegwitSigner?.(0); - if (!nativeSegwitPrivateKeychain || !bitcoinAccountDetails) return; - const currentBitcoinNetwork = bitcoinAccountDetails.network; const currentAddress = bitcoinAccountDetails.address; const currentAccountIndex = extractAddressIndexFromPath(bitcoinAccountDetails.derivationPath); @@ -75,18 +80,11 @@ export function useBitcoinContracts() { if (!currentAddressPrivateKey) return; - const blockchainAPI = whenBitcoinNetwork(currentBitcoinNetwork)({ - mainnet: BITCOIN_API_BASE_URL_MAINNET, - testnet: BITCOIN_API_BASE_URL_TESTNET, - regtest: BITCOIN_API_BASE_URL_TESTNET, - signet: BITCOIN_API_BASE_URL_TESTNET, - }); - - const bitcoinContractInterface = JsDLCInterface.new( + const bitcoinContractInterface = await JsDLCInterface.new( bytesToHex(currentAddressPrivateKey), currentAddress, - currentBitcoinNetwork, - blockchainAPI, + currentBitcoinNetwork.chain.bitcoin.network, + currentBitcoinNetwork.chain.bitcoin.url, JSON.stringify(attestorURLs) ); @@ -198,6 +196,57 @@ export function useBitcoinContracts() { close(); } + async function getHealthyDlcLinkAttestor(): Promise { + const dlcLinkAttestorUrls = [ + 'https://devnet.dlc.link/attestor-1/', + 'https://devnet.dlc.link/attestor-2/', + 'https://devnet.dlc.link/attestor-3/', + ]; + + let currentAttestorUrl: string | undefined; + + for (const attestorURL of dlcLinkAttestorUrls) { + const isAttestorHealthy = await checkDlcLinkAttestorHealth(attestorURL); + if (isAttestorHealthy) { + currentAttestorUrl = attestorURL; + break; + } + } + + if (!currentAttestorUrl) { + throw new Error('Unable to find a healthy DLC.Link attestor'); + } + + return currentAttestorUrl; + } + + async function getAllSignedBitcoinContracts() { + let bitcoinContractInterface: JsDLCInterface | undefined; + + try { + const currentAttestorUrl = await getHealthyDlcLinkAttestor(); + bitcoinContractInterface = await getBitcoinContractInterface([currentAttestorUrl]); + } catch (error) { + navigate(RouteUrls.BitcoinContractLockError, { + state: { + error, + title: 'There was an error with getting the Bitcoin Contract Interface', + body: 'Unable to setup Bitcoin Contract Interface', + }, + }); + sendRpcResponse(BitcoinContractResponseStatus.INTERFACE_ERROR); + } + + if (!bitcoinContractInterface) return; + + const bitcoinContracts = await bitcoinContractInterface.get_contracts(); + const signedBitcoinContracts = bitcoinContracts.filter( + (bitcoinContract: BitcoinContractListItem) => bitcoinContract.state === 'Signed' + ); + + return signedBitcoinContracts; + } + function getTransactionDetails(txId: string, bitcoinCollateral: number) { const bitcoinValue = satToBtc(bitcoinCollateral); const txMoney = createMoneyFromDecimal(bitcoinValue, 'BTC'); @@ -215,6 +264,19 @@ export function useBitcoinContracts() { }; } + async function sumBitcoinContractCollateralAmounts(): Promise { + let bitcoinContractsCollateralSum = 0; + const bitcoinContracts = await getAllSignedBitcoinContracts(); + bitcoinContracts.forEach((bitcoinContract: BitcoinContractListItem) => { + bitcoinContractsCollateralSum += parseInt(bitcoinContract.acceptorCollateral); + }); + const bitcoinContractCollateralSumMoney = createMoneyFromDecimal( + satToBtc(bitcoinContractsCollateralSum), + 'BTC' + ); + return bitcoinContractCollateralSumMoney; + } + function sendRpcResponse( responseStatus: BitcoinContractResponseStatus, bitcoinContractId?: string, @@ -275,6 +337,8 @@ export function useBitcoinContracts() { handleOffer, handleAccept, handleReject, + getAllSignedBitcoinContracts, + sumBitcoinContractCollateralAmounts, sendRpcResponse, }; } diff --git a/src/app/common/utils.ts b/src/app/common/utils.ts index ff16075eb60..2f8314ea828 100644 --- a/src/app/common/utils.ts +++ b/src/app/common/utils.ts @@ -264,10 +264,6 @@ export function whenStacksChainId(chainId: ChainID) { return (chainIdMap: WhenStacksChainIdMap): T => chainIdMap[chainId]; } -export function whenBitcoinNetwork(mode: BitcoinNetworkModes) { - return (networkMap: Record): T => networkMap[mode]; -} - export function logAndThrow(msg: string, args: any[] = []) { logger.error(msg, ...args); throw new Error(msg); diff --git a/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx b/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx new file mode 100644 index 00000000000..e45c3a21295 --- /dev/null +++ b/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx @@ -0,0 +1,63 @@ +import { Flex, StackProps } from '@stacks/ui'; +import { forwardRefWithAs } from '@stacks/ui-core'; +import { CryptoAssetSelectors } from '@tests/selectors/crypto-asset.selectors'; +import { HStack } from 'leather-styles/jsx'; + +import { Money } from '@shared/models/money.model'; + +import { formatBalance } from '@app/common/format-balance'; +import { ftDecimals } from '@app/common/stacks-utils'; +import { Flag } from '@app/components/layout/flag'; +import { Tooltip } from '@app/components/tooltip'; +import { Caption, Text } from '@app/components/typography'; + +import { SmallLoadingSpinner } from '../loading-spinner'; + +interface BitcoinContractEntryPointLayoutProps extends StackProps { + balance: Money; + caption: string; + icon: React.JSX.Element; + usdBalance?: string; + isLoading?: boolean; + onClick: () => void; +} +export const BitcoinContractEntryPointLayout = forwardRefWithAs( + (props: BitcoinContractEntryPointLayoutProps) => { + const { balance, caption, icon, usdBalance, isLoading, onClick } = props; + + const amount = balance.decimals + ? ftDecimals(balance.amount, balance.decimals) + : balance.amount.toString(); + const dataTestId = CryptoAssetSelectors.CryptoAssetListItem.replace( + '{symbol}', + balance.symbol.toLowerCase() + ); + const formattedBalance = formatBalance(amount); + + return ( + + + + {'Bitcoin Contracts'} + + + {isLoading ? : formattedBalance.value} + + + + + {caption} + {isLoading ? '' : {usdBalance}} + + + + ); + } +); diff --git a/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point.tsx b/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point.tsx new file mode 100644 index 00000000000..d0758d28306 --- /dev/null +++ b/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { Box } from '@stacks/ui'; + +import { Money, createMoneyFromDecimal } from '@shared/models/money.model'; +import { RouteUrls } from '@shared/route-urls'; + +import { useBitcoinContracts } from '@app/common/hooks/use-bitcoin-contracts'; +import { i18nFormatCurrency } from '@app/common/money/format-money'; +import { useCalculateBitcoinFiatValue } from '@app/query/common/market-data/market-data.hooks'; + +import { BitcoinContractIcon } from '../icons/bitcoin-contract-icon'; +import { BitcoinContractEntryPointLayout } from './bitcoin-contract-entry-point-layout'; + +interface BitcoinContractEntryPointProps { + btcAddress: string; +} + +export function BitcoinContractEntryPoint({ btcAddress }: BitcoinContractEntryPointProps) { + const navigate = useNavigate(); + const { sumBitcoinContractCollateralAmounts } = useBitcoinContracts(); + const [isLoading, setIsLoading] = useState(true); + const calculateFiatValue = useCalculateBitcoinFiatValue(); + const [bitcoinContractSum, setBitcoinContractSum] = useState( + createMoneyFromDecimal(0, 'BTC') + ); + + useEffect(() => { + const getBitcoinContractDataAndSetState = async () => { + setIsLoading(true); + const currentBitcoinContractSum = await sumBitcoinContractCollateralAmounts(); + setBitcoinContractSum(currentBitcoinContractSum); + setIsLoading(false); + }; + getBitcoinContractDataAndSetState(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [btcAddress]); + + function onClick() { + navigate(RouteUrls.BitcoinContractList); + } + + return ( + } + usdBalance={i18nFormatCurrency(calculateFiatValue(bitcoinContractSum))} + onClick={onClick} + /> + ); +} diff --git a/src/app/components/icons/bitcoin-contract-icon.tsx b/src/app/components/icons/bitcoin-contract-icon.tsx new file mode 100644 index 00000000000..195db6aed1e --- /dev/null +++ b/src/app/components/icons/bitcoin-contract-icon.tsx @@ -0,0 +1,40 @@ +export function BitcoinContractIcon() { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/components/loading-spinner.tsx b/src/app/components/loading-spinner.tsx index a497e40829d..1e195f10006 100644 --- a/src/app/components/loading-spinner.tsx +++ b/src/app/components/loading-spinner.tsx @@ -8,6 +8,14 @@ export function LoadingSpinner(props: FlexProps) { ); } +export function SmallLoadingSpinner(props: FlexProps) { + return ( + + + + ); +} + export function FullPageLoadingSpinner(props: FlexProps) { return ( diff --git a/src/app/features/asset-list/asset-list.tsx b/src/app/features/asset-list/asset-list.tsx index c36a0fef5e8..265abffda89 100644 --- a/src/app/features/asset-list/asset-list.tsx +++ b/src/app/features/asset-list/asset-list.tsx @@ -8,6 +8,7 @@ import { LEDGER_BITCOIN_ENABLED } from '@shared/environment'; import { useBtcAssetBalance } from '@app/common/hooks/balance/btc/use-btc-balance'; import { useStxBalance } from '@app/common/hooks/balance/stx/use-stx-balance'; import { useWalletType } from '@app/common/use-wallet-type'; +import { BitcoinContractEntryPoint } from '@app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point'; import { Brc20TokensLoader } from '@app/components/brc20-tokens-loader'; import { CryptoCurrencyAssetItem } from '@app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item'; import { StxAvatar } from '@app/components/crypto-assets/stacks/components/stx-avatar'; @@ -17,6 +18,7 @@ import { useHasBitcoinLedgerKeychain } from '@app/store/accounts/blockchain/bitc import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useHasStacksKeychain } from '@app/store/accounts/blockchain/stacks/stacks.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; import { Collectibles } from '../collectibles/collectibles'; import { PendingBrc20TransferList } from '../pending-brc-20-transfers/pending-brc-20-transfers'; @@ -28,6 +30,7 @@ export function AssetsList() { const hasStacksKeys = useHasStacksKeychain(); const hasBitcoinKeys = useHasBitcoinLedgerKeychain(); const btcAddress = useCurrentAccountNativeSegwitAddressIndexZero(); + const network = useCurrentNetwork(); const currentAccount = useCurrentStacksAccount(); const { btcAvailableAssetBalance, btcAvailableUsdBalance } = useBtcAssetBalance(btcAddress); @@ -38,6 +41,11 @@ export function AssetsList() { return ( {/* Temporary duplication during Ledger Bitcoin feature dev */} + {network.chain.bitcoin.network === 'testnet' && + whenWallet({ + software: , + ledger: null, + })} {whenWallet({ software: ( ([]); + const [isLoading, setLoading] = useState(true); + + useOnMount(() => { + const fetchAndFormatBitcoinContracts = async () => { + const fetchedBitcoinContracts = await getAllSignedBitcoinContracts(); + setBitcoinContracts(fetchedBitcoinContracts); + setLoading(false); + }; + void fetchAndFormatBitcoinContracts(); + }); + + if (isLoading) return ; + + return ( + + {bitcoinContracts.length === 0 ? ( + + You don't have any open Bitcoin Contracts. + + ) : ( + bitcoinContracts.map(bitcoinContract => { + return ( + + ); + }) + )} + + ); +} diff --git a/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx b/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx new file mode 100644 index 00000000000..ed41f2ec49f --- /dev/null +++ b/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; + +import { Box, Flex } from '@stacks/ui'; +import { HStack } from 'leather-styles/jsx'; + +import { createMoneyFromDecimal } from '@shared/models/money.model'; + +import { useExplorerLink } from '@app/common/hooks/use-explorer-link'; +import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money'; +import { i18nFormatCurrency } from '@app/common/money/format-money'; +import { satToBtc } from '@app/common/money/unit-conversion'; +import { BitcoinContractIcon } from '@app/components/icons/bitcoin-contract-icon'; +import { Flag } from '@app/components/layout/flag'; +import { Caption, Text } from '@app/components/typography'; +import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks'; + +interface BitcoinContractListItemLayoutProps { + id: string; + state: string; + collateralAmount: string; + txId: string; +} +export function BitcoinContractListItemLayout({ + id, + state, + collateralAmount, + txId, +}: BitcoinContractListItemLayoutProps) { + const { handleOpenTxLink } = useExplorerLink(); + const bitcoinMarketData = useCryptoCurrencyMarketData('BTC'); + + const getFiatValue = useCallback( + (value: string) => + i18nFormatCurrency( + baseCurrencyAmountInQuote(createMoneyFromDecimal(Number(value), 'BTC'), bitcoinMarketData) + ), + [bitcoinMarketData] + ); + + return ( + + handleOpenTxLink({ + blockchain: 'bitcoin', + suffix: `&submitted=true`, + txid: txId, + }) + } + > + } align="middle" spacing="base" width="100%"> + + {id} + + {satToBtc(parseInt(collateralAmount)).toString()} + + + + {state} + {getFiatValue(satToBtc(parseInt(collateralAmount)).toString())} + + + + ); +} diff --git a/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-layout.tsx b/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-layout.tsx new file mode 100644 index 00000000000..6002a8bf1da --- /dev/null +++ b/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-layout.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { Stack } from 'leather-styles/jsx'; + +import { RouteUrls } from '@shared/route-urls'; + +import { useRouteHeader } from '@app/common/hooks/use-route-header'; +import { Header } from '@app/components/header'; + +interface BitcoinContractListProps { + children: ReactNode; +} +export function BitcoinContractListLayout({ children }: BitcoinContractListProps) { + const navigate = useNavigate(); + useRouteHeader(
navigate(RouteUrls.Home)} />); + return ( + + {children} + + ); +} diff --git a/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx b/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx index 19b5accd83f..0175b8a2e70 100644 --- a/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx +++ b/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Stack } from '@stacks/ui'; + import { RouteUrls } from '@shared/route-urls'; import { BitcoinContractResponseStatus } from '@shared/rpc/methods/accept-bitcoin-contract'; @@ -103,9 +105,11 @@ export function BitcoinContractRequest() { bitcoinContractOfferDetails.counterpartyWalletDetails.counterpartyWalletIcon } /> - + + + { + try { + const response = await fetch(`${attestorUrl}/health`, { + method: 'get', + }); + + return response.ok; + } catch (error) { + return false; + } +} diff --git a/src/app/routes/app-routes.tsx b/src/app/routes/app-routes.tsx index c4f90b6821e..973b01592e4 100644 --- a/src/app/routes/app-routes.tsx +++ b/src/app/routes/app-routes.tsx @@ -24,6 +24,7 @@ import { ledgerStacksTxSigningRoutes } from '@app/features/ledger/flows/stacks-t import { UnsupportedBrowserLayout } from '@app/features/ledger/generic-steps'; import { ConnectLedgerStart } from '@app/features/ledger/generic-steps/connect-device/connect-ledger-start'; import { RetrieveTaprootToNativeSegwit } from '@app/features/retrieve-taproot-to-native-segwit/retrieve-taproot-to-native-segwit'; +import { BitcoinContractList } from '@app/pages/bitcoin-contract-list/bitcoin-contract-list'; import { BitcoinContractRequest } from '@app/pages/bitcoin-contract-request/bitcoin-contract-request'; import { ChooseAccount } from '@app/pages/choose-account/choose-account'; import { FundPage } from '@app/pages/fund/fund'; @@ -107,6 +108,7 @@ function useAppRoutes() { > } /> } /> + } />