diff --git a/.changeset/silent-cooks-laugh.md b/.changeset/silent-cooks-laugh.md new file mode 100644 index 000000000..a8ac5b3d2 --- /dev/null +++ b/.changeset/silent-cooks-laugh.md @@ -0,0 +1,9 @@ +--- +"@ant-design/web3-bitcoin": major +"@ant-design/web3-assets": patch +"@ant-design/web3-common": patch +"@ant-design/web3-icons": patch +"@ant-design/web3": patch +--- + +feat: support for bitcoin diff --git a/packages/assets/src/wallets/index.ts b/packages/assets/src/wallets/index.ts index d28b613ba..1e4792819 100644 --- a/packages/assets/src/wallets/index.ts +++ b/packages/assets/src/wallets/index.ts @@ -6,5 +6,7 @@ export * from './safeheron'; export * from './okx-wallet'; export * from './phantom'; export * from './im-token'; +export * from './xverse'; +export * from './unisat-wallet'; export * from './backpack'; export * from './trust'; diff --git a/packages/assets/src/wallets/unisat-wallet.tsx b/packages/assets/src/wallets/unisat-wallet.tsx new file mode 100644 index 000000000..50199dece --- /dev/null +++ b/packages/assets/src/wallets/unisat-wallet.tsx @@ -0,0 +1,20 @@ +import type { WalletMetadata } from '@ant-design/web3-common'; +import { ChromeCircleColorful, UnisatColorful } from '@ant-design/web3-icons'; + +export const metadata_Unisat: WalletMetadata = { + icon: , + name: 'Unisat Wallet', + remark: 'Unisat Wallet', + app: { + link: 'https://unisat.io/download', + }, + extensions: [ + { + key: 'Chrome', + browserIcon: , + browserName: 'Chrome', + link: 'https://chrome.google.com/webstore/detail/unisat/ppbibelpcjmhbdihakflkdcoccbgbkpo', + description: 'Access your wallet right from your favorite web browser.', + }, + ], +}; diff --git a/packages/assets/src/wallets/xverse.tsx b/packages/assets/src/wallets/xverse.tsx new file mode 100644 index 000000000..f50e3f3dd --- /dev/null +++ b/packages/assets/src/wallets/xverse.tsx @@ -0,0 +1,20 @@ +import type { WalletMetadata } from '@ant-design/web3-common'; +import { ChromeCircleColorful, XverseColorful } from '@ant-design/web3-icons'; + +export const metadata_Xverse: WalletMetadata = { + icon: , + name: 'Xverse', + remark: 'Xverse Wallet', + app: { + link: 'https://www.xverse.app/', + }, + extensions: [ + { + key: 'Chrome', + browserIcon: , + browserName: 'Chrome', + link: 'https://chrome.google.com/webstore/detail/xverse-wallet/idnnbdplmphpflfnlkomgpfbpcgelopg', + description: 'Access your wallet right from your favorite web browser.', + }, + ], +}; diff --git a/packages/bitcoin/.fatherrc.ts b/packages/bitcoin/.fatherrc.ts new file mode 100644 index 000000000..3305dd5a7 --- /dev/null +++ b/packages/bitcoin/.fatherrc.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'father'; + +export default defineConfig({ + extends: '../../.fatherrc.base.ts', +}); diff --git a/packages/bitcoin/README.md b/packages/bitcoin/README.md new file mode 100644 index 000000000..a17f0a554 --- /dev/null +++ b/packages/bitcoin/README.md @@ -0,0 +1,36 @@ +# @ant-design/web3-bitcoin + +This package provides a Bitcoin adapter for [@ant-design/web3](https://www.npmjs.com/package/@ant-design/web3). + +## Installation + +```bash +npm install @ant-design/web3 @ant-design/web3-bitcoin +``` + +## Usage + +```tsx +import { ConnectButton, Connector } from '@ant-design/web3'; +import { BitcoinWeb3ConfigProvider, UnisatWallet, XverseWallet } from '@ant-design/web3-bitcoin'; + +const App: React.FC = () => { + return ( + + + + + + ); +}; + +export default App; +``` + +For more examples, refer to [Bitcoin - Ant Design Web3](https://web3.ant.design/components/bitcoin). + +## Documentation + +- For more information, visit [Ant Design Web3](https://web3.ant.design). +- For an introduction to UniSat wallet, visit [UniSat Wallet](https://docs.unisat.io/dev/unisat-developer-service/unisat-wallet). +- For an introduction to Xverse wallet, visit [Sats Connect](https://docs.xverse.app/sats-connect). diff --git a/packages/bitcoin/package.json b/packages/bitcoin/package.json new file mode 100644 index 000000000..f26fe78e8 --- /dev/null +++ b/packages/bitcoin/package.json @@ -0,0 +1,62 @@ +{ + "name": "@ant-design/web3-bitcoin", + "version": "0.0.1", + "main": "dist/lib/index.js", + "module": "dist/esm/index.js", + "typings": "dist/esm/index.d.ts", + "exports": { + "import": "./dist/esm/index.js", + "require": "./dist/lib/index.js", + "types": "./dist/esm/index.d.ts" + }, + "files": [ + "dist", + "CHANGELOG.md", + "README.md" + ], + "keywords": [ + "ant", + "component", + "components", + "design", + "framework", + "frontend", + "react", + "react-component", + "ui", + "web3", + "bitcoin" + ], + "homepage": "https://web3.ant.design", + "bugs": { + "url": "https://github.com/ant-design/ant-design-web3/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/ant-design/ant-design-web3" + }, + "scripts": { + "dev": "father dev", + "build": "father build" + }, + "dependencies": { + "@ant-design/web3-common": "workspace:*", + "@ant-design/web3-icons": "workspace:*", + "@mempool/mempool.js": "^2.3.0", + "sats-connect": "^2.1.0" + }, + "devDependencies": { + "father": "^4.3.8", + "typescript": "^5.4.2" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "browserslist": [ + "last 2 versions", + "Firefox ESR", + "> 1%", + "ie >= 11" + ] +} diff --git a/packages/bitcoin/src/adapter/index.ts b/packages/bitcoin/src/adapter/index.ts new file mode 100644 index 000000000..e8a2e3d24 --- /dev/null +++ b/packages/bitcoin/src/adapter/index.ts @@ -0,0 +1,2 @@ +export * from './useBitcoinWallet'; +export * from './wallets'; diff --git a/packages/bitcoin/src/adapter/useBitcoinWallet.ts b/packages/bitcoin/src/adapter/useBitcoinWallet.ts new file mode 100644 index 000000000..dd4870320 --- /dev/null +++ b/packages/bitcoin/src/adapter/useBitcoinWallet.ts @@ -0,0 +1,23 @@ +import React, { createContext, useContext } from 'react'; +import type { Account, Balance } from '@ant-design/web3-common'; + +export interface BitcoinWallet { + name: string; + provider: any; + account?: Account; + getBalance: () => Promise; + connect: () => Promise; + signMessage: (message: string) => Promise; + sendTransfer: ( + to: string, + sats: number, + options?: { feeRate: number }, + ) => Promise; +} + +export const BitcoinAdapterContext = createContext({} as BitcoinWallet); + +export const useBitcoinWallet = () => { + const adapter = useContext(BitcoinAdapterContext); + return adapter; +}; diff --git a/packages/bitcoin/src/adapter/wallets/index.ts b/packages/bitcoin/src/adapter/wallets/index.ts new file mode 100644 index 000000000..915d445a9 --- /dev/null +++ b/packages/bitcoin/src/adapter/wallets/index.ts @@ -0,0 +1,2 @@ +export * from './unisat'; +export * from './xverse'; diff --git a/packages/bitcoin/src/adapter/wallets/unisat.ts b/packages/bitcoin/src/adapter/wallets/unisat.ts new file mode 100644 index 000000000..595b76e6d --- /dev/null +++ b/packages/bitcoin/src/adapter/wallets/unisat.ts @@ -0,0 +1,55 @@ +import type { Account, Balance } from '@ant-design/web3-common'; + +import { getBalanceObject } from '../../helpers'; +import type { BitcoinWallet } from '../useBitcoinWallet'; + +export class UnisatBitcoinWallet implements BitcoinWallet { + name: string; + provider: Window['unisat']; + account?: Account; + + constructor(name: string) { + this.name = name; + this.provider = window.unisat; + this.account = undefined; + } + + connect = async (): Promise => { + if (!this.provider) return; + try { + const accounts = await this.provider.requestAccounts(); + this.account = { address: accounts[0] }; + } catch (e) { + throw e; + } + return; + }; + + getBalance = async (): Promise => { + if (!this.provider) return; + const { confirmed } = await this.provider.getBalance(); + const balance = getBalanceObject(confirmed); + return balance; + }; + + signMessage = async (msg: string): Promise => { + if (!this.provider) return; + const signature = await this.provider.signMessage(msg); + return signature; + }; + + sendTransfer = async ( + to: string, + sats: number, + options?: { feeRate: number }, + ): Promise => { + if (!this.provider) return; + let txid = ''; + try { + txid = await this.provider.sendBitcoin(to, sats, options); + } catch (e) { + throw e; + } + return txid; + }; +} diff --git a/packages/bitcoin/src/adapter/wallets/xverse.ts b/packages/bitcoin/src/adapter/wallets/xverse.ts new file mode 100644 index 000000000..d82576312 --- /dev/null +++ b/packages/bitcoin/src/adapter/wallets/xverse.ts @@ -0,0 +1,71 @@ +import type { Account, Balance } from '@ant-design/web3-common'; +import { AddressPurpose, getProviderById, request, type BitcoinProvider } from 'sats-connect'; + +import { getBalanceByMempool } from '../../helpers'; +import type { BitcoinWallet } from '../useBitcoinWallet'; + +export class XverseBitcoinWallet implements BitcoinWallet { + name: string; + provider: BitcoinProvider | null; + account?: Account; + payment?: string; + + constructor(name: string, id = 'XverseProviders.BitcoinProvider') { + this.name = name; + this.provider = getProviderById(id); + } + + connect = async (): Promise => { + if (!this.provider) return; + const response = await request('getAccounts', { + purposes: [AddressPurpose.Ordinals, AddressPurpose.Payment], + }); + if (response.status === 'error') { + throw new Error(response.error.message); + } + const [ordinals, payment] = response.result; + this.account = { address: ordinals.address }; + this.payment = payment.address; + }; + + getBalance = async (): Promise => { + if (!this.payment) return; + const balance = await getBalanceByMempool(this.payment); + return balance; + }; + + signMessage = async (msg: string): Promise => { + if (!this.account?.address || !this.provider) return; + const response = await request('signMessage', { + address: this.account.address, + message: msg, + }); + if (response.status === 'success') { + return response.result.signature; + } else { + throw new Error(response.error.message); + } + }; + + sendTransfer = async (to: string, sats: number): Promise => { + let txid = ''; + try { + const response = await request('sendTransfer', { + recipients: [ + { + address: to, + amount: sats, + }, + ], + }); + if (response.status === 'success') { + txid = response.result.txid; + } else { + throw new Error(response.error.message); + } + } catch (e) { + throw e; + } + return txid; + }; +} diff --git a/packages/bitcoin/src/global.d.ts b/packages/bitcoin/src/global.d.ts new file mode 100644 index 000000000..01f4f36e6 --- /dev/null +++ b/packages/bitcoin/src/global.d.ts @@ -0,0 +1,4 @@ +declare interface Window { + // TODO: unisat interface + unisat?: any; +} diff --git a/packages/bitcoin/src/helpers.tsx b/packages/bitcoin/src/helpers.tsx new file mode 100644 index 000000000..6ccf0c678 --- /dev/null +++ b/packages/bitcoin/src/helpers.tsx @@ -0,0 +1,29 @@ +import type { Balance } from '@ant-design/web3-common'; +import { BitcoinCircleColorful } from '@ant-design/web3-icons'; +import mempoolJS from '@mempool/mempool.js'; + +const { + bitcoin: { addresses }, +} = mempoolJS({ + hostname: 'mempool.space', +}); + +export const getBalanceObject = (sats: number): Balance => { + return { + value: BigInt(sats), + decimals: 8, + symbol: 'BTC', + icon: , + }; +}; + +/** + * 对于不提供获取余额接口的钱包,如 Xverse,需调用 mempool API 自行获取 + * https://github.com/secretkeylabs/sats-connect/issues/12#issuecomment-2038963924 + */ +export const getBalanceByMempool = async (address: string): Promise => { + const addr = await addresses.getAddress({ address }); + const { funded_txo_sum, spent_txo_count } = addr.chain_stats; + // mempool not included + return getBalanceObject(funded_txo_sum - spent_txo_count); +}; diff --git a/packages/bitcoin/src/index.ts b/packages/bitcoin/src/index.ts new file mode 100644 index 000000000..8cd0413dc --- /dev/null +++ b/packages/bitcoin/src/index.ts @@ -0,0 +1,3 @@ +export * from './provider'; +export * from './wallets'; +export { useBitcoinWallet } from './adapter/useBitcoinWallet'; diff --git a/packages/bitcoin/src/provider/__tests__/basic.test.tsx b/packages/bitcoin/src/provider/__tests__/basic.test.tsx new file mode 100644 index 000000000..137f5e0b7 --- /dev/null +++ b/packages/bitcoin/src/provider/__tests__/basic.test.tsx @@ -0,0 +1,96 @@ +import { ConnectButton, Connector, useConnection } from '@ant-design/web3'; +import { fireEvent } from '@testing-library/react'; +import { Button } from 'antd'; +import { describe, expect, it, vi } from 'vitest'; + +import { XverseWallet } from '../../wallets'; +import { BitcoinWeb3ConfigProvider } from '../index'; +import { xrender } from './utils'; + +vi.mock('sats-connect', async () => { + const originModules = await vi.importActual('sats-connect'); + return { + ...originModules, + request: (method: string) => { + if (method === 'getAccounts') { + return Promise.resolve({ + status: 'success', + result: [ + { + address: '123', + }, + { + address: '456', + }, + ], + }); + } + return; + }, + getProviderById: () => ({}), + }; +}); + +vi.mock('@mempool/mempool.js', async () => { + return { + default: () => ({ + bitcoin: { + addresses: { + getAddress: () => + Promise.resolve({ + chain_stats: { + funded_txo_sum: 1000, + spent_txo_count: 100, + }, + }), + }, + }, + }), + }; +}); + +describe('BitcoinWeb3ConfigProvider', () => { + it('mount correctly', () => { + const App = () => ( + +
test
+
+ ); + + const { selector } = xrender(App); + expect(selector('.content')?.textContent).toBe('test'); + }); + + it('connect and disconnect', async () => { + const Disconnect = () => { + const { disconnect } = useConnection(); + return ( + + ); + }; + + const App = () => { + return ( + + + + + + + ); + }; + + const { selector } = xrender(App); + const modalBtn = selector('.connect')!; + fireEvent.click(modalBtn); + // select wallet + const connectBtn = selector('.ant-list-item')!; + expect(connectBtn.textContent).not.toBeNull(); + fireEvent.click(connectBtn); + // disconnect + const disconnectBtn = selector('.disconnect')!; + fireEvent.click(disconnectBtn); + }); +}); diff --git a/packages/bitcoin/src/provider/__tests__/utils.tsx b/packages/bitcoin/src/provider/__tests__/utils.tsx new file mode 100644 index 000000000..3955fa77f --- /dev/null +++ b/packages/bitcoin/src/provider/__tests__/utils.tsx @@ -0,0 +1,19 @@ +import type { FC } from 'react'; +import { render } from '@testing-library/react'; + +type RenderResult = ReturnType; +type RenderWithUtils = RenderResult & { + selector: (selector: string) => T | null; + selectors: (selector: string) => NodeListOf; +}; +type XRender = (Comp: FC, options?: Parameters[1]) => RenderWithUtils; + +export const xrender: XRender = (Comp, options) => { + const { baseElement, ...others } = render(, options); + return { + baseElement, + ...others, + selector: (selector) => baseElement.querySelector(selector), + selectors: (selector) => baseElement.querySelectorAll(selector), + }; +}; diff --git a/packages/bitcoin/src/provider/config-provider.tsx b/packages/bitcoin/src/provider/config-provider.tsx new file mode 100644 index 000000000..422a3708c --- /dev/null +++ b/packages/bitcoin/src/provider/config-provider.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState, type FC, type PropsWithChildren } from 'react'; +import { + Web3ConfigProvider, + type Balance, + type Locale, + type Wallet, +} from '@ant-design/web3-common'; + +import { useBitcoinWallet } from '../adapter'; + +export interface BitcoinConfigProviderProps { + locale?: Locale; + wallets: Wallet[]; + selectWallet: (wallet?: Wallet | null) => void; + balance: boolean; +} + +export const BitcoinConfigProvider: FC> = ({ + children, + locale, + wallets, + selectWallet, + balance: showBalance, +}) => { + const { getBalance, account } = useBitcoinWallet(); + const [balance, setBalance] = useState(); + + useEffect(() => { + if (!showBalance) return; + getBalance?.().then((b) => setBalance(b)); + }, [showBalance, getBalance]); + + return ( + { + selectWallet(wallet); + }} + disconnect={async () => { + selectWallet(null); + }} + > + {children} + + ); +}; diff --git a/packages/bitcoin/src/provider/index.tsx b/packages/bitcoin/src/provider/index.tsx new file mode 100644 index 000000000..0b4cab832 --- /dev/null +++ b/packages/bitcoin/src/provider/index.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState, type FC, type PropsWithChildren } from 'react'; +import type { Locale, Wallet } from '@ant-design/web3-common'; + +import { type BitcoinWallet } from '../adapter'; +import { BitcoinAdapterContext } from '../adapter/useBitcoinWallet'; +import { WalletFactory, WalletWithAdapter } from '../wallets/types'; +import { BitcoinConfigProvider } from './config-provider'; + +export interface BitcoinWeb3ConfigProviderProps { + // 钱包显式传入 + wallets?: WalletFactory[]; + locale?: Locale; + balance?: boolean; +} + +export const BitcoinWeb3ConfigProvider: FC> = ({ + children, + wallets: initWallets = [], + balance = false, + locale, +}) => { + const [adapter, setAdapter] = useState({} as BitcoinWallet); + const [wallets, setWallets] = useState([]); + + useEffect(() => { + if (initWallets.length === 0) return; + setWallets(initWallets.map((w) => w.create())); + }, [initWallets]); + + const selectWallet = async (wallet?: Wallet | null) => { + if (!wallet) { + // disconnect + if (!!adapter) setAdapter({} as BitcoinWallet); + return; + } + const provider = wallets.find((w) => w.name === wallet.name)?.adapter; + await provider?.connect(); + // @ts-ignore provider is not undefined + setAdapter(provider); + }; + + return ( + + + {children} + + + ); +}; diff --git a/packages/bitcoin/src/wallets/factory.ts b/packages/bitcoin/src/wallets/factory.ts new file mode 100644 index 000000000..951baca7a --- /dev/null +++ b/packages/bitcoin/src/wallets/factory.ts @@ -0,0 +1,15 @@ +import { WalletFactoryBuilder } from './types'; + +export const WalletFactory: WalletFactoryBuilder = (adapter, metadata) => { + return { + adapter, + create: () => { + return { + ...metadata, + adapter: adapter, + hasWalletReady: () => Promise.resolve(!!adapter.provider), + hasExtensionInstalled: () => Promise.resolve(!!adapter.provider), + }; + }, + }; +}; diff --git a/packages/bitcoin/src/wallets/index.ts b/packages/bitcoin/src/wallets/index.ts new file mode 100644 index 000000000..12370c288 --- /dev/null +++ b/packages/bitcoin/src/wallets/index.ts @@ -0,0 +1,12 @@ +/** + * wallets 是将 adapter 与钱包静态信息一起封装 + */ +import { metadata_Unisat, metadata_Xverse } from '@ant-design/web3-assets'; + +import { UnisatBitcoinWallet, XverseBitcoinWallet } from '../adapter'; +import { WalletFactory } from './factory'; + +export const UnisatWallet = () => + WalletFactory(new UnisatBitcoinWallet(metadata_Unisat.name), metadata_Unisat); +export const XverseWallet = () => + WalletFactory(new XverseBitcoinWallet(metadata_Xverse.name), metadata_Xverse); diff --git a/packages/bitcoin/src/wallets/types.ts b/packages/bitcoin/src/wallets/types.ts new file mode 100644 index 000000000..fd71c7eae --- /dev/null +++ b/packages/bitcoin/src/wallets/types.ts @@ -0,0 +1,17 @@ +import type { Wallet, WalletMetadata } from '@ant-design/web3-common'; + +import type { BitcoinWallet } from '../adapter'; + +export interface WalletWithAdapter extends Wallet { + adapter: BitcoinWallet; +} + +export interface WalletFactory { + adapter: BitcoinWallet; + create: () => WalletWithAdapter; +} + +export type WalletFactoryBuilder = ( + adapter: BitcoinWallet, + metadata: WalletMetadata, +) => WalletFactory; diff --git a/packages/bitcoin/tsconfig.json b/packages/bitcoin/tsconfig.json new file mode 100644 index 000000000..928e5b0ef --- /dev/null +++ b/packages/bitcoin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "global.d.ts"] +} diff --git a/packages/web3/package.json b/packages/web3/package.json index 39e98e752..4906a16eb 100644 --- a/packages/web3/package.json +++ b/packages/web3/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@ant-design/web3-solana": "workspace:*", + "@ant-design/web3-bitcoin": "workspace:*", "@ant-design/web3-wagmi": "workspace:*", "@ant-design/web3-ethers": "workspace:*", "@types/react": "^18.2.78", diff --git a/packages/web3/src/bitcoin/demos/basic.tsx b/packages/web3/src/bitcoin/demos/basic.tsx new file mode 100644 index 000000000..548d97695 --- /dev/null +++ b/packages/web3/src/bitcoin/demos/basic.tsx @@ -0,0 +1,14 @@ +import { ConnectButton, Connector } from '@ant-design/web3'; +import { BitcoinWeb3ConfigProvider, UnisatWallet, XverseWallet } from '@ant-design/web3-bitcoin'; + +const App: React.FC = () => { + return ( + + + + + + ); +}; + +export default App; diff --git a/packages/web3/src/bitcoin/demos/get-inscriptions.tsx b/packages/web3/src/bitcoin/demos/get-inscriptions.tsx new file mode 100644 index 000000000..4cd57c472 --- /dev/null +++ b/packages/web3/src/bitcoin/demos/get-inscriptions.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { ConnectButton, Connector } from '@ant-design/web3'; +import { + BitcoinWeb3ConfigProvider, + UnisatWallet, + useBitcoinWallet, +} from '@ant-design/web3-bitcoin'; +import { Button, message, Space } from 'antd'; + +const GetInscriptions: React.FC = () => { + const { account, name, provider } = useBitcoinWallet(); + const [img, setImg] = useState(); + + return account ? ( + + + {img ?