用户要想与以太坊交互就离不开钱包。同样的,为用户提供服务的以太坊 dapp 也必须实现与用户钱包交互的功能。否则,它算不上真正的 dapp,顶多成为一个以太坊数据的查看器。在众多钱包之中,不得不提及 MetaMask。不仅仅因为其是目前使用最多的钱包之一,而且还因为它是不少的 EIP 的参考实现。基本上,只要你的 dapp 可以正常跟 MetaMask 交互,那跟其他钱包交互也不会有多少大问题。因此,本文以 MetaMask 为例,谈谈 dapp 开发者必须了解的知识点。
通过本文,你可以了解到:
- 每个【Connect Wallet】都需要实现的基本功能
- 支持多 provider
- 应用加载后自动连接
- 如何让地址旁出现跟 MM 一样的图标?
- 理解 permission
- 如何添加新网络?
- 如何实现自动切换网络?
- 如何添加新 Token?
- 实现钱包登录的一般思路
- 实现自己的钱包连接界面
- 移动端和桌面端的差异
同时,也请留意本文不会提及的内容:
- 签名,请参见 Ethers.js 非权威开发指南(下)的“签名”一节。
- MM Falsk 相关内容,因为尚未发布稳定版,在这之前一切皆有可能变化。
每个【Connect Wallet】都需要实现的基本功能
当 dapp 被(已装 MM 插件的)桌面浏览器或钱包浏览器加载后,后者会往页面注入钱包实例。此时,开发者所需完成的工作就是:
- 连上它。
- 得到当前实例连接到的网络和账户地址。
- 注册必要的事件处理函数。
以上三件事就是每个【Connect Wallet】必须实现的基本事项,至于其他的,比如说“若当前连接到的网络不符合预期则报错”,都属于锦上添花。这三件事对应下面的三段代码:
// 获得实例
provider = window.ethereum;
// 连接并获得当前账户地址
const accounts = await ethereum.request({ method: "eth_requestAccounts" });
const account = accounts[0];
// 获得当前链接的 chain id
const chainId = await ethereum.request({ method: "eth_chainId" });
// 注册事件处理函数
provider
.on("connect", async (connectInfo) => {
// 处理
})
.on("disconnect", (error) => {
// 处理
})
.on("accountsChanged", (accounts) => {
// 处理
})
.on("chainChanged", (chainId) => {
// 处理
});
这段代码并不复杂,但有需要留意几点:
- 获得
window.ethereum
并不是去连接 MM,而是获得注入的钱包实例。 - 真正的连接发生在
eth_requestAccounts
调用时,这对于初学者会有些困惑,但只需注意这一点就好了。即,获取账户地址的过程实际上就是连接钱包的过程。 eth_requestAccounts
并不是返回你在 MM 中创建的所有账户,而仅仅是已连接并未断开的那些账户。- 当前所连账户就是
eth_requestAccounts
返回数组的第一个值。 - 若一个账户已连接,除非手动断开,下载加载 dapp 时,它仍处于已连接状态,并且此时再调用
eth_requestAccounts
并不会导致弹出框的出现。 - 上面代码中的那些事件是针对 MM 而言,并非针对当个账户而言,即:
connect
,MM 连上网络,并非指账户连上应用disconnect
,MM 断开网络,同上accountsChanged
,MM 当前账户改变chainChanged
,MM 当前网络改变
显然,上面的代码片段太基础了,用于传授知识点合适,但对于实际开发就有点太简单了。通常,在这个时候我会建议大家直接去使用 Web3Modal。
直接使用 Web3Modal 会有这些好处:
- 直接利用工程化的代码,各位可以对比一下它的
injected.ts
代码,它同样实现了连接 MM,但明眼人一看就知道这个代码更完善。 - 给 dapp 带来支持多个 provider 的能力,让它一下子就可以同时支持多种连接方式,典型比如:MM、WalletConnect、Torus 等。
当然,Web3Modal 也并非尽善尽美:
- 它是 react 组件,对 react 有依赖。对于 react 应用还好,对于非 react 应用而言,则有一个多余的依赖。最坏的情况下是无法通过
npm i
安装。 - 需要预装 provider 包,导致 dapp 体积变大。
但是这些问题并不难化解,采用最朴素的 <script> 引入即可,请参考 Ethers.js 非权威开发指南(上)。
支持多 provider
使用 Web3Modal 支持多 provider 的步骤如下:
- 安装 web3modal
- 安装要支持的 provider
- 代码实现
最后一步的代码实现框架如下,以 MM、WalletConnect、Torus 为例。
// 列出要支持的 provider,
// MM 默认支持,不需要单独列出。
const providerOptions = {
walletconnect: {
package: WalletConnectProvider,
options: {
infuraId: "...",
},
},
torus: {
package: Torus,
},
};
// 初始化 web3modal 对象
const web3Modal = new Web3Modal({
network: "mainnet",
cacheProvider: true,
providerOptions,
});
export function isEnabled() {
return web3Modal.cachedProvider;
}
export async function connect() {
try {
const web3ModalProvider = await web3Modal.connect();
provider = new ethers.providers.Web3Provider(web3ModalProvider);
registerEthListener(web3ModalProvider);
updateCurrentStatus(await provider.listAccounts());
} catch (err) {
console.log(err);
}
}
function updateCurrentStatus(accounts) {
if (accounts.length === 0) {
console.log("no account");
} else {
saveCurrentAccount(accounts[0]);
}
}
function registerEthListener(web3ModalProvider) {
web3ModalProvider
.on("disconnect", (error) => {
reset();
location.reload();
})
.on("accountsChanged", (accounts) => {
if (accounts.length === 0) {
reset();
} else {
saveCurrentAccount(accounts[0]);
}
})
.on("chainChanged", (chainId) => {
console.log("chainChanged", chainId);
});
}
function reset() {
web3Modal.clearCachedProvider();
if (web3Modal.cachedProvider === "walletconnect") {
localStorage.removeItem("walletconnect");
}
}
代码中的注意点:
- 一旦连过,Web3Modal 会在
localStorage
中保存上次连过的 provider,可用于下次自动重连。这也是为何isEnabled
以此为判断依据的原因。 - 注意 provider 在不同上下文中的含义:
- ethers provider,eth rpc 连接
- web3 provider,钱包连接
- 钱包事件的监听要针对 web3 provider 而言,也就是代码中的
web3ModalProvider
。 - WalletConnect 本身也会在
localStorage
中保存状态,故reset
会一并清除。
自动重连
所谓自动重连就是指:dapp 在连过一次钱包之后,下次打开时会自动连上上次连接的钱包。这个功能的实现并不复杂。从前文我们已知:
- MM 一旦连上,除非手动断开,就会一直保持链接状态。并且,在此情况下在此调用
eth_requestAccounts
不会出现第一次连接时出现的弹出框。 - Web3Modal 缺省会在 localStorage 中记录状态。
这样一来,利用上面的框架代码,自动重连的功能其实就是在程序加载时调用下面的代码:
if (isEnabled()) {
await connect();
}
地址图标
细心的朋友会发现某些 dapp 还有更神奇的功能:应用中在地址旁的图标居然跟同一地址在 MM 中的图标一样!显然,一定有相关的 npm 可以做到这一点。猜的没错,它就是 @metamask/jazzicon
。
安装好之后,调用下面的代码:
import jazzicon from "@metamask/jazzicon";
function jsNumberForAddress(address) {
const addr = address.slice(2, 10);
const seed = parseInt(addr, 16);
return seed;
}
// 在需要的地方调用它
jazzicon(iconWidth, jsNumberForAddress(address));
理解 Permission
Permission 对应于 EIP-2255,其目的在于:为敏感操作提供一个统一和标准化的接口。在首次连接钱包时,弹出的确认对话框其实就是触发了 permission 机制。
与 Permission 对应的主要有两个方法:wallet_getPermissions
和 wallet_requestPermissions
。一般情况下,大多数开发者其实用不到它们。但某些情况下,出于 UX 方面的考虑,你可能得通过 permission 来实现某些 trick。
比如这个场景:在使用 Web3Modal 时,为了统一 UX,期望每次在切换 provider 进行连接时,dapp 都有某种提示。通俗来讲,它的需求就是(假设 dapp 同时提供了 MM 和 WalletConnect):
- 当选择 MM 时,出现确认对话框。
- 当选择 WalletConnect 时,出现二维码对话框。
- 当断开 WalletConnect 再次切换到 MM 时,还会出现对话框。
- 自动重连时不必如此。
简单吧?但这里隐藏着一个问题:前面说过,除非手动断开,MM 一旦连上它就一直保持连接状态。而在此状态下再次连接 MM 不会出现对话框。也就是说,在断开 WalletConnect 再去连 MM 时,对话框并不会出现。这是由于在第一步已经连过,所以它其实还是保持连接状态,此时再连对话框就不会出现了。
对于上面的需求,只能借助 Permission 实现。在再次重连时,附加下面的代码:
await window.ethereum.request({
method: "wallet_requestPermissions",
params: [
{
eth_accounts: {},
},
],
});
此时,即使在已连接状态下,对话框依旧会出现。在某种意义上来讲,这算得上是一种“诡计”,因为此时并没有一个实际断开再重连的过程。
添加网络和切换网络
在缺省情况下,MM 已经预置了若干网络,基本上就是 mainnet 加上若干测试网。随着以太坊生态的状态,出现了不少兼容 evm 的其他公链,典型比如 polygon,并且 MM 也对它们提供了支持。那么,假如 dapp 可以实现下面的功能岂不是很酷:
- 若发现当前 MM 所连的网络不对,提示用户并提供一个切换网络的链接或按钮。
- 在切换时,若发现当前 MM 没有配置该网络,自动给用户添加,然后再完成切换。
这两个步骤分别对应两个 rpc 方法:wallet_switchEthereumChain
和 wallet_addEthereumChain
。整个代码框架如下(以 ploygon 和 mumbai 为例):
const networkInfo = {
mumbai: {
chainId: "0x13881",
chainName: "Mumbai",
nativeCurrency: {
name: "MATIC",
symbol: "MATIC",
decimals: 18,
},
rpcUrls: ["https://rpc-mumbai.maticvigil.com"],
blockExplorerUrls: ["https://mumbai.polygonscan.com/"],
},
matic: {
chainId: "0x89",
chainName: "Polygon",
nativeCurrency: {
name: "MATIC",
symbol: "MATIC",
decimals: 18,
},
rpcUrls: ["https://rpc-mainnet.maticvigil.com/"],
blockExplorerUrls: ["https://polygonscan.com/"],
},
};
export async function switchNetwork(network: string, walletProvider) {
try {
await walletProvider.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: networkInfo[network].chainId }],
});
} catch (error) {
if (error.code === 4902) {
return await addNetwork(network, walletProvider);
}
throw error;
}
}
export async function addNetwork(network: string, walletProvider) {
try {
return await walletProvider.request({
method: "wallet_addEthereumChain",
params: [networkInfo[network]],
});
} catch (error) {
console.error(error);
}
}
请注意,这里传入的必须是 walletProvider,也就是 Web3Modal 的返回值,而不是 ethers 的 provider。
添加新的 Token
与添加网络一样,添加新 Token 同样是利用 rpc 完成:wallet_watchAsset
,这里以文档里的例子来说明:
ethereum.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: '0xb60e8dd61c5d32be8058bb8eb970870f07233155',
symbol: 'FOO',
decimals: 18,
image: 'https://foo.io/token-image.svg',
},
},
});
.then((success) => {
if (success) {
console.log('FOO successfully added to wallet!')
} else {
throw new Error('Something went wrong.')
}
})
.catch(console.error)
MM 还支持其他 rpc 方法,但大多数要么不常用,要么就可以直接通过 ethers provider 来完成,不必直接使用,有兴趣的可以参见文档。
使用钱包登录
现在的 dapp 有种趋势就是使用钱包连接来代替传统的登录,其基本流程大致如下:
- 用户点击连接钱包。
- 连接完成之后,提示用户完成签名。
- 签名完毕之后,显示完成登录。
看起来很时髦不是吗。既然所有数据都在链上,跟合约交互也只需钱包授权就行了,用钱包签名替代传统登录似乎是个合理的想法。实话实说,关于这种方式的合理性判断,传统的 “it depends” 要来的更合适。但是本文并不打算就此展开讨论,单就技术实现简单说说。
当下的 web 应用,jwt 应该已经成为了主流。如何要让钱包作为登录手段,替代传统的用户密码登录方式,那么最省事的方式就是能打通签名和 jwt 生成就行了。整个实现思路:
- dapp 发起钱包签名。
- 签名成功之后,发往后端验证。
- 后端验证签名成功之后,生成 jwt 传回。
- 未来所有请求都携带此 jwt 以表示已登录。
以上就是实现签名的最简步骤,更复杂的方式基本都是在此之上扩展的。思路讲完,让我们看看实际的代码。
客户端发起签名的代码(基于 ethers),代码中的 provider 是 ethers provider:
export async function sign(message: string, account: string) {
const signature = await provider.send("personal_sign", [
ethers.utils.hexlify(ethers.utils.toUtf8Bytes(message)),
account,
]);
return signature;
}
后端验证并生成 jwt 的代码(基于 ethers + fastify jwt):
// 对应的 url 为:/login/:address,处理 post 请求
async function generateJwtToken(request: FastifyRequest, reply: FastifyReply) {
const { address } = request.params as any;
const { message, signature } = request.body as any;
const addressVerified = ethers.utils.verifyMessage(message, signature);
if (addressVerified !== address) {
reply.code(403).send("Invalid signature.");
} else {
reply.code(200).send({ jwt: await reply.jwtSign({ address }, { expiresIn: "10s" }) });
}
}
这里略去了:
- 客户端发送请求和响应处理
- fastify controller 和 jwt 插件的配置
以上常规内容并不复杂,请自行解决。对于 ethers 不熟悉的,请参见前面的链接。
实现自己的钱包连接界面
为了满足界面的个性化要求 Web3Modal,提供了自定义主题的功能,参见这里。当然,你也可以做得跟 geek 一些:从 devtool 分析它的 css,然后覆盖它。
但假如你有更高要求呢?典型的如 1inch 的那种:
- 用户可以选择不同的网络。
- 在选择完网络之后,用户可以继续选择需要使用的 provider。
很遗憾,Web3Modal 无法完成以上需求,除了自行编写代码没有其他办法。但其实也没有想象中那么难,并且我们依旧可以 Web3Modal 的力量来完成这一点。只不过,是从源代码级别上。大体步骤如下:
git clone
Web3Modal 的源代码仓库。- 将其 src 下除 component 之外的其他文件都复制出来。
- 按需实现自己的界面。
以上做法的思路可以总结为:保留 provider 层,替换 ui 层,即 component 目录的内容。此外,这种方式还有另一个好处:不再依赖 react。Web3Moadal 项目除了 component 的代码依赖 react 之外,其余代码是框架中立的,移出该目录之后,各位大可使用自己喜爱的框架去实现 ui 层。
当然,这种方式对于开发者的代码能力有一定的要求,但好处也显而易见:从此将拥有自己的连接组件,够酷吧,😄。
这个自定义的连接组件的使用方式跟 Web3Modal 也一样:除了 MM 外,需要先安装想要使用的 provider。而且在实现过程中,我们可以参考 Web3Modal 的接口,尽量保持使用方式接近。
移动端和桌面端的差异
最后,简单说说移动端和桌面端的差异。
首先,操作方式上有差异:桌面端上,如 MM,在连上之后,还允许用户自行选择网络和切换账户。但是,典型的移动端钱包的使用方式基本都是:先选择网络和账户,再打开 dapp。
其次,桌面端上 walletProvider 存在的方法在移动端上不一定有,比如事件监听的 on
和 Permission 的 request
。
第三,某些 provider 在用移动端钱包打开时不可用,比如 WalletConnect。所幸,Web3Modal 会自动帮助我们处理这些。
以上的这些差异可能会要求 dapp 能智能判断,并依据不同的平台显示不同的界面。最典型的情况就是某些菜单项在移动端的时候不显示。
Web3Modal 提供了方便判断当前是桌面端还是移动端的方法:isMobile()
,可以利用它来作为判断条件。
此外,还可以在调用函数前先检查所需方法是否存在,若没有则返回即可。比如下面的代码片段:
function registerEthListener(walletProvider) {
if (!walletProvider || !walletProvider.removeAllListeners || !walletProvider.on) {
return;
}
//其余处理
}
上面的代码更加通用,且没有其他额外的依赖。
总结
对于 dapp 开发者而言,除了熟悉 ethers 之外,对于钱包交互也必不可少。本文则完整覆盖了这方面的主要的场景和知识点,并给出相应的代码示例和实现思路说明,为实现更高级和个性化的需求提供了一个坚实的基础。
本文包含付费内容,需要会员权限才能查看完整内容。