diff --git a/eslint.config.js b/eslint.config.js index 761863e..15a6f97 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,5 +24,8 @@ export default tseslint.config( "react-hooks": reactHooks, "react-refresh": reactRefresh, }, + rules: { + "@typescript-eslint/no-explicit-any": 0, + }, }, ); diff --git a/package-lock.json b/package-lock.json index 20847f2..8606529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "bbn-wallet-connect", "version": "0.0.0", "dependencies": { - "react-dom": "^18.3.1", "react-icons": "^5.3.0" }, "devDependencies": { @@ -21,6 +20,7 @@ "@storybook/react": "^8.4.2", "@storybook/react-vite": "^8.4.2", "@storybook/test": "^8.4.2", + "@types/bitcoinjs-lib": "^5.0.4", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -47,6 +47,7 @@ }, "peerDependencies": { "react": "^18.3.1", + "react-dom": "^18.3.1", "tailwind-merge": "^2.5.4" } }, @@ -1220,6 +1221,18 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/@noble/hashes": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2352,6 +2365,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bitcoinjs-lib": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/bitcoinjs-lib/-/bitcoinjs-lib-5.0.4.tgz", + "integrity": "sha512-4IXPR8tIDNZPsWk6TQxOpbZnpZsoRCuwuUzlqw8aO1hQEDi1J5x46+HlI4Xh7ECmdoIwnAB8bGvTdnVuBSDZXQ==", + "deprecated": "This is a stub types definition. bitcoinjs-lib provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "bitcoinjs-lib": "*" + } + }, "node_modules/@types/doctrine": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", @@ -3126,6 +3149,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "dev": true + }, "node_modules/better-opn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", @@ -3150,6 +3179,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bip174": { + "version": "3.0.0-rc.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0-rc.1.tgz", + "integrity": "sha512-+8P3BpSairVNF2Nee6Ksdc1etIjWjBOi/MH0MwKtq9YaYp+S2Hk2uvup0e8hCT4IKlS58nXJyyQVmW92zPoD4Q==", + "dev": true, + "dependencies": { + "uint8array-tools": "^0.0.9", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.0-rc.0.tgz", + "integrity": "sha512-7CQgOIbREemKR/NT2uc3uO/fkEy+6CM0sLxboVVY6bv6DbZmPt3gg5Y/hhWgQFeZu5lfTbtVAv32MIxf7lMh4g==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^3.0.0-rc.0", + "bs58check": "^4.0.0", + "uint8array-tools": "^0.0.9", + "valibot": "^0.38.0", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==", + "dev": true + }, + "node_modules/bitcoinjs-lib/node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "dev": true, + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6946,6 +7031,15 @@ "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", "dev": true }, + "node_modules/uint8array-tools": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz", + "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -7053,6 +7147,38 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valibot": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.38.0.tgz", + "integrity": "sha512-RCJa0fetnzp+h+KN9BdgYOgtsMAG9bfoJ9JSjIhFHobKWVWyzM3jjaeNTdpFK9tQtf3q1sguXeERJ/LcmdFE7w==", + "dev": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/varuint-bitcoin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz", + "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==", + "dev": true, + "dependencies": { + "uint8array-tools": "^0.0.8" + } + }, + "node_modules/varuint-bitcoin/node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vite": { "version": "5.4.10", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", diff --git a/package.json b/package.json index d8ee352..8993503 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@storybook/react": "^8.4.2", "@storybook/react-vite": "^8.4.2", "@storybook/test": "^8.4.2", + "@types/bitcoinjs-lib": "^5.0.4", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/src/core/Wallet.ts b/src/core/Wallet.ts new file mode 100644 index 0000000..5e6029f --- /dev/null +++ b/src/core/Wallet.ts @@ -0,0 +1,93 @@ +import { IWallet, Network, IProvider, type NetworkConfig, type WalletMetadata } from "@/core/types"; + +interface Options

{ + id: string; + name: string; + icon: string; + docs: string; + networks: Network[]; + origin: any; + provider: P | null; +} + +const defaultWalletGetter = (key: string) => (context: any) => context[key]; + +export class Wallet

implements IWallet { + readonly id: string; + readonly origin: any; + readonly name: string; + readonly icon: string; + readonly docs: string; + readonly networkds: Network[]; + readonly provider: P | null = null; + + static create = async

(metadata: WalletMetadata

, context: any, config: NetworkConfig) => { + const { + id, + wallet: walletGetter, + name: nameGetter, + icon: iconGetter, + docs = "", + networks = [], + createProvider, + } = metadata; + + const options: Options

= { + id, + name: "", + icon: "", + origin: null, + provider: null, + docs, + networks, + }; + + if (walletGetter) { + const getWallet = typeof walletGetter === "string" ? defaultWalletGetter(walletGetter) : walletGetter; + + options.origin = getWallet(context, config) ?? null; + options.provider = options.origin ? createProvider(options.origin, config) : null; + } else { + options.origin = null; + options.provider = createProvider(null, config); + } + + if (typeof nameGetter === "string") { + options.name = nameGetter ?? ""; + } else { + options.name = options.origin ? await nameGetter(options.origin, config) : ""; + } + + if (typeof iconGetter === "string") { + options.icon = iconGetter ?? ""; + } else { + options.icon = options.origin ? await iconGetter(options.origin, config) : ""; + } + + return new Wallet(options); + }; + + constructor({ id, origin, name, icon, docs, networks, provider }: Options

) { + this.id = id; + this.origin = origin; + this.name = name; + this.icon = icon; + this.docs = docs; + this.networkds = networks; + this.provider = provider; + } + + get installed() { + return Boolean(this.provider); + } + + async connect() { + if (!this.provider) { + throw Error("Provider not found"); + } + + await this.provider.connectWallet(); + + return this; + } +} diff --git a/src/core/WalletConnector.ts b/src/core/WalletConnector.ts new file mode 100644 index 0000000..d6e3f63 --- /dev/null +++ b/src/core/WalletConnector.ts @@ -0,0 +1,34 @@ +import { Wallet } from "@/core/Wallet"; +import type { NetworkConfig, IProvider, IChain, ConnectMetadata } from "@/core/types"; + +export class WalletConnector

implements IChain { + connectedWallet: Wallet

| null = null; + + static async create

( + metadata: ConnectMetadata

, + config: NetworkConfig, + context: any, + ): Promise> { + const wallets: Wallet

[] = []; + + for (const walletMetadata of metadata.wallets) { + wallets.push(await Wallet.create(walletMetadata, context, config)); + } + + return new WalletConnector(metadata.chain, metadata.icon, wallets); + } + + constructor( + public readonly chain: string, + public readonly icon: string, + public readonly wallets: Wallet

[], + ) {} + + async connect(name: string) { + const wallet = this.wallets.find((wallet) => wallet.name.toLowerCase() === name.toLowerCase()); + + this.connectedWallet = (await wallet?.connect()) ?? null; + + return this.connectedWallet; + } +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..2a9f2b2 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,86 @@ +export type Fees = { + // fee for inclusion in the next block + fastestFee: number; + // fee for inclusion in a block in 30 mins + halfHourFee: number; + // fee for inclusion in a block in 1 hour + hourFee: number; + // economy fee: inclusion not guaranteed + economyFee: number; + // minimum fee: the minimum fee of the network + minimumFee: number; +}; + +// UTXO is a structure defining attributes for a UTXO +export interface UTXO { + // hash of transaction that holds the UTXO + txid: string; + // index of the output in the transaction + vout: number; + // amount of satoshis the UTXO holds + value: number; + // the script that the UTXO contains + scriptPubKey: string; +} + +export interface InscriptionIdentifier { + // hash of transaction that holds the ordinals/brc-2-/runes etc in the UTXO + txid: string; + // index of the output in the transaction + vout: number; +} +// supported networks +export enum Network { + MAINNET = "mainnet", + TESTNET = "testnet", + SIGNET = "signet", +} + +// WalletInfo is a structure defining attributes for a wallet +export type WalletInfo = { + publicKeyHex: string; + address: string; +}; + +export interface NetworkConfig { + coinName: string; + coinSymbol: string; + networkName: string; + mempoolApiUrl: string; + network: Network; +} + +export interface IProvider { + connectWallet: () => Promise; +} + +export interface IWallet { + id: string; + name: string; + icon: string; + docs: string; + installed: boolean; + provider: IProvider | null; +} + +export interface IChain { + chain: string; + icon: string; + wallets: IWallet[]; +} + +export interface WalletMetadata

{ + id: string; + wallet?: string | ((context: any, config: NetworkConfig) => any); + name: string | ((wallet: any, config: NetworkConfig) => Promise); + icon: string | ((wallet: any, config: NetworkConfig) => Promise); + docs: string; + networks: Network[]; + createProvider: (wallet: any, config: NetworkConfig) => P; +} + +export interface ConnectMetadata

{ + chain: string; + icon: string; + wallets: WalletMetadata

[]; +} diff --git a/src/core/utils/mempool.ts b/src/core/utils/mempool.ts new file mode 100644 index 0000000..b17d908 --- /dev/null +++ b/src/core/utils/mempool.ts @@ -0,0 +1,216 @@ +import type { Fees, UTXO } from "@/core/types"; + +export interface MempoolApi { + pushTx(txHex: string): Promise; + getAddressBalance(address: string): Promise; + getNetworkFees(): Promise; + getTipHeight(): Promise; + getFundingUTXOs(address: string, amount?: number): Promise; + getTxInfo(txId: string): Promise; +} + +/* + URL Construction methods +*/ +// The base URL for the signet API +// Utilises an environment variable specifying the mempool API we intend to +// utilise +export function createMempoolAPI(mempoolApiUrl: string): MempoolApi { + const mempoolAPI = `${mempoolApiUrl}/api/`; + + // URL for the address info endpoint + function addressInfoUrl(address: string): URL { + return new URL(mempoolAPI + "address/" + address); + } + + // URL for the push transaction endpoint + function pushTxUrl(): URL { + return new URL(mempoolAPI + "tx"); + } + + // URL for retrieving information about an address' UTXOs + function utxosInfoUrl(address: string): URL { + return new URL(mempoolAPI + "address/" + address + "/utxo"); + } + + // URL for retrieving information about the recommended network fees + function networkFeesUrl(): URL { + return new URL(mempoolAPI + "v1/fees/recommended"); + } + + // URL for retrieving the tip height of the BTC chain + function btcTipHeightUrl(): URL { + return new URL(mempoolAPI + "blocks/tip/height"); + } + + // URL for validating an address which contains a set of information about the address + // including the scriptPubKey + function validateAddressUrl(address: string): URL { + return new URL(mempoolAPI + "v1/validate-address/" + address); + } + + // URL for the transaction info endpoint + function txInfoUrl(txId: string): URL { + return new URL(mempoolAPI + "tx/" + txId); + } + + /** + * Pushes a transaction to the Bitcoin network. + * @param txHex - The hex string corresponding to the full transaction. + * @returns A promise that resolves to the response message. + */ + async function pushTx(txHex: string): Promise { + const response = await fetch(pushTxUrl(), { + method: "POST", + body: txHex, + }); + if (!response.ok) { + try { + const mempoolError = await response.text(); + // Extract the error message from the response + const message = mempoolError.split('"message":"')[1].split('"}')[0]; + if (mempoolError.includes("error") || mempoolError.includes("message")) { + throw new Error(message); + } else { + throw new Error("Error broadcasting transaction. Please try again"); + } + } catch (error: Error | any) { + throw new Error(error?.message || error); + } + } else { + return await response.text(); + } + } + + /** + * Returns the balance of an address. + * @param address - The Bitcoin address in string format. + * @returns A promise that resolves to the amount of satoshis that the address + * holds. + */ + async function getAddressBalance(address: string): Promise { + const response = await fetch(addressInfoUrl(address)); + if (!response.ok) { + const err = await response.text(); + throw new Error(err); + } else { + const addressInfo = await response.json(); + return addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum; + } + } + + /** + * Retrieve the recommended Bitcoin network fees. + * @returns A promise that resolves into a `Fees` object. + */ + async function getNetworkFees(): Promise { + const response = await fetch(networkFeesUrl()); + if (!response.ok) { + const err = await response.text(); + throw new Error(err); + } else { + return await response.json(); + } + } + // Get the tip height of the BTC chain + async function getTipHeight(): Promise { + const response = await fetch(btcTipHeightUrl()); + const result = await response.text(); + if (!response.ok) { + throw new Error(result); + } + const height = Number(result); + if (Number.isNaN(height)) { + throw new Error("Invalid result returned"); + } + return height; + } + + /** + * Retrieve a set of UTXOs that are available to an address + * and satisfy the `amount` requirement if provided. Otherwise, fetch all UTXOs. + * The UTXOs are chosen based on descending amount order. + * @param address - The Bitcoin address in string format. + * @param amount - The amount we expect the resulting UTXOs to satisfy. + * @returns A promise that resolves into a list of UTXOs. + */ + async function getFundingUTXOs(address: string, amount?: number): Promise { + // Get all UTXOs for the given address + + let utxos = null; + try { + const response = await fetch(utxosInfoUrl(address)); + utxos = await response.json(); + } catch (error: Error | any) { + throw new Error(error?.message || error); + } + + // Remove unconfirmed UTXOs as they are not yet available for spending + // and sort them in descending order according to their value. + // We want them in descending order, as we prefer to find the least number + // of inputs that will satisfy the `amount` requirement, + // as less inputs lead to a smaller transaction and therefore smaller fees. + const confirmedUTXOs = (utxos || []) + .filter((utxo: any) => utxo.status.confirmed) + .sort((a: any, b: any) => b.value - a.value); + + // If amount is provided, reduce the list of UTXOs into a list that + // contains just enough UTXOs to satisfy the `amount` requirement. + let sliced = confirmedUTXOs; + if (amount) { + let sum = 0; + let i; + for (i = 0; i < confirmedUTXOs.length; ++i) { + sum += confirmedUTXOs[i].value; + if (sum > amount) { + break; + } + } + if (sum < amount) { + return []; + } + sliced = confirmedUTXOs.slice(0, i + 1); + } + + const response = await fetch(validateAddressUrl(address)); + const addressInfo = await response.json(); + const { isvalid, scriptPubKey } = addressInfo; + if (!isvalid) { + throw new Error("Invalid address"); + } + + // Iterate through the final list of UTXOs to construct the result list. + // The result contains some extra information, + return sliced.map((s: any) => { + return { + txid: s.txid, + vout: s.vout, + value: s.value, + scriptPubKey, + }; + }); + } + + /** + * Retrieve information about a transaction. + * @param txId - The transaction ID in string format. + * @returns A promise that resolves into the transaction information. + */ + async function getTxInfo(txId: string): Promise { + const response = await fetch(txInfoUrl(txId)); + if (!response.ok) { + const err = await response.text(); + throw new Error(err); + } + return await response.json(); + } + + return { + pushTx, + getAddressBalance, + getNetworkFees, + getTipHeight, + getFundingUTXOs, + getTxInfo, + }; +} diff --git a/src/core/utils/wallet.ts b/src/core/utils/wallet.ts new file mode 100644 index 0000000..9289faf --- /dev/null +++ b/src/core/utils/wallet.ts @@ -0,0 +1,11 @@ +import { Network } from "@/core/types"; + +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'."); + } else if ([Network.SIGNET, Network.TESTNET].includes(network) && !address.startsWith("tb1")) { + throw new Error("Incorrect address prefix for Testnet / Signet. Expected address to start with 'tb1'."); + } else if (![Network.MAINNET, Network.SIGNET, Network.TESTNET].includes(network)) { + throw new Error(`Unsupported network: ${network}. Please provide a valid network.`); + } +} diff --git a/src/core/wallets/btc/BTCProvider.ts b/src/core/wallets/btc/BTCProvider.ts new file mode 100644 index 0000000..98e7965 --- /dev/null +++ b/src/core/wallets/btc/BTCProvider.ts @@ -0,0 +1,112 @@ +import type { Fees, InscriptionIdentifier, Network, NetworkConfig, UTXO, Provider } from "../../types"; +import { createMempoolAPI, MempoolApi } from "../../utils/mempool"; + +/** + * Abstract class representing a wallet provider. + * Provides methods for connecting to a wallet, retrieving wallet information, signing transactions, and more. + */ +export abstract class BTCProvider implements Provider { + protected mempool: MempoolApi; + + constructor(protected config: NetworkConfig) { + this.mempool = createMempoolAPI(this.config.mempoolApiUrl); + } + /** + * Connects to the wallet and returns the instance of the wallet provider. + * Currently only supports "native segwit" and "taproot" address types. + * @returns A promise that resolves to an instance of the wrapper wallet provider in babylon friendly format. + * @throws An error if the wallet is not installed or if connection fails. + */ + abstract connectWallet(): Promise; + + /** + * Gets the address of the connected wallet. + * @returns A promise that resolves to the address of the connected wallet. + */ + abstract getAddress(): Promise; + + /** + * Gets the public key of the connected wallet. + * @returns A promise that resolves to the public key of the connected wallet. + */ + abstract getPublicKeyHex(): Promise; + + /** + * Signs the given PSBT in hex format. + * @param psbtHex - The hex string of the unsigned PSBT to sign. + * @returns A promise that resolves to the hex string of the signed PSBT. + */ + abstract signPsbt(psbtHex: string): Promise; + + /** + * Signs multiple PSBTs in hex format. + * @param psbtsHexes - The hex strings of the unsigned PSBTs to sign. + * @returns A promise that resolves to an array of hex strings, each representing a signed PSBT. + */ + abstract signPsbts(psbtsHexes: string[]): Promise; + + /** + * Gets the network of the current account. + * @returns A promise that resolves to the network of the current account. + */ + abstract getNetwork(): Promise; + + /** + * Signs a message using BIP-322 simple. + * @param message - The message to sign. + * @returns A promise that resolves to the signed message. + */ + abstract signMessageBIP322(message: string): Promise; + + /** + * Registers an event listener for the specified event. + * At the moment, only the "accountChanged" event is supported. + * @param eventName - The name of the event to listen for. + * @param callBack - The callback function to be executed when the event occurs. + */ + abstract on(eventName: string, callBack: () => void): void; + + /** + * Gets the balance for the connected wallet address. + * By default, this method will return the mempool balance if not implemented by the child class. + * @returns A promise that resolves to the balance of the wallet. + */ + abstract getBalance(): Promise; + + /** + * Retrieves the network fees. + * @returns A promise that resolves to the network fees. + */ + abstract getNetworkFees(): Promise; + + /** + * Pushes a transaction to the network. + * @param txHex - The hexadecimal representation of the transaction. + * @returns A promise that resolves to a string representing the transaction ID. + */ + abstract pushTx(txHex: string): Promise; + + /** + * Retrieves the unspent transaction outputs (UTXOs) for a given address and amount. + * + * If the amount is provided, it will return UTXOs that cover the specified amount. + * If the amount is not provided, it will return all available UTXOs for the address. + * + * @param address - The address to retrieve UTXOs for. + * @param amount - Optional amount of funds required. + * @returns A promise that resolves to an array of UTXOs. + */ + abstract getUtxos(address: string, amount?: number): Promise; + + /** + * Retrieves the tip height of the BTC chain. + * @returns A promise that resolves to the block height. + */ + abstract getBTCTipHeight(): Promise; + + /** + * Retrieves the inscriptions for the connected wallet. + * @returns A promise that resolves to an array of inscriptions. + */ + abstract getInscriptions(): Promise; +} diff --git a/src/core/wallets/btc/index.ts b/src/core/wallets/btc/index.ts new file mode 100644 index 0000000..a0a84de --- /dev/null +++ b/src/core/wallets/btc/index.ts @@ -0,0 +1,14 @@ +import { BTCProvider } from "./BTCProvider"; + +import injectable from "./injectable"; +import okx from "./okx"; + +import { ConnectMetadata } from "@/core/types"; + +const metadata: ConnectMetadata = { + chain: "BTC", + icon: "test", + wallets: [injectable, okx], +}; + +export default metadata; diff --git a/src/core/wallets/btc/injectable/index.ts b/src/core/wallets/btc/injectable/index.ts new file mode 100644 index 0000000..7a6ae2d --- /dev/null +++ b/src/core/wallets/btc/injectable/index.ts @@ -0,0 +1,14 @@ +import { Network, type WalletMetadata } from "@/core/types"; +import { BTCProvider } from "../BTCProvider"; + +const metadata: WalletMetadata = { + id: "injectable", + name: (wallet) => wallet.getWalletProviderName?.(), + icon: (wallet) => wallet.getWalletProviderIcon?.(), + docs: "", + wallet: "btcwallet", + createProvider: (wallet) => wallet, + networks: [Network.MAINNET, Network.SIGNET], +}; + +export default metadata; diff --git a/src/core/wallets/btc/okx/index.ts b/src/core/wallets/btc/okx/index.ts new file mode 100644 index 0000000..d9610f4 --- /dev/null +++ b/src/core/wallets/btc/okx/index.ts @@ -0,0 +1,17 @@ +import { Network, type WalletMetadata } from "@/core/types"; + +import logo from "./logo.svg"; +import type { BTCProvider } from "../BTCProvider"; +import { OKXProvider } from "./provider"; + +const metadata: WalletMetadata = { + id: "okx", + name: "OKX", + icon: logo, + docs: "https://www.okx.com/web3", + wallet: "okxwallet", + createProvider: (wallet, config) => new OKXProvider(wallet, config), + networks: [Network.MAINNET, Network.SIGNET], +}; + +export default metadata; diff --git a/src/core/wallets/btc/okx/logo.svg b/src/core/wallets/btc/okx/logo.svg new file mode 100644 index 0000000..277f419 --- /dev/null +++ b/src/core/wallets/btc/okx/logo.svg @@ -0,0 +1,13 @@ + + + okx + + + + + + + + + + \ No newline at end of file diff --git a/src/core/wallets/btc/okx/provider.ts b/src/core/wallets/btc/okx/provider.ts new file mode 100644 index 0000000..ddcd01e --- /dev/null +++ b/src/core/wallets/btc/okx/provider.ts @@ -0,0 +1,192 @@ +import { BTCProvider } from "@/core/wallets/btc/BTCProvider"; +import { validateAddress } from "@/core/utils/wallet"; +import type { Fees, InscriptionIdentifier, NetworkConfig, UTXO, WalletInfo } from "@/core/types"; +import { Network } from "@/core/types"; + +const PROVIDER_NAMES = { + [Network.MAINNET]: "bitcoin", + [Network.TESTNET]: "bitcoinTestnet", + [Network.SIGNET]: "bitcoinSignet", +}; + +export class OKXProvider extends BTCProvider { + private provider: any; + private walletInfo: WalletInfo | undefined; + + constructor( + private wallet: any, + config: NetworkConfig, + ) { + super(config); + + // check whether there is an OKX Wallet extension + if (!wallet) { + throw new Error("OKX Wallet extension not found"); + } + + const providerName = PROVIDER_NAMES[config.network]; + + if (!providerName) { + throw new Error("Unsupported network"); + } + + this.provider = wallet[providerName]; + } + + connectWallet = async (): Promise => { + try { + await this.wallet.enable(); // Connect to OKX Wallet extension + } catch (error) { + if ((error as Error)?.message?.includes("rejected")) { + throw new Error("Connection to OKX Wallet was rejected"); + } else { + throw new Error((error as Error)?.message); + } + } + let result; + try { + // this will not throw an error even if user has no network enabled + result = await this.provider.connect(); + } catch { + throw new Error(`BTC ${this.config.network} is not enabled in OKX Wallet`); + } + + const { address, compressedPublicKey } = result; + + validateAddress(this.config.network, address); + + if (compressedPublicKey && address) { + this.walletInfo = { + publicKeyHex: compressedPublicKey, + address, + }; + return this; + } else { + throw new Error("Could not connect to OKX Wallet"); + } + }; + + getWalletProviderName = async (): Promise => { + return "OKX"; + }; + + getAddress = async (): Promise => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + return this.walletInfo.address; + }; + + getPublicKeyHex = async (): Promise => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + return this.walletInfo.publicKeyHex; + }; + + signPsbt = async (psbtHex: string): Promise => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + // Use signPsbt since it shows the fees + return await this.provider.signPsbt(psbtHex); + }; + + signPsbts = async (psbtsHexes: string[]): Promise => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + // sign the PSBTs + return await this.provider.signPsbts(psbtsHexes); + }; + + signMessageBIP322 = async (message: string): Promise => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + return await this.provider.signMessage(message, "bip322-simple"); + }; + + getNetwork = async (): Promise => { + // OKX does not provide a way to get the network for Signet and Testnet + // So we pass the check on connection and return the environment network + if (!this.config.network) { + throw new Error("Network not set"); + } + return this.config.network; + }; + + on = (eventName: string, callBack: () => void) => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + // subscribe to account change event + if (eventName === "accountChanged") { + return this.provider.on(eventName, callBack); + } + }; + + // Mempool calls + getBalance = async (): Promise => { + return await this.mempool.getAddressBalance(await this.getAddress()); + }; + + getNetworkFees = async (): Promise => { + return await this.mempool.getNetworkFees(); + }; + + pushTx = async (txHex: string): Promise => { + return await this.mempool.pushTx(txHex); + }; + + getUtxos = async (address: string, amount: number): Promise => { + // mempool call + return await this.mempool.getFundingUTXOs(address, amount); + }; + + getBTCTipHeight = async (): Promise => { + return await this.mempool.getTipHeight(); + }; + + // Inscriptions are only available on OKX Wallet BTC mainnet (i.e okxWallet.bitcoin) + getInscriptions = async (): Promise => { + if (!this.walletInfo) { + throw new Error("OKX Wallet not connected"); + } + if (this.config.network !== Network.MAINNET) { + throw new Error("Inscriptions are only available on OKX Wallet BTC mainnet"); + } + // max num of iterations to prevent infinite loop + const MAX_ITERATIONS = 100; + // Fetch inscriptions in batches of 100 + const limit = 100; + const inscriptionIdentifiers: InscriptionIdentifier[] = []; + let cursor = 0; + let iterations = 0; + try { + while (iterations < MAX_ITERATIONS) { + const { list } = await this.provider.getInscriptions(cursor, limit); + const identifiers = list.map((i: { output: string }) => { + const [txid, vout] = i.output.split(":"); + return { + txid, + vout, + }; + }); + inscriptionIdentifiers.push(...identifiers); + if (list.length < limit) { + break; + } + cursor += limit; + iterations++; + if (iterations >= MAX_ITERATIONS) { + throw new Error("Exceeded maximum iterations when fetching inscriptions"); + } + } + } catch { + throw new Error("Failed to get inscriptions from OKX Wallet"); + } + + return inscriptionIdentifiers; + }; +} diff --git a/src/state/state.d.ts b/src/state/state.d.ts new file mode 100644 index 0000000..e8a0498 --- /dev/null +++ b/src/state/state.d.ts @@ -0,0 +1,20 @@ +import type { IChain, IWalllet } from "@/core/types"; + +type Step = "loading" | "acceptTermsOfService" | "selectChain" | "selectWallet" | "lockInscriptions"; + +export interface State { + visible: boolean; + loading: boolean; + step: Step; + selectedWallets: Record; + visibleWallets: string; + chains: Record; + displayTermsOfService?: () => void; + displayChains?: () => void; + displayWallets?: (chain: string) => void; + selectWallet?: (chain: string, wallet: IWalllet) => void; + addChain?: (chain: IChain) => void; + setLoading?: (value: boolean) => void; + open?: () => void; + close?: () => void; +} diff --git a/src/state/state.tsx b/src/state/state.tsx new file mode 100644 index 0000000..d13d6cc --- /dev/null +++ b/src/state/state.tsx @@ -0,0 +1,73 @@ +import { type PropsWithChildren, createContext, useCallback, useMemo, useState } from "react"; + +import { type State } from "./state.d"; +import { IChain, IWallet } from "@/core/types"; + +const defaultState: State = { + visible: true, + loading: true, + step: "selectChain", + chains: {}, + selectedWallets: {}, + visibleWallets: "", +}; + +const StateContext = createContext(defaultState); + +export function StateProvider({ children }: PropsWithChildren) { + const [state, setState] = useState(defaultState); + + const open = useCallback(() => { + setState((state) => ({ ...state, visible: true })); + }, []); + + const close = useCallback(() => { + setState((state) => ({ ...state, visible: false })); + }, []); + + const displayTermsOfService = useCallback(() => { + setState((state) => ({ ...state, step: "acceptTermsOfService" })); + }, []); + + const displayChains = useCallback(() => { + setState((state) => ({ ...state, step: "selectChain", visibleWallets: "" })); + }, []); + + const displayWallets = useCallback((chain: string) => { + setState((state) => ({ ...state, step: "selectWallet", visibleWallets: chain })); + }, []); + + const selectWallet = useCallback((chain: string, wallet: IWallet) => { + setState((state) => ({ + ...state, + step: "lockInscriptions", + visibleWallets: "", + selectedWallets: { ...state.selectedWallets, [chain]: wallet }, + })); + }, []); + + const addChain = useCallback((chainInfo: IChain) => { + setState((state) => ({ ...state, chains: { ...state.chains, [chainInfo.chain]: chainInfo } })); + }, []); + + const setLoading = useCallback((loading: boolean) => { + setState((state) => ({ ...state, loading })); + }, []); + + const context = useMemo( + () => ({ + ...state, + displayTermsOfService, + displayChains, + displayWallets, + selectWallet, + addChain, + setLoading, + open, + close, + }), + [state], + ); + + return {children}; +} diff --git a/src/widgets/Widget/index.stories.tsx b/src/widgets/Widget/index.stories.tsx new file mode 100644 index 0000000..9024572 --- /dev/null +++ b/src/widgets/Widget/index.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Widget } from "./index"; + +const meta: Meta = { + component: Widget, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/widgets/Widget/index.tsx b/src/widgets/Widget/index.tsx new file mode 100644 index 0000000..c0a9179 --- /dev/null +++ b/src/widgets/Widget/index.tsx @@ -0,0 +1,25 @@ +import { Network } from "@/core/types"; +import { WalletConnector } from "@/core/WalletConnector"; +import btc from "@/core/wallets/btc"; +import { useEffect } from "react"; + +export function Widget() { + useEffect(() => { + WalletConnector.create( + btc, + { + coinName: "Signet BTC", + coinSymbol: "sBTC", + networkName: "BTC signet", + mempoolApiUrl: "https://mempool.space/signet", + network: Network.SIGNET, + }, + window.parent, + ) + .then((connector) => { + return connector.connect("okx"); + }) + .then(console.log); + }, []); + return ; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 44bf55c..7b3d9cb 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -11,6 +11,7 @@ /* Bundler mode */ "moduleResolution": "Bundler", "allowImportingTsExtensions": true, + "noImplicitAny": false, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, diff --git a/tsconfig.node.json b/tsconfig.node.json index abcd7f0..3d7023c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -9,6 +9,7 @@ /* Bundler mode */ "moduleResolution": "Bundler", "allowImportingTsExtensions": true, + "noImplicitAny": false, "isolatedModules": true, "moduleDetection": "force", "noEmit": true,