From d3b034fdd27dd266c43e9a7b20295ee79a6f7ed8 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Tue, 14 Jan 2025 15:33:55 +0200 Subject: [PATCH] feat: Unisat (#187) * add unisat * unisat tweak signer --- .changeset/calm-starfishes-sip.md | 5 + docs/E2E.md | 7 +- src/core/wallets/btc/index.ts | 3 +- src/core/wallets/btc/unisat/index.ts | 16 ++ src/core/wallets/btc/unisat/logo.svg | 1 + src/core/wallets/btc/unisat/provider.ts | 252 ++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-starfishes-sip.md create mode 100644 src/core/wallets/btc/unisat/index.ts create mode 100644 src/core/wallets/btc/unisat/logo.svg create mode 100644 src/core/wallets/btc/unisat/provider.ts diff --git a/.changeset/calm-starfishes-sip.md b/.changeset/calm-starfishes-sip.md new file mode 100644 index 0000000..520bba2 --- /dev/null +++ b/.changeset/calm-starfishes-sip.md @@ -0,0 +1,5 @@ +--- +"@babylonlabs-io/bbn-wallet-connect": patch +--- + +add bitcoin wallet - unisat diff --git a/docs/E2E.md b/docs/E2E.md index 1d6f7e3..185ff4c 100644 --- a/docs/E2E.md +++ b/docs/E2E.md @@ -2,9 +2,12 @@ ## Overview -These tests use Playwright to launch a Chromium instance with selected wallet extensions. The required extensions (OKX, Keplr, etc.) download automatically as part of the test setup. +These tests use Playwright to launch a Chromium instance with selected wallet +extensions. The required extensions (OKX, Keplr, etc.) download automatically as +part of the test setup. -You need to provide your 12 words mnemonic and a password (that is used for the Chrome extension) in `.env.local`: +You need to provide your 12 words mnemonic and a password (that is used for the +Chrome extension) in `.env.local`: ```env E2E_WALLET_MNEMONIC="one two three four five six seven eight nine ten eleven twelve" diff --git a/src/core/wallets/btc/index.ts b/src/core/wallets/btc/index.ts index be59142..1b6d279 100644 --- a/src/core/wallets/btc/index.ts +++ b/src/core/wallets/btc/index.ts @@ -7,12 +7,13 @@ import injectable from "./injectable"; import keystone from "./keystone"; import okx from "./okx"; import onekey from "./onekey"; +import unisat from "./unisat"; const metadata: ChainMetadata<"BTC", IBTCProvider, BTCConfig> = { chain: "BTC", name: "Bitcoin", icon, - wallets: [injectable, okx, onekey, bitget, cactus, keystone], + wallets: [injectable, okx, onekey, bitget, cactus, unisat, keystone], }; export default metadata; diff --git a/src/core/wallets/btc/unisat/index.ts b/src/core/wallets/btc/unisat/index.ts new file mode 100644 index 0000000..85bbc1d --- /dev/null +++ b/src/core/wallets/btc/unisat/index.ts @@ -0,0 +1,16 @@ +import { IBTCProvider, Network, type BTCConfig, type WalletMetadata } from "@/core/types"; + +import logo from "./logo.svg"; +import { UnisatProvider, WALLET_PROVIDER_NAME } from "./provider"; + +const metadata: WalletMetadata = { + id: "unisat", + name: WALLET_PROVIDER_NAME, + icon: logo, + docs: "https://unisat.io/download", + wallet: "unisat", + createProvider: (wallet, config) => new UnisatProvider(wallet, config), + networks: [Network.MAINNET, Network.SIGNET], +}; + +export default metadata; diff --git a/src/core/wallets/btc/unisat/logo.svg b/src/core/wallets/btc/unisat/logo.svg new file mode 100644 index 0000000..1f944dd --- /dev/null +++ b/src/core/wallets/btc/unisat/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/core/wallets/btc/unisat/provider.ts b/src/core/wallets/btc/unisat/provider.ts new file mode 100644 index 0000000..628cbf2 --- /dev/null +++ b/src/core/wallets/btc/unisat/provider.ts @@ -0,0 +1,252 @@ +import { initBTCCurve } from "@babylonlabs-io/btc-staking-ts"; +import { Psbt, address as btcAddress, networks } from "bitcoinjs-lib"; + +import type { BTCConfig, IBTCProvider, InscriptionIdentifier, WalletInfo } from "@/core/types"; +import { Network } from "@/core/types"; +import { validateAddress } from "@/core/utils/wallet"; + +import logo from "./logo.svg"; + +enum UnisatChainEnum { + BITCOIN_SIGNET = "BITCOIN_SIGNET", + BITCOIN_MAINNET = "BITCOIN_MAINNET", + BITCOIN_TESTNET = "BITCOIN_TESTNET", +} + +interface UnisatChainResponse { + enum: UnisatChainEnum; + name: string; + network: "testnet" | "livenet"; +} + +export const WALLET_PROVIDER_NAME = "Unisat"; + +// Unisat derivation path for BTC Signet +// Taproot: `m/86'/1'/0'/0` +// Native Segwit: `m/84'/1'/0'/0` +export class UnisatProvider implements IBTCProvider { + private provider: any; + private walletInfo: WalletInfo | undefined; + private config: BTCConfig; + + constructor(wallet: any, config: BTCConfig) { + this.config = config; + + // check whether there is an Unisat extension + if (!wallet) { + throw new Error("Unisat Wallet extension not found"); + } + + this.provider = wallet; + } + + connectWallet = async (): Promise => { + console.log("new version 18"); + let accounts; + try { + accounts = await this.provider.requestAccounts(); + } catch (error) { + if ((error as Error)?.message?.includes("rejected")) { + throw new Error("Connection to Unisat Wallet was rejected"); + } else { + throw new Error((error as Error)?.message); + } + } + + const address = accounts[0]; + validateAddress(this.config.network, address); + + const publicKeyHex = await this.provider.getPublicKey(); + + if (publicKeyHex && address) { + this.walletInfo = { + publicKeyHex, + address, + }; + } else { + throw new Error("Could not connect to Unisat Wallet"); + } + }; + + getAddress = async (): Promise => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + + return this.walletInfo.address; + }; + + getPublicKeyHex = async (): Promise => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + + return this.walletInfo.publicKeyHex; + }; + + signPsbt = async (psbtHex: string): Promise => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + if (!psbtHex) throw new Error("psbt hex is required"); + + const network = await this.getNetwork(); + try { + const signedHex = await this.provider.signPsbt(psbtHex, this.getSignPsbtDefaultOptions(psbtHex, network)); + return signedHex; + } catch (error: Error | any) { + throw new Error(error?.message || error); + } + }; + + signPsbts = async (psbtsHexes: string[]): Promise => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + if (!psbtsHexes && !Array.isArray(psbtsHexes)) throw new Error("psbts hexes are required"); + + const network = await this.getNetwork(); + try { + return await this.provider.signPsbts( + psbtsHexes, + psbtsHexes.map((psbtHex) => this.getSignPsbtDefaultOptions(psbtHex, network)), + ); + } catch (error: Error | any) { + throw new Error(error?.message || error); + } + }; + + private getSignPsbtDefaultOptions(psbtHex: string, network: Network) { + const toSignInputs: any[] = []; + const psbt = Psbt.fromHex(psbtHex); + psbt.data.inputs.forEach((input, index) => { + let useTweakedSigner = false; + if (input.witnessUtxo && input.witnessUtxo.script) { + let btcNetwork = networks.bitcoin; + + if (network === Network.TESTNET || network === Network.SIGNET) { + btcNetwork = networks.testnet; + } + + let addressToBeSigned; + try { + addressToBeSigned = btcAddress.fromOutputScript(input.witnessUtxo.script, btcNetwork); + } catch (error: Error | any) { + if (error instanceof Error && error.message.toLowerCase().includes("has no matching address")) { + // initialize the BTC curve if not already initialized + initBTCCurve(); + addressToBeSigned = btcAddress.fromOutputScript(input.witnessUtxo.script, btcNetwork); + } else { + throw new Error(error); + } + } + // check if the address is a taproot address + const isTaproot = addressToBeSigned.indexOf("tb1p") === 0 || addressToBeSigned.indexOf("bc1p") === 0; + // check if the address is the same as the wallet address + const isWalletAddress = addressToBeSigned === this.walletInfo?.address; + // tweak the signer if needed + if (isTaproot && isWalletAddress) { + useTweakedSigner = true; + } + } + + const signed = input.finalScriptSig || input.finalScriptWitness; + + if (!signed) { + toSignInputs.push({ + index, + publicKey: this.walletInfo?.publicKeyHex, + sighashTypes: undefined, + useTweakedSigner, + }); + } + }); + + return { + autoFinalized: true, + toSignInputs, + }; + } + + getNetwork = async (): Promise => { + const chainInfo: UnisatChainResponse = await this.provider.getChain(); + + switch (chainInfo.enum) { + case UnisatChainEnum.BITCOIN_MAINNET: + return Network.MAINNET; + case UnisatChainEnum.BITCOIN_SIGNET: + return Network.SIGNET; + case UnisatChainEnum.BITCOIN_TESTNET: + // For testnet, we return Signet + return Network.SIGNET; + default: + throw new Error("Unsupported network"); + } + }; + + signMessage = async (message: string, type: "ecdsa"): Promise => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + + return await this.provider.signMessage(message, type); + }; + + getInscriptions = async (): Promise => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + if (this.config.network !== Network.MAINNET) { + throw new Error("Inscriptions are only available on Unisat 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 Unisat Wallet"); + } + + return inscriptionIdentifiers; + }; + + on = (eventName: string, callBack: () => void) => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + + // subscribe to account change event: `accountChanged` -> `accountsChanged` + if (eventName === "accountChanged") { + return this.provider.on("accountsChanged", callBack); + } + return this.provider.on(eventName, callBack); + }; + + off = (eventName: string, callBack: () => void) => { + if (!this.walletInfo) throw new Error("Unisat Wallet not connected"); + + // unsubscribe to account change event + if (eventName === "accountChanged") { + return this.provider.off("accountsChanged", callBack); + } + return this.provider.off(eventName, callBack); + }; + + getWalletProviderName = async (): Promise => { + return WALLET_PROVIDER_NAME; + }; + + getWalletProviderIcon = async (): Promise => { + return logo; + }; +}