Skip to content

Commit

Permalink
feat: Unisat (#187)
Browse files Browse the repository at this point in the history
* add unisat

* unisat tweak signer
  • Loading branch information
gbarkhatov authored Jan 14, 2025
1 parent f0ad8a6 commit d3b034f
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-starfishes-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@babylonlabs-io/bbn-wallet-connect": patch
---

add bitcoin wallet - unisat
7 changes: 5 additions & 2 deletions docs/E2E.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion src/core/wallets/btc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 16 additions & 0 deletions src/core/wallets/btc/unisat/index.ts
Original file line number Diff line number Diff line change
@@ -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<IBTCProvider, BTCConfig> = {
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;
1 change: 1 addition & 0 deletions src/core/wallets/btc/unisat/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
252 changes: 252 additions & 0 deletions src/core/wallets/btc/unisat/provider.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<string> => {
if (!this.walletInfo) throw new Error("Unisat Wallet not connected");

return this.walletInfo.address;
};

getPublicKeyHex = async (): Promise<string> => {
if (!this.walletInfo) throw new Error("Unisat Wallet not connected");

return this.walletInfo.publicKeyHex;
};

signPsbt = async (psbtHex: string): Promise<string> => {
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<string[]> => {
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<Network> => {
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<string> => {
if (!this.walletInfo) throw new Error("Unisat Wallet not connected");

return await this.provider.signMessage(message, type);
};

getInscriptions = async (): Promise<InscriptionIdentifier[]> => {
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<string> => {
return WALLET_PROVIDER_NAME;
};

getWalletProviderIcon = async (): Promise<string> => {
return logo;
};
}

0 comments on commit d3b034f

Please sign in to comment.