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 @@
+
+
\ 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,