diff --git a/.changeset/tidy-flies-accept.md b/.changeset/tidy-flies-accept.md new file mode 100644 index 000000000..c8053d678 --- /dev/null +++ b/.changeset/tidy-flies-accept.md @@ -0,0 +1,5 @@ +--- +'@ant-design/web3-icp': major +--- + +功能:集成 ICP 链的钱包连接能力 diff --git a/packages/assets/src/icp/chains.tsx b/packages/assets/src/icp/chains.tsx new file mode 100644 index 000000000..238857767 --- /dev/null +++ b/packages/assets/src/icp/chains.tsx @@ -0,0 +1,17 @@ +import { createGetBrowserLink, IcpChainIds, type Chain } from '@ant-design/web3-common'; +import { IcpColorful } from '@ant-design/web3-icons'; + +export interface IcpChain extends Chain { + id: IcpChainIds; +} + +export const Icp: IcpChain = { + id: IcpChainIds.Mainnet, + name: 'Internet Computer', + icon: , + browser: { + icon: , + getBrowserLink: createGetBrowserLink('https://dashboard.internetcomputer.org'), + }, + nativeCurrency: { name: 'ICP', symbol: 'ICP', decimals: 8 }, +}; diff --git a/packages/assets/src/icp/index.ts b/packages/assets/src/icp/index.ts new file mode 100644 index 000000000..c2037b545 --- /dev/null +++ b/packages/assets/src/icp/index.ts @@ -0,0 +1 @@ +export * from './chains'; diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts index e916d1641..132b18779 100644 --- a/packages/assets/src/index.ts +++ b/packages/assets/src/index.ts @@ -1,3 +1,4 @@ export * from './wallets'; export * from './chains/ethereum'; export * from './tokens'; +export * from './icp'; diff --git a/packages/assets/src/wallets/index.ts b/packages/assets/src/wallets/index.ts index de82f4a4b..935e25b0a 100644 --- a/packages/assets/src/wallets/index.ts +++ b/packages/assets/src/wallets/index.ts @@ -15,3 +15,4 @@ export * from './mobile-wallet'; export * from './slush'; export * from './suiet'; export * from './solflare'; +export * from './plug'; diff --git a/packages/assets/src/wallets/plug.tsx b/packages/assets/src/wallets/plug.tsx new file mode 100644 index 000000000..c4c6c81bd --- /dev/null +++ b/packages/assets/src/wallets/plug.tsx @@ -0,0 +1,20 @@ +import type { WalletMetadata } from '@ant-design/web3-common'; +import { ChromeCircleColorful } from '@ant-design/web3-icons'; + +export const metadata_Plug: WalletMetadata = { + icon: null, + name: 'Plug', + remark: 'Plug Wallet', + app: { + link: 'https://plugwallet.ooo/', + }, + extensions: [ + { + key: 'Chrome', + browserIcon: , + browserName: 'Chrome', + link: 'https://chromewebstore.google.com/detail/plug/cfbfdhimifdmdehjmkdobpcjfefblkjm', + description: 'Access your wallet right from your favorite web browser.', + }, + ], +}; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 587197647..b3b69f013 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -44,6 +44,10 @@ export enum SuiChainIds { Localnet = 4, } +export enum IcpChainIds { + Mainnet = 1, +} + export type BrowserLinkType = 'address' | 'transaction'; export type BalanceMetadata = { diff --git a/packages/icp/package.json b/packages/icp/package.json new file mode 100644 index 000000000..beef568c1 --- /dev/null +++ b/packages/icp/package.json @@ -0,0 +1,63 @@ +{ + "name": "@ant-design/web3-icp", + "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" + }, + "sideEffects": false, + "files": [ + "dist", + "CHANGELOG.md", + "README.md" + ], + "keywords": [ + "ant", + "design", + "web3", + "antd", + "component", + "components", + "framework", + "frontend", + "react", + "react-component", + "ui", + "icp", + "internet-computer", + "plug" + ], + "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:*" + }, + "devDependencies": { + "father": "^4.6.2", + "typescript": "^5.6.2" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "browserslist": [ + "last 2 versions", + "Firefox ESR", + "> 1%", + "ie >= 11" + ] +} diff --git a/packages/icp/src/icp-provider/config-provider.tsx b/packages/icp/src/icp-provider/config-provider.tsx new file mode 100644 index 000000000..1117877a6 --- /dev/null +++ b/packages/icp/src/icp-provider/config-provider.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { IcpChain } from '@ant-design/web3-assets/icp'; +import type { Account, Chain, Locale, Wallet } from '@ant-design/web3-common'; +import { Web3ConfigProvider } from '@ant-design/web3-common'; + +import type { IcpWallet } from '../wallets/types'; + +interface ConnectAsync { + promise: Promise; + resolve: (account?: Account) => void; + reject: (reason: any) => void; +} + +export interface AntDesignWeb3ConfigProviderProps { + locale?: Locale; + chainAssets?: Chain[]; + availableChains: IcpChain[]; + balance?: boolean; + currentChain?: IcpChain; + availableWallets: Wallet[]; + onCurrentChainChange?: (chain?: IcpChain) => void; + wallet: IcpWallet; + principal: string | null; + connecting: boolean; + onConnect: () => Promise; + onDisconnect: () => Promise; +} + +export const AntDesignWeb3ConfigProvider: React.FC< + React.PropsWithChildren +> = (props) => { + const { + wallet, + principal, + connecting, + onConnect, + onDisconnect, + availableChains, + chainAssets, + currentChain, + availableWallets, + balance, + locale, + onCurrentChainChange, + } = props; + + const connectAsyncRef = useRef(); + const [account, setAccount] = useState(); + const [balanceData, setBalanceData] = useState(); + + // get account address + useEffect(() => { + if (!principal) { + setAccount(undefined); + return; + } + + setAccount({ + address: principal, + }); + }, [principal]); + + // connect/disconnect wallet + useEffect(() => { + if (connecting && connectAsyncRef.current) { + // connecting in progress + return; + } + + if (principal && connectAsyncRef.current) { + connectAsyncRef.current.resolve({ address: principal }); + connectAsyncRef.current = undefined; + } + }, [principal, connecting]); + + const chainList = useMemo(() => { + return availableChains + .map((item) => { + const c = chainAssets?.find((asset) => { + return asset.id === item.id; + }) as Chain; + + if (c?.id) { + return { + ...item, + ...c, + id: c.id, + name: c.name, + icon: c.icon, + }; + } + return item; + }) + .filter((item) => item !== null) as (Chain & IcpChain)[]; + }, [availableChains, chainAssets]); + + const currentChainData = useMemo(() => { + return chainList.find((c) => c.id === currentChain?.id); + }, [currentChain, chainList]); + + const currency = currentChainData?.nativeCurrency; + + return ( + { + const foundChain = chainList.find((c) => c.id === _chain.id); + const targetChain = foundChain ?? chainList[0]; + if (targetChain) { + onCurrentChainChange?.(targetChain); + } + }} + connect={async (_wallet, options) => { + let resolve: any; + let reject: any; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + connectAsyncRef.current = { promise, resolve, reject }; + + try { + await onConnect(); + } catch (error) { + connectAsyncRef.current.reject(error); + connectAsyncRef.current = undefined; + throw error; + } + + return promise; + }} + disconnect={async () => { + await onDisconnect(); + }} + > + {props.children} + + ); +}; diff --git a/packages/icp/src/icp-provider/index.tsx b/packages/icp/src/icp-provider/index.tsx new file mode 100644 index 000000000..20c15cc0a --- /dev/null +++ b/packages/icp/src/icp-provider/index.tsx @@ -0,0 +1,85 @@ +import React, { useMemo, useState, type FC, type PropsWithChildren } from 'react'; +import { Icp, type IcpChain } from '@ant-design/web3-assets/icp'; +import type { Locale } from '@ant-design/web3-common'; + +import { PlugWallet } from '../wallets/built-in'; +import type { IcpWalletFactory } from '../wallets/factory'; +import { AntDesignWeb3ConfigProvider } from './config-provider'; + +export interface IcpWeb3ConfigProviderProps { + locale?: Locale; + chains?: IcpChain[]; + wallets?: IcpWalletFactory[]; + balance?: boolean; +} + +export const IcpWeb3ConfigProvider: FC> = ({ + locale, + chains = [Icp], + wallets: walletFactories = [PlugWallet()], + balance, + children, +}) => { + const [currentChain, setCurrentChain] = useState(chains[0]); + const [principal, setPrincipal] = useState(null); + const [connecting, setConnecting] = useState(false); + + const availableWallets = useMemo(() => { + return walletFactories.map((factory) => { + const wallet = factory.create(); + return { + name: wallet.name, + remark: wallet.name, + icon: wallet.icon, + hasExtensionInstalled: async () => wallet.installed, + hasWalletReady: async () => wallet.installed, + }; + }); + }, [walletFactories]); + + const wallet = useMemo(() => { + // 优先选择已安装的钱包,如果没有已安装的,则选择第一个 + const walletInstances = walletFactories.map((factory) => factory.create()); + const installedWallet = walletInstances.find((w) => w.installed); + return installedWallet ?? walletInstances[0]; + }, [walletFactories]); + + const connect = async () => { + if (!wallet) { + return; + } + + setConnecting(true); + try { + await wallet.connect(); + const p = await wallet.getPrincipal(); + setPrincipal(p); + } finally { + setConnecting(false); + } + }; + + const disconnect = async () => { + await wallet.disconnect(); + setPrincipal(null); + }; + + return ( + setCurrentChain(chain)} + availableChains={chains} + wallet={wallet} + principal={principal} + connecting={connecting} + onConnect={connect} + onDisconnect={disconnect} + > + {children} + + ); +}; diff --git a/packages/icp/src/index.ts b/packages/icp/src/index.ts new file mode 100644 index 000000000..b2ff4fcab --- /dev/null +++ b/packages/icp/src/index.ts @@ -0,0 +1,4 @@ +export * from './icp-provider'; +export * from './wallets/types'; +export * from './wallets/factory'; +export * from './wallets/built-in'; diff --git a/packages/icp/src/wallets/built-in.ts b/packages/icp/src/wallets/built-in.ts new file mode 100644 index 000000000..8f8edde60 --- /dev/null +++ b/packages/icp/src/wallets/built-in.ts @@ -0,0 +1,10 @@ +/* v8 ignore start */ +import { metadata_Plug } from '@ant-design/web3-assets'; +import type { WalletMetadata } from '@ant-design/web3-common'; + +import { WalletFactory } from './factory'; +import type { IcpWalletFactory } from './factory'; +import { createPlugWallet } from './plug'; + +export const PlugWallet = (metadata?: Partial): IcpWalletFactory => + WalletFactory(createPlugWallet, { ...metadata_Plug, ...metadata }); diff --git a/packages/icp/src/wallets/factory.ts b/packages/icp/src/wallets/factory.ts new file mode 100644 index 000000000..df569eeb4 --- /dev/null +++ b/packages/icp/src/wallets/factory.ts @@ -0,0 +1,25 @@ +import type { WalletMetadata } from '@ant-design/web3-common'; + +import type { IcpWallet } from './types'; + +export interface IcpWalletFactory { + create: () => IcpWallet; +} + +export type IcpWalletFactoryBuilder = ( + createWallet: () => IcpWallet, + metadata: WalletMetadata, +) => IcpWalletFactory; + +export const WalletFactory: IcpWalletFactoryBuilder = (createWallet, metadata) => { + return { + create: () => { + const wallet = createWallet(); + return { + ...wallet, + name: metadata.name, + icon: metadata.icon ?? wallet.icon, + }; + }, + }; +}; diff --git a/packages/icp/src/wallets/infinity.tsx b/packages/icp/src/wallets/infinity.tsx new file mode 100644 index 000000000..2bbbe874e --- /dev/null +++ b/packages/icp/src/wallets/infinity.tsx @@ -0,0 +1,76 @@ +import type React from 'react'; + +import type { IcpWallet } from './types'; + +// NOTE: 这里假设 Infinity 钱包通过 window.ic.infinityWallet 注入, +// 具体字段请根据官方文档调整。 +declare global { + interface Window { + ic?: { + infinityWallet?: { + isConnected: () => Promise; + requestConnect: (opts?: unknown) => Promise<{ principalId: string }>; + disconnect: () => Promise; + getPrincipal: () => Promise<{ toText: () => string }>; + }; + }; + } +} + +const getInfinity = () => + typeof window === 'undefined' || typeof window.ic === 'undefined' + ? undefined + : window.ic.infinityWallet; + +export const isInfinityInstalled = () => !!getInfinity(); + +export function createInfinityWallet(): IcpWallet { + const getInstalled = () => isInfinityInstalled(); + + return { + id: 'infinity', + name: 'Infinity', + icon: null as React.ReactNode | null, + + get installed() { + return getInstalled(); + }, + + async connect() { + const wallet = getInfinity(); + if (!wallet) { + return; + } + await wallet.requestConnect(); + }, + + async disconnect() { + const wallet = getInfinity(); + if (!wallet) { + return; + } + await wallet.disconnect(); + }, + + async isConnected() { + const wallet = getInfinity(); + if (!wallet) { + return false; + } + return wallet.isConnected(); + }, + + async getPrincipal() { + const wallet = getInfinity(); + if (!wallet) { + return null; + } + try { + const principal = await wallet.getPrincipal(); + return principal.toText(); + } catch { + return null; + } + }, + }; +} diff --git a/packages/icp/src/wallets/plug.tsx b/packages/icp/src/wallets/plug.tsx new file mode 100644 index 000000000..738d97dc3 --- /dev/null +++ b/packages/icp/src/wallets/plug.tsx @@ -0,0 +1,73 @@ +import type React from 'react'; + +import type { IcpWallet } from './types'; + +declare global { + interface Window { + ic?: { + plug?: { + isConnected: () => Promise; + requestConnect: (opts?: unknown) => Promise<{ principalId: string }>; + disconnect: () => Promise; + getPrincipal: () => Promise<{ toText: () => string }>; + }; + }; + } +} + +const getPlug = () => + typeof window === 'undefined' || typeof window.ic === 'undefined' ? undefined : window.ic.plug; + +export const isPlugInstalled = () => !!getPlug(); + +export function createPlugWallet(): IcpWallet { + const getInstalled = () => isPlugInstalled(); + + return { + id: 'plug', + name: 'Plug', + icon: null, + + get installed() { + return getInstalled(); + }, + + async connect() { + const plug = getPlug(); + if (!plug) { + // 不抛异常,由上层根据 installed 提示安装钱包 + return; + } + await plug.requestConnect(); + }, + + async disconnect() { + const plug = getPlug(); + if (!plug) { + return; + } + await plug.disconnect(); + }, + + async isConnected() { + const plug = getPlug(); + if (!plug) { + return false; + } + return plug.isConnected(); + }, + + async getPrincipal() { + const plug = getPlug(); + if (!plug) { + return null; + } + try { + const principal = await plug.getPrincipal(); + return principal.toText(); + } catch { + return null; + } + }, + }; +} diff --git a/packages/icp/src/wallets/types.ts b/packages/icp/src/wallets/types.ts new file mode 100644 index 000000000..7356eacd5 --- /dev/null +++ b/packages/icp/src/wallets/types.ts @@ -0,0 +1,14 @@ +import type React from 'react'; + +export interface IcpWallet { + id: string; + name: string; + icon: React.ReactNode | null; + readonly installed: boolean; + connect: () => Promise; + disconnect: () => Promise; + isConnected: () => Promise; + getPrincipal: () => Promise; +} + +export type IcpWalletType = 'plug' | 'infinity'; diff --git a/packages/icp/tsconfig.json b/packages/icp/tsconfig.json new file mode 100644 index 000000000..928e5b0ef --- /dev/null +++ b/packages/icp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "global.d.ts"] +} diff --git a/packages/web3/src/icp/demos/basic.tsx b/packages/web3/src/icp/demos/basic.tsx new file mode 100644 index 000000000..597f74c66 --- /dev/null +++ b/packages/web3/src/icp/demos/basic.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { ConnectButton, Connector } from '@ant-design/web3'; +import { IcpWeb3ConfigProvider, PlugWallet } from '@ant-design/web3-icp'; + +const App: React.FC = () => { + return ( + + + + + + ); +}; + +export default App; diff --git a/packages/web3/src/icp/index.md b/packages/web3/src/icp/index.md new file mode 100644 index 000000000..7ec75cdcf --- /dev/null +++ b/packages/web3/src/icp/index.md @@ -0,0 +1,28 @@ +--- +nav: Components +subtitle: ICP +order: 6 +group: + title: Chains + order: 2 +--- + +## Introduction + +`@ant-design/web3-icp` provides basic adaptation for the ICP chain and currently ships with **Plug** wallet support out of the box. + +This page shows how to integrate ICP wallet connection via `IcpWeb3ConfigProvider` together with `ConnectButton`. + +> 📎 Need to log in through the official NNS portal first? Visit [https://nns.ic0.app/](https://nns.ic0.app/) to authorize your ICP account / login, then come back to experience the connection flow. + +## When to use + +- You need to integrate ICP wallet connection into your DApp; +- You are already using `@ant-design/web3` UI components (such as `ConnectButton`) and want a unified user experience; +- You only need a minimal Plug integration for now and will extend more wallets later. + +## Examples + +### Basic usage + + diff --git a/packages/web3/src/icp/index.zh-CN.md b/packages/web3/src/icp/index.zh-CN.md new file mode 100644 index 000000000..6f1b1398f --- /dev/null +++ b/packages/web3/src/icp/index.zh-CN.md @@ -0,0 +1,31 @@ +--- +nav: 组件 +subtitle: Internet Computer +order: 6 +group: + title: 连接链 + order: 2 +tag: + title: 新增 + color: success +--- + +## 介绍 + +`@ant-design/web3-icp` 提供了对 ICP 链的基础适配能力,目前内置支持 **Plug** 钱包,后续可以按需扩展更多 ICP 钱包。 + +本页示例展示如何通过 `IcpWeb3ConfigProvider` 和 `ConnectButton` 快速接入 ICP 链的钱包连接能力。 + +> 📎 如果需要提前登录官方 NNS 门户,可直接访问 [https://nns.ic0.app/](https://nns.ic0.app/),先在该站点完成 ICP 账号授权与登录,再回到页面体验连接流程。 + +## 何时使用 + +- 需要在 DApp 中集成 ICP 链的钱包连接能力; +- 已经在使用 `@ant-design/web3` 的通用 UI 组件(如 `ConnectButton`),希望统一体验; +- 只需要 Plug 钱包的最小接入,后续再逐步扩充。 + +## 代码演示 + +### 基础用法 + + diff --git a/tsconfig.base.json b/tsconfig.base.json index 8ebe0a74e..c2565f473 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,7 +34,8 @@ "@ant-design/web3-sui": ["./packages/sui/src/"], "@ant-design/web3-ton": ["./packages/ton/src/"], "@ant-design/web3-bitcoin": ["./packages/bitcoin/src/"], - "@ant-design/web3-tron": ["./packages/tron/src/"] + "@ant-design/web3-tron": ["./packages/tron/src/"], + "@ant-design/web3-icp": ["./packages/icp/src/"] } } }