PunkWallet | Web3.0 dApp 开发(十一)
0x00 目标
PunkWallet是一个开源的Ethereum钱包,它是一个Burner Wallet,并且允许我们在不同的网络之间交易。可以通过WalletConnect的方式连接Web3网站。
本文将解析钱包源码(https://github.com/leeduckgo/punk-wallet, fork from https://github.com/scaffold-eth/scaffold-eth-examples/tree/punk-wallet)的实现,并且提取一些可以复用的模块/组件代码。
0x01 What Is a Burner Wallet
BurnerWallet主要用于Mint NFT或者与未审核的DAPP进行交互,可以理解为为了某个目的临时创建出来的,区分于你的主钱包的一种钱包。
Burner钱包可以是热钱包,也可以是冷钱包,您可以只保留少量的代币来支付mint或与任何智能合约交互的汽油费。
在PunkWallet上,可以非常方便地创建多个钱包,钱包之间可以非常方便地相互转账。
0x02 How To Use
一个Demo视频(英文):
https://www.youtube.com/watch?v=lYRd1k1RBAQ&feature=youtu.be
对于英文能力不佳的同学(比如我),可以看我下面总结的要点,但还是非常建议看原视频。
2.1 运行
首先,克隆仓库:
git clone https://github.com/leeduckgo/punk-wallet
然后进入仓库目录,安装所需的包,并且开启Hardhat服务:
yarn install
yarn chain
在新的终端开启你的前端服务:
yarn start
之后你就可以在http://localhost:3000访问你的页面了!
2.2 切换网络
可以点击这里切换网络,如果为localhost,则使用Hardhat的本地区块链网络
在本地网络下,可以通过左下角的faucet来领取Token。
2.3 生成钱包
点击右上角的钱包图标,在弹窗里点击“Generate"即可生成钱包,同样,你也可以在这里查看钱包的私钥、Import或删除钱包
注意,你也可以通过修改代码,让网站显示指定地址的钱包,但是由于缺少钱包私钥,你将没法进行上述操作
2.4 转账
在下方输入地址和金额进行转账交易,进行中的交易会显示在上方,你可以选择取消或加速,每次加速将多支付10%的gas费。
2.5 连接钱包
在PunkWallet上扫描WalletConnect二维码即可连接到对应网站
如果是在PC操作,可以直接复制二维码数据并粘贴到最下面的文本框进行连接。
2.6 签名&交易
连接好钱包后,在对应网站上发起签名/交易:
3.1 Provider
Web3Modal
Web3Modal是一个面向所有钱包的Web3/以太坊Provider的解决方案。它是一个易于使用的库,可帮助开发人员通过简单的可定制配置在其应用程序中添加对多个Provider的支持。默认情况下,Web3Modal库支持Injected Provider(如Metamask、Brave Wallet、Dapper、Frame、Gnosis Safe、Tally、Web3浏览器等)和WalletConnect。你还可以通过配置以支持Coinbase钱包、Torus、Portis、Fortmatic等:
https://www.npmjs.com/package/web3modal
这里主要分析Web3Modal在PunkWallet的使用,在App.jsx中,引入了Web3Modal并进行了基本的配置:
/*
Web3 modal helps us "connect" external wallets:
*/
const web3Modal = new Web3Modal({
// network: "mainnet", // optional
cacheProvider: true, // optional
providerOptions: {
walletconnect: {
package: WalletConnectProvider, // required
options: {
infuraId:INFURA_ID,
rpc: {
10: "https://mainnet.optimism.io", // xDai
100: "https://rpc.gnosischain.com", // xDai
137: "https://polygon-rpc.com",
31337: "http://localhost:8545",
42161: "https://arb1.arbitrum.io/rpc",
80001: "https://rpc-mumbai.maticvigil.com"
},
},
},
},
});其中,package的值为WalletConnectProvider:
https://www.npmjs.com/package/@walletconnect/web3-provider
配置完毕后,通过
await web3Modal.connect()
即可构造Web3Provider实例:const loadWeb3Modal = useCallback(async () => {
const provider = await web3Modal.connect();
provider.on("disconnect",()=>{
console.log("LOGOUT!");
logoutOfWeb3Modal()
})
setInjectedProvider(new Web3Provider(provider));
}, [setInjectedProvider]);运行效果:
BurnerProvider
BurnerProvider是一个可以生成临时密钥对的Web3Provider库:
https://www.npmjs.com/package/burner-provider
生成出来的秘钥会明文保存在LocalStorage,因此安全性较低,在LocalStorage没有私钥时,将会自动生成一个私钥,具体逻辑参考Github:
https://github.com/austintgriffith/burner-provider/blob/master/index.js
PunkWallet中,在没有Injected Provider下,自动使用BurnerProvider(默认也是自动使用BurnerProvider)
3.2 Transactor
该函数可以构造一个交易模板,通过传入配置参数发起交易,源码见:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/helpers/Transactor.js
该函数逻辑是,传入上述的Provider,返回一个发起交易的函数。通过从Provider里获取Signer、Network等数据来构建并发起一个交易。
注意,由于我们还需要实现一个交易管理功能(在没有Injected Provider情况下),所以在发起交易时需要将sendTransaction的结果保存,这将在下一节讲到。
这部分代码逻辑比较单一,直接参考源码即可。源码中与Notify、notification相关的语句仅用于前端提醒功能,和交易无关,可以忽略。
3.3 TransactionManager
该类用于管理PunkWallet发起的交易,交易数据会缓存在LocalStorage,可以对交易进行增删改(加速)查。
为了提高可读性,我稍微修改了一下TransactionManager的代码,源码见
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/helpers/TransactionManager.js
基础增、查
增:将发起的交易通过
setTransactionResponse
函数添加到TransactionManager
里,并通过setTransactionResponses
函数将交易列表保存至LocalStorage
查:调用
getTransactionResponse
函数获取指定nonce的交易数据。该函数通过调用getTransactionResponses
来读取LocalStorage中的交易列表。相关代码如下:
getStorageKey() { return STORAGE_KEY; }
getLocalStorageChangedEventName() { return LOCAL_STORAGE_CHANGED_EVENT_NAME; }
/**
*获取所有交易响应
*/
getTransactionResponses() {
const txResStr = localStorage.getItem(this.getStorageKey());
return txResStr ? JSON.parse(txResStr) : {};
}
/**
*设置交易响应状态
*/
setTransactionResponses(txResList) {
localStorage.setItem(this.getStorageKey(),JSON.stringify(txResList));
// StorageEvent doesn't work in the same window
window.dispatchEvent(newCustomEvent(this.getLocalStorageChangedEventName()));
}
/**
*获取交易响应数组
*/
getTransactionResponsesArray() {
return Object.values(this.getTransactionResponses())
}
/**
*获取单个交易响应
*/
getTransactionResponse(nonce) {
return this.getTransactionResponses()[nonce];
}
/**
*设置单个交易响应
*/
setTransactionResponse(txRes) {
const txResMap = this.getTransactionResponses();
txResMap[txRes.nonce] = txRes;
this.setTransactionResponses(txResMap);
}调用示例:
let signer = userProvider.getSigner();
// I'm not sure if all the Dapps send an array or not
let params = payload.params;
if (Array.isArray(params)) params = params[0];
// Ethers uses gasLimit instead of gas
params.gasLimit = params.gas;
delete params.gas;
// Speed up transaction list is filtered by chainId
params.chainId = targetNetwork.chainId
result = await signer.sendTransaction(params);
**const transactionManager = new TransactionManager(userProvider, signer, true);
transactionManager.setTransactionResponse(result);**改(加速)
加速本质上是修改Gas费上限,首先需要读取待加速的交易数据,并修改Gas费上限(默认提高10%),然后重新用signer发起这个交易
相关代码如下:
/**
* 加速交易
*/
speedUpTransaction(nonce, rate) {
rate ||= 10; // 等同于 rate = rate || 10,旧版本JS似乎无法识别该语法
const txParams = this.getSpeedUpTransactionParams(nonce, rate);
this.log("txParams", txParams);
return txParams && this.signer.sendTransaction(txParams);
}
/**
* 获取加速交易参数
*/
getSpeedUpTransactionParams(nonce, rate) {
const txRes = this.getTransactionResponse(nonce);
if (!txRes) return;
let txParams = this.getTransactionParams(txRes);
// Legacy txs
if (txParams.gasPrice)
txParams.gasPrice = this.getUpdatedGasPrice(txParams.gasPrice, rate);
// EIP1559
else {
txParams.maxPriorityFeePerGas = this.getUpdatedGasPrice(txParams.maxPriorityFeePerGas, rate);
// This shouldn't be necessary, but without it polygon fails way too many times with "replacement transaction underpriced"
txParams.maxFeePerGas = this.getUpdatedGasPrice(txParams.maxFeePerGas, rate);
}
return txParams;
}
/**
* 获取交易参数
*/
getTransactionParams(txRes) {
if (!txRes) return {};
const keys = ["type", "chainId", "nonce", "maxPriorityFeePerGas", "maxFeePerGas", "gasPrice", "gasLimit", "from", "to", "value", "data"];
return keys.reduce((res, key) =>
this.addTransactionParamIfExists(res, key, txRes[key]), {});
}
/**
* 添加交易传参数
*/
addTransactionParamIfExists(res, key, value) {
if (value === 0 || value) {
const keys = ["maxPriorityFeePerGas", "maxFeePerGas", "gasPrice", "gasLimit", "value"];
if (keys.includes(key))
value = BigNumber.from(value).toHexString();
res[key] = value;
}
return res
}
/**
* 计算升级后的Gas费
*/
getUpdatedGasPrice(val, rate) {
return BigNumber.from(val).mul(rate + 100).div(100).toHexString();
}删(取消)
取消本质上是用一笔空的交易覆盖待取消交易,相关代码如下:
/**
* 取消交易
*/
cancelTransaction(nonce) {
const txParams = this.getSpeedUpTransactionParams(nonce, 10);
// 修改交易参数
txParams.to = txParams.from;
txParams.data = "0x";
txParams.value = "0x";
this.log("txParams", txParams);
return this.signer.sendTransaction(txParams);
}查询确认中交易
上述展示的"删"和"改"的操作,是基于交易还没结束的前提下进行的,因此还需要写一个获取待确认交易列表的函数。
通过provider实时查询交易数据即可获得指定交易的当前已确认区块数(
confirmations
),该值为0则表示待确认,具体代码如下:/**
*获取交易确认
*/
async getConfirmations(txRes) {
const newTxRes = await this.provider.getTransaction(txRes.hash);
if (!newTxRes) {
this.log("getConfirmations newTxRes is undefined", txRes);
// I'm not sure what is this case, but it happened
// Maybe the transaction was just confirmed when SpeedUpTx button was hit,
// resulting in the previous response to be confirmed,
// and the new sped up hash to be invalid
// Also, sometimes the provider is faulty and returns null
let nonce = await this.provider.getTransactionCount(txRes.from);
if (txRes.nonce <= (nonce - 1)) {
console.log("getConfirmations nonce is already used", txRes);
// Transaction with the same nonce was already confirmed
this.removeTransactionResponse(txRes);
return -1;
}
return 0;
}
console.log("newTxRes", newTxRes)
return newTxRes.confirmations;
}
3.4 ConnectWallet以及事件监听
PunkWallet中,使用WalletConnect来连接钱包,代码如下:
let connector;
try {
connector = new WalletConnect(sessionDetails);
}
catch(error) {
console.error("Couldn't connect to", sessionDetails, error);
localStorage.removeItem("walletConnectUrl");
return;
}
其中,sessionDetails结构如下:
{
// Required
uri: walletConnectUrl,
// Required
// Change Place
clientMeta: {
description: "Forkable web wallet for small/quick transactions.",
url: "https://punkwallet.io",
icons: ["https://punkwallet.io/punk.png"],
name: "🧑🎤 PunkWallet.io",
},
}
walletConnectUrl为连接字符串(通过二维码扫描或复制得到)
成功连接后,通过以下语句来监听session和call:
connector.on("session_request", (err, payload) => ... )
connector.on("call_request", (err, payload) => ... )
具体代码参考App.jsx的232行:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/App.jsx
0x04 可复用组件分析
4.1 钱包主功能
该小节主要分析以下组件:
网络切换组件
先定义可切换的网络列表(部分代码已省略):
export const NETWORKS = {
ethereum: {
name: "ethereum",
color: "#ceb0fa",
chainId: 1,
price: "uniswap",
rpcUrl: `https://mainnet.infura.io/v3/${INFURA_ID}`,
blockExplorer: "https://etherscan.io/",
},
optimism: {
name: "optimism",
color: "#f01a37",
price: "uniswap",
chainId: 10,
blockExplorer: "https://optimistic.etherscan.io/",
rpcUrl: `https://mainnet.optimism.io`,
//gasPrice: 1000000,
},
......
polygon: {
name: "polygon",
color: "#2bbdf7",
price: "uniswap:0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0",
chainId: 137,
rpcUrl: "https://polygon-rpc.com",
faucet: "https://faucet.matic.network/",
blockExplorer: "https://explorer-mainnet.maticvigil.com//",
},
localhost: {
name: "localhost",
color: "#666666",
price: "uniswap", // use mainnet eth price for localhost
chainId: 31337,
blockExplorer: "",
rpcUrl: "http://localhost:8545",
}
};将上述列表转化为Select.Option:
const options = Object.keys(NETWORKS).map(key =>
<Select.Option key={key} value={NETWORKS[key].name}>
<span style={{ color: NETWORKS[key].color, fontSize: 24 }}>{NETWORKS[key].name}</span>
</Select.Option>
)然后使用Select实现选择框:
在PunkWallet中,每次切换网络都会刷新一次页面
<Select
size="large"
defaultValue={targetNetwork.name}
style={{ textAlign: "left", width: 170, fontSize: 30 }}
onChange={value => {
if (targetNetwork.chainId != NETWORKS[value].chainId) {
window.localStorage.setItem("network", value);
setTimeout(() => window.location.reload(), 1);
}
}}
>
{options}
</Select>钱包余额显示
获取余额
首先需要获取当前的钱包余额,我们可以通过provider获取余额,获取过程可以封装为一个callback:
const [balance, setBalance] = useState();
const pollBalance = useCallback(
async (provider, address) => {
if (!provider || !address) return;
const newBalance = await provider.getBalance(address);
if (newBalance !== balance) setBalance(newBalance);
},
[provider, address],
);我们通过调用pollBalance即可获取address的余额,为了增强可复用性,我们可以写一个Hook来封装这个逻辑:
export default function useBalance(provider, address) {
const [balance, setBalance] = useState();
const pollBalance = useCallback(
async (provider, address) => {
if (!provider || !address) return;
const newBalance = await provider.getBalance(address);
if (newBalance !== balance) setBalance(newBalance);
},
[provider, address],
);
// useOnBlock将pollBalance的操作封装到provider的"block"事件中
// 每当block改变时都会触发该事件,带一个blockNumber参数,具体实现请参考源码
useOnBlock(provider, () => {
if (provider && address) pollBalance(provider, address);
});
return balance;
}为了简化逻辑,这里的代码和源代码有一些出入,具体请看源码:punk-wallet/Balance.js at master · leeduckgo/punk-wallet (github.com)
获取币价
我们除了需要获取余额数量外,还需要获取实时币价,可以通过Uniswap获取币价。
我们在NETWORKS里定义的网络数据,里面包含了币价的获取方式。price为
"uniswap"
表示通过Uniswap获取币价,对于非ETH,我们在"uniswap"
后加上代币的合约地址,比如polygon下对应的price为:"uniswap:0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB"
这里需要用到Uniswap的SDK:@uniswap/sdk - npm (npmjs.com)
那么如何计算币价呢?我们首先构造一个DAI Token对象,来获取DAI到ETH的价格:
const chainId = mainnetProvider.network ? mainnetProvider.network.chainId : 1;
const ETH = WETH[chainId];
const DAI = new Token(chainId, "0x6B175474E89094C44Da98b954EedeAC495271d0F", 18);
const pair = await Fetcher.fetchPairData(DAI, ETH, mainnetProvider);
const route = new Route([pair], ETH);
// 获取ETH在DAI中的价格
const priceOfETHinDAI = parseFloat(route.midPrice.toSignificant(6));然后解析上述的price,构造对应代币的Token对象,并计算代币在ETH中的价格,得到两者价格后即可算出最终币价:
const contractAddress = targetNetwork.price.replace("uniswap:", "");
if (contractAddress) {
const TOKEN = new Token(chainId, contractAddress, 18);
const pair = await Fetcher.fetchPairData(ETH, TOKEN, mainnetProvider);
const route = new Route([pair], TOKEN);
// 先获取当前代币在ETH中的价格,再用此价格乘以ETH在DAI中的价格,即可算出最终币价
const price = parseFloat(route.midPrice.toSignificant(6) * priceOfETHinDAI);
setPrice(price); // const [price, setPrice] = useState(0);
} else {
setPrice(priceOfETHinDAI);
}当然,上述逻辑我们也可以封装成一个Hook,具体参考源码:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/hooks/ExchangePrice.js
前端展示
获取了这些数据后,前端展示就很容易实现了,我们只需要对显示格式做一些处理,并加一个代币/法币的显示切换功能即可。这里就不给出代码了,感兴趣的同学可以自行查看源码:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/Balance.jsx
钱包二维码显示
使用qrcode.react库绘制QRCode:
https://www.npmjs.com/package/qrcode.react
使用react-blockies库绘制二维码中心的随机图案:
https://www.npmjs.com/package/react-blockies
把这两个库合并起来即可得到钱包二维码~具体细节请看源码:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/QRPunkBlockie.jsx
转账交易功能
使用react-qr-reader库读取QRCode:
https://www.npmjs.com/package/react-qr-reader
封装一个地址输入框,实现输入地址并自动解析ENS域名并显示钱包头像。钱包头像可以复用上一节封装好的QRPunkBlockie组件。
由于用户输入的可能是ENS域名,我们需要一个函数处理输入逻辑。在本例,我们判断输入地址是否以
".eth"
或".xyz"
结尾,如果是,解析出对应的钱包地址。对应代码如下:if (address.endsWith(".eth") || address.endsWith(".xyz")) {
try {
const possibleAddress = await ensProvider.resolveName(address);
if (possibleAddress) address = possibleAddress;
} catch (e) {}
}金额输入的实现比较简单,这里就略过,我们完成地址输入和金额输入后,最后需要做转账按钮。该按钮将生成一定的交易参数,触发上文提到过的Transactor任务。部分代码如下:
const gasPrice = useGasPrice(targetNetwork, "fast");
const userProvider = useUserProvider(injectedProvider, localProvider);
const tx = Transactor(userProvider, gasPrice, undefined, injectedProvider);
<Button key="submit" type="primary" loading={loading}
disabled={loading || !amount || !toAddress}
onClick={async () => {
setLoading(true);
let value;
try { value = parseEther(amount.toString()) }
catch (e) {
const floatVal = parseFloat(amount).toFixed(8);
// failed to parseEther, try something else
value = parseEther(floatVal.toString());
}
const txConfig = {
to: toAddress, chainId: selectedChainId, value, gasPrice
}
const txTask = tx(txConfig);
setAmount(""); setData("");
const result = await txTask;
setLoading(false);
console.log(result);
}}
>
{loading || !amount || !toAddress ? <CaretUpOutlined /> : <SendOutlined style={{ color: "#FFFFFF" }} />}{" "}
Send
</Button>
4.2 SpeedUpTransactions
在这里,我们会分析一下交易列表的代码
源码参考:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/SpeedUpTransactions.jsx
该组件基于上文提到的TransactionManager,因此界面的逻辑相对简单,主要负责对数据进行筛选(筛选出我们主动发起的交易),变化的监听即可。
数据筛选:
const transactionManager = new TransactionManager(provider, signer, true);
const [transactionResponsesArray, setTransactionResponsesArray] = useState([]);
const initTransactionResponsesArray = () => {
setTransactionResponsesArray(
injectedProvider ? [] : // 如果有Injected Provider,我们不需要管理交易
filterResponsesAddressAndChainId(
transactionManager.getTransactionResponsesArray()));
}
const filterResponsesAddressAndChainId = txResList =>
// 筛选出自己发起的数据
txResList.filter(txRes => txRes.from === address && txRes.chainId === chainId)
通过设置对localStorage的监听器,实现交易列表的实时更新显示:
useEffect(() => {
initTransactionResponsesArray();
// Listen for storage change events from the same and from other windows as well
window.addEventListener("storage", initTransactionResponsesArray);
window.addEventListener(transactionManager.getLocalStorageChangedEventName(), initTransactionResponsesArray);
return () => {
window.removeEventListener("storage", initTransactionResponsesArray);
window.removeEventListener(transactionManager.getLocalStorageChangedEventName(), initTransactionResponsesArray);
}
}, [injectedProvider, address, chainId]);
其中,transactionResponsesArray是未完成的交易数组,将该数组渲染出来,加上对应的Cancel和SpeedUp按钮,并触发TransactionManager内的对应函数即可实现我们的需求。
还有一点要注意,当一个交易完成后,需要自动清除,逻辑如下:
const updateConfirmations = async () => {
const confirmations = await transactionManager.getConfirmations(transactionResponse);
if (confirmations >= 1)
transactionManager.removeTransactionResponse(transactionResponse);
}
在PunkWallet中,由TransactionResponseDisplay处理以上逻辑:
https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/TransactionResponseDisplay.jsx
0x05 相关资料
https://github.com/scaffold-eth/scaffold-eth https://github.com/leeduckgo/punk-wallet web3modal - npm (npmjs.com) burner-provider - npm (npmjs.com) @uniswap/sdk - npm (npmjs.com) Hardhat | Ethereum development environment for professionals by Nomic Foundation
往期回顾:
dApp 实用开发存储指南之 Gist | Web3.0 dApp 开发(十)
Vercel 极速入门 | Web3.0 dApp 开发(八)
Token 自动售卖机 | Web3.0 dApp 开发(七)
SVG NFT 全面实践 | Web3.0 dApp 开发(六)
Scaffold-eth 快速上手 | Web3.0 dApp 开发(二)
eth.build 快速上手 | Web3.0 dApp 开发(一)