diff --git a/src/app/features/add-network/add-network-form.tsx b/src/app/features/add-network/add-network-form.tsx new file mode 100644 index 00000000000..88c9066961e --- /dev/null +++ b/src/app/features/add-network/add-network-form.tsx @@ -0,0 +1,134 @@ +import { useCallback, useEffect } from 'react'; + +import { SelectContent, SelectItem, SelectRoot, SelectTrigger } from '@radix-ui/themes'; +import { NetworkSelectors } from '@tests/selectors/network.selectors'; +import { useFormikContext } from 'formik'; +import { css } from 'leather-styles/css'; + +import { Input } from '@app/ui/components/input/input'; +import { Title } from '@app/ui/components/typography/title'; + +import { type AddNetworkFormValues, useAddNetwork } from './use-add-network'; + +export function AddNetworkForm() { + const { handleChange, setFieldValue, values } = useFormikContext(); + + const { bitcoinApi, handleApiChange } = useAddNetwork(); + + const setStacksUrl = useCallback( + (value: string) => { + void setFieldValue('stacksUrl', value); + }, + [setFieldValue] + ); + + const setBitcoinUrl = useCallback( + (value: string) => { + void setFieldValue('bitcoinUrl', value); + }, + [setFieldValue] + ); + + useEffect(() => { + switch (bitcoinApi) { + case 'mainnet': + setStacksUrl('https://api.hiro.so'); + setBitcoinUrl('https://blockstream.info/api'); + break; + case 'testnet': + setStacksUrl('https://api.testnet.hiro.so'); + setBitcoinUrl('https://blockstream.info/testnet/api'); + break; + case 'signet': + setStacksUrl('https://api.testnet.hiro.so'); + setBitcoinUrl('https://mempool.space/signet/api'); + break; + case 'regtest': + setStacksUrl('https://api.testnet.hiro.so'); + setBitcoinUrl('https://mempool.space/testnet/api'); + break; + } + }, [bitcoinApi, setStacksUrl, setBitcoinUrl]); + + return ( + <> + + Name + + + Bitcoin API + {/* TODO: Replace with new Select */} + + + + + Mainnet + + + Testnet + + + Signet + + + Regtest + + + + Stacks API URL + + Name + + + Bitcoin API URL + + Bitcoin API URL + + + + Network key + + + + ); +} diff --git a/src/app/features/add-network/add-network.tsx b/src/app/features/add-network/add-network.tsx index 60cedfe0874..6c85285c705 100644 --- a/src/app/features/add-network/add-network.tsx +++ b/src/app/features/add-network/add-network.tsx @@ -1,210 +1,28 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { SelectContent, SelectItem, SelectRoot, SelectTrigger } from '@radix-ui/themes'; -import { ChainID } from '@stacks/transactions'; import { NetworkSelectors } from '@tests/selectors/network.selectors'; -import { Formik, useFormik } from 'formik'; -import { css } from 'leather-styles/css'; +import { Form, Formik } from 'formik'; import { Stack, styled } from 'leather-styles/jsx'; -import { BitcoinNetworkModes, DefaultNetworkConfigurations } from '@shared/constants'; -import { RouteUrls } from '@shared/route-urls'; -import { isValidUrl } from '@shared/utils/validate-url'; - -import { removeTrailingSlash } from '@app/common/url-join'; import { ErrorLabel } from '@app/components/error-label'; -import { - useCurrentStacksNetworkState, - useNetworksActions, -} from '@app/store/networks/networks.hooks'; import { Button } from '@app/ui/components/button/button'; -import { Input } from '@app/ui/components/input/input'; -import { Title } from '@app/ui/components/typography/title'; import { Page } from '@app/ui/layout/page/page.layout'; -/** - * The **peer** network ID. - * Not used in signing, but needed to determine the parent of a subnet. - */ -enum PeerNetworkID { - Mainnet = 0x17000000, - Testnet = 0xff000000, -} - -interface AddNetworkFormValues { - key: string; - name: string; - stacksUrl: string; - bitcoinUrl: string; -} -const addNetworkFormValues: AddNetworkFormValues = { - key: '', - name: '', - stacksUrl: '', - bitcoinUrl: '', -}; +import { AddNetworkForm } from './add-network-form'; +import { useAddNetwork } from './use-add-network'; export function AddNetwork() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const navigate = useNavigate(); - const network = useCurrentStacksNetworkState(); - const networksActions = useNetworksActions(); - const [bitcoinApi, setBitcoinApi] = useState('mainnet'); - - const formikProps = useFormik({ - initialValues: addNetworkFormValues, - onSubmit: () => {}, - }); - - const { setFieldValue } = formikProps; - - const handleApiChange = (newValue: BitcoinNetworkModes) => { - setBitcoinApi(newValue); - }; - - const setStacksUrl = useCallback( - (value: string) => { - void setFieldValue('stacksUrl', value); - }, - [setFieldValue] - ); - - const setBitcoinUrl = useCallback( - (value: string) => { - void setFieldValue('bitcoinUrl', value); - }, - [setFieldValue] - ); - - useEffect(() => { - switch (bitcoinApi) { - case 'mainnet': - setStacksUrl('https://api.hiro.so'); - setBitcoinUrl('https://blockstream.info/api'); - break; - case 'testnet': - setStacksUrl('https://api.testnet.hiro.so'); - setBitcoinUrl('https://blockstream.info/testnet/api'); - break; - case 'signet': - setStacksUrl('https://api.testnet.hiro.so'); - setBitcoinUrl('https://mempool.space/signet/api'); - break; - case 'regtest': - setStacksUrl('https://api.testnet.hiro.so'); - setBitcoinUrl('https://mempool.space/testnet/api'); - break; - } - }, [bitcoinApi, setStacksUrl, setBitcoinUrl]); + const { error, initialFormValues, loading, onSubmit } = useAddNetwork(); return ( - { - const { name, stacksUrl, bitcoinUrl, key } = formikProps.values; - - if (!name) { - setError('Enter a name'); - return; - } - - if (!isValidUrl(stacksUrl)) { - setError('Enter a valid Stacks API URL'); - return; - } - - if (!isValidUrl(bitcoinUrl)) { - setError('Enter a valid Bitcoin API URL'); - return; - } - - if (!key) { - setError('Enter a unique key'); - return; - } - - setLoading(true); - setError(''); - - const stacksPath = removeTrailingSlash(new URL(formikProps.values.stacksUrl).href); - const bitcoinPath = removeTrailingSlash(new URL(formikProps.values.bitcoinUrl).href); - - try { - const bitcoinResponse = await network.fetchFn(`${bitcoinPath}/mempool/recent`); - if (!bitcoinResponse.ok) throw new Error('Unable to fetch mempool from bitcoin node'); - const bitcoinMempool = await bitcoinResponse.json(); - if (!Array.isArray(bitcoinMempool)) - throw new Error('Unable to fetch mempool from bitcoin node'); - } catch (error) { - setError('Unable to fetch mempool from bitcoin node'); - setLoading(false); - return; - } - - let stacksChainInfo: any; - try { - const stacksResponse = await network.fetchFn(`${stacksPath}/v2/info`); - stacksChainInfo = await stacksResponse.json(); - if (!stacksChainInfo) throw new Error('Unable to fetch info from stacks node'); - } catch (error) { - setError('Unable to fetch info from stacks node'); - setLoading(false); - return; - } - - // Attention: - // For mainnet/testnet the v2/info response `.network_id` refers to the chain ID - // For subnets the v2/info response `.network_id` refers to the network ID and the chain ID (they are the same for subnets) - // The `.parent_network_id` refers to the actual peer network ID in both cases - const { network_id: chainId, parent_network_id: parentNetworkId } = stacksChainInfo; - - const isSubnet = typeof stacksChainInfo.l1_subnet_governing_contract === 'string'; - const isFirstLevelSubnet = - isSubnet && - (parentNetworkId === PeerNetworkID.Mainnet || - parentNetworkId === PeerNetworkID.Testnet); - - // Currently, only subnets of mainnet and testnet are supported in the wallet - if (isFirstLevelSubnet) { - const parentChainId = - parentNetworkId === PeerNetworkID.Mainnet ? ChainID.Mainnet : ChainID.Testnet; - networksActions.addNetwork({ - id: key as DefaultNetworkConfigurations, - name: name, - chainId: parentChainId, // Used for differentiating control flow in the wallet - subnetChainId: chainId, // Used for signing transactions (via the network object, not to be confused with the NetworkConfigurations) - url: stacksPath, - bitcoinNetwork: bitcoinApi, - bitcoinUrl: bitcoinPath, - }); - navigate(RouteUrls.Home); - } else if (chainId === ChainID.Mainnet || chainId === ChainID.Testnet) { - networksActions.addNetwork({ - id: key as DefaultNetworkConfigurations, - name: name, - chainId: chainId, - url: stacksPath, - bitcoinNetwork: bitcoinApi, - bitcoinUrl: bitcoinPath, - }); - navigate(RouteUrls.Home); - } else { - setError('Unable to determine chainID from node.'); - } - setLoading(false); - }} - > - {({ handleSubmit }) => ( -
+ + {() => ( + Use this form to add a new instance of the{' '} @@ -221,96 +39,19 @@ export function AddNetwork() { . Make sure you review and trust the host before you add it. - - Name - - - Bitcoin API - {/* TODO: Replace with new Select */} - - - - - Mainnet - - - Testnet - - - Signet - - - Regtest - - - - Stacks API URL - - Name - - - Bitcoin API URL - - Bitcoin API URL - - - - Network key - - + {error ? ( {error} ) : null} - + )}
diff --git a/src/app/features/add-network/use-add-network.tsx b/src/app/features/add-network/use-add-network.tsx new file mode 100644 index 00000000000..52b62371df9 --- /dev/null +++ b/src/app/features/add-network/use-add-network.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { ChainID } from '@stacks/transactions'; + +import type { BitcoinNetworkModes, DefaultNetworkConfigurations } from '@shared/constants'; +import { RouteUrls } from '@shared/route-urls'; +import { isValidUrl } from '@shared/utils/validate-url'; + +import { removeTrailingSlash } from '@app/common/url-join'; +import { + useCurrentStacksNetworkState, + useNetworksActions, +} from '@app/store/networks/networks.hooks'; + +/** + * The **peer** network ID. + * Not used in signing, but needed to determine the parent of a subnet. + */ +enum PeerNetworkID { + Mainnet = 0x17000000, + Testnet = 0xff000000, +} + +export interface AddNetworkFormValues { + key: string; + name: string; + stacksUrl: string; + bitcoinUrl: string; +} + +const initialFormValues: AddNetworkFormValues = { + key: '', + name: '', + stacksUrl: '', + bitcoinUrl: '', +}; + +export function useAddNetwork() { + const [bitcoinApi, setBitcoinApi] = useState('mainnet'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + const network = useCurrentStacksNetworkState(); + const networksActions = useNetworksActions(); + + return { + bitcoinApi, + handleApiChange: (value: BitcoinNetworkModes) => setBitcoinApi(value), + error, + initialFormValues, + loading, + onSubmit: async (values: AddNetworkFormValues) => { + const { name, stacksUrl, bitcoinUrl, key } = values; + + if (!name) { + setError('Enter a name'); + return; + } + + if (!isValidUrl(stacksUrl)) { + setError('Enter a valid Stacks API URL'); + return; + } + + if (!isValidUrl(bitcoinUrl)) { + setError('Enter a valid Bitcoin API URL'); + return; + } + + if (!key) { + setError('Enter a unique key'); + return; + } + + setLoading(true); + setError(''); + + const stacksPath = removeTrailingSlash(new URL(values.stacksUrl).href); + const bitcoinPath = removeTrailingSlash(new URL(values.bitcoinUrl).href); + + try { + const bitcoinResponse = await network.fetchFn(`${bitcoinPath}/mempool/recent`); + if (!bitcoinResponse.ok) throw new Error('Unable to fetch mempool from bitcoin node'); + const bitcoinMempool = await bitcoinResponse.json(); + if (!Array.isArray(bitcoinMempool)) + throw new Error('Unable to fetch mempool from bitcoin node'); + } catch (error) { + setError('Unable to fetch mempool from bitcoin node'); + setLoading(false); + return; + } + + let stacksChainInfo: any; + try { + const stacksResponse = await network.fetchFn(`${stacksPath}/v2/info`); + stacksChainInfo = await stacksResponse.json(); + if (!stacksChainInfo) throw new Error('Unable to fetch info from stacks node'); + } catch (error) { + setError('Unable to fetch info from stacks node'); + setLoading(false); + return; + } + + // Attention: + // For mainnet/testnet the v2/info response `.network_id` refers to the chain ID + // For subnets the v2/info response `.network_id` refers to the network ID and the chain ID (they are the same for subnets) + // The `.parent_network_id` refers to the actual peer network ID in both cases + const { network_id: chainId, parent_network_id: parentNetworkId } = stacksChainInfo; + + const isSubnet = typeof stacksChainInfo.l1_subnet_governing_contract === 'string'; + const isFirstLevelSubnet = + isSubnet && + (parentNetworkId === PeerNetworkID.Mainnet || parentNetworkId === PeerNetworkID.Testnet); + + // Currently, only subnets of mainnet and testnet are supported in the wallet + if (isFirstLevelSubnet) { + const parentChainId = + parentNetworkId === PeerNetworkID.Mainnet ? ChainID.Mainnet : ChainID.Testnet; + networksActions.addNetwork({ + id: key as DefaultNetworkConfigurations, + name: name, + chainId: parentChainId, // Used for differentiating control flow in the wallet + subnetChainId: chainId, // Used for signing transactions (via the network object, not to be confused with the NetworkConfigurations) + url: stacksPath, + bitcoinNetwork: bitcoinApi, + bitcoinUrl: bitcoinPath, + }); + navigate(RouteUrls.Home); + } else if (chainId === ChainID.Mainnet || chainId === ChainID.Testnet) { + networksActions.addNetwork({ + id: key as DefaultNetworkConfigurations, + name: name, + chainId: chainId, + url: stacksPath, + bitcoinNetwork: bitcoinApi, + bitcoinUrl: bitcoinPath, + }); + navigate(RouteUrls.Home); + } else { + setError('Unable to determine chainID from node.'); + } + setLoading(false); + }, + }; +} diff --git a/src/app/features/settings/network/network.tsx b/src/app/features/settings/network/network.tsx index f17a5d29b22..a390bad5a7f 100644 --- a/src/app/features/settings/network/network.tsx +++ b/src/app/features/settings/network/network.tsx @@ -46,7 +46,7 @@ export function NetworkDialog({ isShowing, onClose }: DialogProps) { footer={