diff --git a/src/components/Error/container.tsx b/src/components/Error/container.tsx new file mode 100644 index 0000000..ee4b3df --- /dev/null +++ b/src/components/Error/container.tsx @@ -0,0 +1,25 @@ +import { useWidgetState } from "@/hooks/useWidgetState"; + +import { Error } from "./index"; + +interface ErrorContainer { + className?: string; +} + +export function ErrorContainer({ className }: ErrorContainer) { + const { screen } = useWidgetState(); + const { icon, title, description, cancelButton, submitButton, onCancel, onSubmit } = screen.params ?? {}; + + return ( + + ); +} diff --git a/src/components/Error/index.stories.tsx b/src/components/Error/index.stories.tsx new file mode 100644 index 0000000..6b863fc --- /dev/null +++ b/src/components/Error/index.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Error } from "./index"; + +const meta: Meta = { + component: Error, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + className: "", + icon: ( + + + + ), + title: "Public Key Mismatch", + description: + "The Bitcoin address and Public Key for this wallet do not match. Please contact your wallet provider for support.", + cancelButton: "Cancel", + submitButton: "Continue Anyway", + onCancel: () => console.log("cancel"), + onSubmit: () => console.log("submit"), + }, +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000..a35a2a8 --- /dev/null +++ b/src/components/Error/index.tsx @@ -0,0 +1,52 @@ +import { Button, DialogBody, DialogFooter, Heading, Text } from "@babylonlabs-io/bbn-core-ui"; +import {} from "react"; + +interface ErrorProps { + className?: string; + icon: JSX.Element; + title: string | JSX.Element; + description: string | JSX.Element; + cancelButton?: string | JSX.Element; + submitButton?: string | JSX.Element; + onCancel?: () => void; + onSubmit?: () => void; +} + +export function Error({ + className, + icon, + title, + description, + cancelButton = "Cancel", + submitButton = "Submit", + onCancel, + onSubmit, +}: ErrorProps) { + return ( +
+ +
+ {icon} +
+ + + {title} + + + + {description} + +
+ + + + + + +
+ ); +} diff --git a/src/components/WalletProvider/components/Screen.tsx b/src/components/WalletProvider/components/Screen.tsx index 3f21fa6..73ba4dd 100644 --- a/src/components/WalletProvider/components/Screen.tsx +++ b/src/components/WalletProvider/components/Screen.tsx @@ -1,6 +1,7 @@ import { type JSX } from "react"; import { ChainsContainer as Chains } from "@/components/Chains/container"; +import { ErrorContainer as Error } from "@/components/Error/container"; import { InscriptionsContainer as Inscriptions } from "@/components/Inscriptions/container"; import { LoaderScreen } from "@/components/Loader"; import { TermsOfServiceContainer as TermsOfService } from "@/components/TermsOfService/container"; @@ -37,6 +38,7 @@ const SCREENS = { LOADER: ({ className, current }: ScreenProps) => ( ), + ERROR: () => , EMPTY: ({ className }: ScreenProps) =>
, } as const; diff --git a/src/context/State.context.tsx b/src/context/State.context.tsx index 819369a..ec4ebe5 100644 --- a/src/context/State.context.tsx +++ b/src/context/State.context.tsx @@ -4,7 +4,7 @@ import type { IChain, IWallet } from "@/core/types"; export type Screen = { type: T; - params?: Record; + params?: Record; }; export type Screens = @@ -12,7 +12,8 @@ export type Screens = | Screen<"TERMS_OF_SERVICE"> | Screen<"CHAINS"> | Screen<"WALLETS"> - | Screen<"INSCRIPTIONS">; + | Screen<"INSCRIPTIONS"> + | Screen<"ERROR">; export interface State { confirmed: boolean; @@ -30,6 +31,15 @@ export interface Actions { displayWallets?: (chain: string) => void; displayInscriptions?: () => void; displayTermsOfService?: () => void; + displayError?: (params: { + icon: JSX.Element; + title: string; + description: string; + cancelButton?: string; + submitButton?: string; + onCancel?: () => void; + onSubmit?: () => void; + }) => void; selectWallet?: (chain: string, wallet: IWallet) => void; removeWallet?: (chain: string) => void; confirm?: () => void; @@ -91,6 +101,10 @@ export function StateProvider({ children, chains }: PropsWithChildren ({ ...state, screen: { type: "INSCRIPTIONS" } })); }, + displayError: (params) => { + setState((state) => ({ ...state, screen: { type: "ERROR", params } })); + }, + selectWallet: (chain: string, wallet: IWallet) => { setState((state) => ({ ...state, diff --git a/src/core/utils/wallet.ts b/src/core/utils/wallet.ts index 7e9f208..dddfb0e 100644 --- a/src/core/utils/wallet.ts +++ b/src/core/utils/wallet.ts @@ -1,32 +1,109 @@ -import { networks } from "bitcoinjs-lib"; +import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; +import { initEccLib, networks, payments } from "bitcoinjs-lib"; +import { toXOnly } from "bitcoinjs-lib/src/psbt/bip371"; import { Network } from "@/core/types"; +export const COMPRESSED_PUBLIC_KEY_HEX_LENGTH = 66; -export function validateAddress(network: Network, address: string): void { - if (network === Network.MAINNET && !address.startsWith("bc1")) { - throw new Error("Incorrect address prefix for Mainnet. Expected address to start with 'bc1'."); +initEccLib(ecc); + +const NETWORKS = { + [Network.MAINNET]: { + name: "Mainnet", + config: networks.bitcoin, + prefix: { + common: "bc1", + nativeSegWit: "bc1q", + taproot: "bc1p", + }, + }, + [Network.CANARY]: { + name: "Canary", + config: networks.bitcoin, + prefix: { + common: "bc1", + nativeSegWit: "bc1q", + taproot: "bc1p", + }, + }, + [Network.TESTNET]: { + name: "Testnet", + config: networks.testnet, + prefix: { + common: "tb1", + nativeSegWit: "tb1q", + taproot: "tb1p", + }, + }, + [Network.SIGNET]: { + name: "Signet", + config: networks.testnet, + prefix: { + common: "tb1", + nativeSegWit: "tb1q", + taproot: "tb1p", + }, + }, +}; + +export const getTaprootAddress = (publicKey: string, network: Network) => { + if (publicKey.length == COMPRESSED_PUBLIC_KEY_HEX_LENGTH) { + publicKey = publicKey.slice(2); } - if (network === Network.CANARY && !address.startsWith("bc1")) { - throw new Error("Incorrect address prefix for Canary. Expected address to start with 'bc1'."); + + const internalPubkey = Buffer.from(publicKey, "hex"); + const { address, output: scriptPubKey } = payments.p2tr({ + internalPubkey: toXOnly(internalPubkey), + network: NETWORKS[network].config, + }); + + if (!address || !scriptPubKey) { + throw new Error("Failed to generate taproot address or script from public key"); } - if ((network === Network.TESTNET || network === Network.SIGNET) && !address.startsWith("tb1")) { - throw new Error("Incorrect address prefix for Testnet/Signet. Expected address to start with 'tb1'."); + + return address; +}; + +export const getNativeSegwitAddress = (publicKey: string, network: Network) => { + if (publicKey.length !== COMPRESSED_PUBLIC_KEY_HEX_LENGTH) { + throw new Error("Invalid public key length for generating native segwit address"); } - if (![Network.MAINNET, Network.SIGNET, Network.TESTNET, Network.CANARY].includes(network)) { - throw new Error(`Unsupported network: ${network}. Please provide a valid network.`); + const internalPubkey = Buffer.from(publicKey, "hex"); + const { address, output: scriptPubKey } = payments.p2wpkh({ + pubkey: internalPubkey, + network: NETWORKS[network].config, + }); + + if (!address || !scriptPubKey) { + throw new Error("Failed to generate native segwit address or script from public key"); + } + + return address; +}; + +export function validateAddressWithPK(address: string, publicKey: string, network: Network) { + if (address.startsWith(NETWORKS[network].prefix.taproot)) { + return address === getTaprootAddress(publicKey, network); + } + + if (address.startsWith(NETWORKS[network].prefix.nativeSegWit)) { + return address === getNativeSegwitAddress(publicKey, network); } + + return false; } -export const toNetwork = (network: Network): networks.Network => { - switch (network) { - case Network.MAINNET: - case Network.CANARY: - return networks.bitcoin; - case Network.TESTNET: - case Network.SIGNET: - return networks.testnet; - default: - throw new Error("Unsupported network"); +export function validateAddress(network: Network, address: string): void { + const { prefix, name } = NETWORKS[network]; + + if (!(network in NETWORKS)) { + throw new Error(`Unsupported network: ${network}. Please provide a valid network.`); } -}; + + if (!address.startsWith(prefix.common)) { + throw new Error(`Incorrect address prefix for ${name}. Expected address to start with '${prefix}'.`); + } +} + +export const toNetwork = (network: Network): networks.Network => NETWORKS[network].config; diff --git a/src/hooks/useWalletConnectors.tsx b/src/hooks/useWalletConnectors.tsx index c7f0089..553885f 100644 --- a/src/hooks/useWalletConnectors.tsx +++ b/src/hooks/useWalletConnectors.tsx @@ -3,12 +3,14 @@ import { useCallback, useEffect } from "react"; import { useChainProviders } from "@/context/Chain.context"; import { useInscriptionProvider } from "@/context/Inscriptions.context"; import { IChain, IWallet } from "@/core/types"; +import { validateAddressWithPK } from "@/core/utils/wallet"; import { useWidgetState } from "./useWidgetState"; export function useWalletConnectors(onError?: (e: Error) => void) { const connectors = useChainProviders(); - const { selectWallet, removeWallet, displayLoader, displayChains, displayInscriptions } = useWidgetState(); + const { selectWallet, removeWallet, displayLoader, displayChains, displayInscriptions, displayError } = + useWidgetState(); const { showAgain } = useInscriptionProvider(); // Connecting event @@ -28,22 +30,60 @@ export function useWalletConnectors(onError?: (e: Error) => void) { useEffect(() => { const connectorArr = Object.values(connectors); - const unsubscribeArr = connectorArr.filter(Boolean).map((connector) => - connector.on("connect", (connectedWallet: IWallet) => { + const handlers: Record (connectedWallet: IWallet) => void> = { + BTC: (connector) => (connectedWallet) => { if (connectedWallet) { - selectWallet?.(connector.id, connectedWallet); + selectWallet?.("BTC", connectedWallet); } - if (showAgain && connector.id === "BTC") { - displayInscriptions?.(); + const goToNextScreen = () => void (showAgain ? displayInscriptions?.() : displayChains?.()); + + if ( + validateAddressWithPK( + connectedWallet.account?.address ?? "", + connectedWallet.account?.publicKeyHex ?? "", + connector.config.network, + ) + ) { + goToNextScreen(); } else { - displayChains?.(); + displayError?.({ + icon: ( + + + + ), + title: "Public Key Mismatch", + description: + "The Bitcoin address and Public Key for this wallet do not match. Please contact your wallet provider for support.", + cancelButton: "Cancel", + submitButton: "Continue Anyway", + onSubmit: goToNextScreen, + onCancel: () => { + removeWallet?.(connector.id); + displayChains?.(); + }, + }); } - }), - ); + }, + BBN: (connector) => (connectedWallet) => { + if (connectedWallet) { + selectWallet?.(connector.id, connectedWallet); + } + + displayChains?.(); + }, + }; + + const unsubscribeArr = connectorArr + .filter(Boolean) + .map((connector) => connector.on("connect", handlers[connector.id]?.(connector))); return () => unsubscribeArr.forEach((unsubscribe) => unsubscribe()); - }, [selectWallet, displayInscriptions, displayChains, connectors, showAgain]); + }, [selectWallet, removeWallet, displayInscriptions, displayChains, connectors, showAgain]); // Disconnect Event useEffect(() => {