以太坊官网:https://ethereum.org/zh/。
web3官方教程:https://web3js.readthedocs.io/
以太坊区块链浏览器:https://etherscan.io/
以太坊开发文档:https://ethereum.org/zh/developers/docs/
以太坊JSON-RPC 应用程序接口:https://ethereum.org/zh/developers/docs/apis/json-rpc/
小狐狸(MetaMask)文档:https://docs.metamask.io/
币安钱包文档:https://docs.bnbchain.org/docs/wallet_api
注:以下的前端界面使用vite + react
开发,每次粘贴的都是片段。
实例化Web3的过程,就是给web3.js设置一个 服务提供者。
服务提供者就是一个包含节点服务器的 RPC 地址的对象,web3和节点服务交互时,会把所有的请求给这个服务提供者,服务提供者再去和节点服务器交互。
所以实例化Web对象需要先生成服务提供者。
RPC有三种类型:HTTP、WebSocket、IPC,所以创建服务提供者也有三个方法:
但实际这一步可以省略不写,这部分主要是了解内部逻辑,原因见后续内容。
// HTTP类型的服务提供者
const provider = new Web3.providers.HttpProvider(RPC_HTTP)
// Websocket类型的服务提供者
const provider = new Web3.providers.WebsocketProvider(RPC_WebSocket)
// IPC类型的服务提供者
const provider = new Web3.providers.IpcProvider(RPC_IPC)
有了服务提供者,可以实例化web3了:
// 使用上一步创建的服务提供者实例化 web3
const web3 = new Web3(provider)
但其实,如果是使用RPC,我们不用手动创建服务提供者。
可以直接把RPC传给Web3来进行实例化,Web3会自动判断生成服务提供者并使用:
const web3 = new Web3(RPC_HTTP)
// or
const web3 = new Web3(RPC_WebSocket)
// or
const web3 = new Web3(RPC_IPC)
当浏览器安装了小狐狸等钱包插件,那么我们可以直接使用钱包作为服务提供者。
如果刚刚安装了小狐狸,甚至还没有打开、没有配置过,那他默认配置的是以太坊主网络。
// 小狐狸插件未正常启用
if (!window.ethereum) return
// 可以获取网络 chainId,判断是否是自己想要的网络
console.log(parseInt(window.ethereum.chainId, 16))
// 如果是以太坊主网,则打印数字 5
// 把 window.ethereum 作为服务提供者,实例化 web3
const web3 = new Web3(window.ethereum)
在以太坊兼容的浏览器中使用 web3.js 时,Web3.givenProvider 将由该浏览器设置为当前的本机提供程序,否则为 null。
也就是说,如果浏览器已经安装了小狐狸,那么:
Web3.givenProvider === window.ethereum
平时可以这样使用:
// RPC 是自己提前定义好的地址
const web3 = new Web3(Web3.givenProvider || RPC)
但如果这么使用,无法保证小狐狸当前连接的网络是自己需要的,还是要开发者判断。
实例化web3后,可以随时查看当前的服务提供者对象:
const currentProvider = web3.currentProvider
如果当前的服务提供者是自己设置的 RPC,那么 currentProvider 对象为:
{
"timeout": 0,
"connected": false,
"host": "https://...省略", // 这一项就是 RPC 地址
"httpsAgent": {}
}
如果当前的服务提供者是钱包,那么 currentProvider 对象其实就是钱包对象:
console.log(currentProvider === window.ethereum)
// 打印 true
我们可以直接给现有的web3实例切换服务提供者:
// provider 是新的服务提供者,可以是 RPC,可以是钱包对象,也可以是生成好的 provider 对象
web3.setProvider(provider)
现在页面开发,只把钱包作为服务提供者的只是少数,更多的是通过钱包,获取当前用户使用的账户地址,能省的用户手动在页面输入这一步。
下面的代码执行后,小狐狸好会弹出提示框,按提示确认后,即可允许某几个账户连接当前网站。
有的教程使用的可能是 window.ethereum.enable() 这个方法,这个方法已废弃,不再建议使用。
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' })
console.log(accounts)
// ['0x53c985340d30535C0a4E9513F1b009ACA6A690c2']
// 打印的是用户允许连接此网站的账户数组,正常是只有一项,如果是多项,那第一项是当前用户要使用的账户地址。
注意:钱包会为钱包中的每个账户,都记录「已连接的网站」列表,当用户允许某网站连接账户后,会自动把此网站地址放入「已连接的网站」列表中。
日后,这个页面再请求账户列表,小狐狸会直接返回一个数组,数组中只有一项,就是允许连接此网站的某个账户地址,优先返回用户当前正在使用的账户地址。
把 window.ethereum 作为服务提供者实例化 web3 后,可以使用 web3 的方法读取这个数组,如果没有账户连接过此页面,那得到的是空数组。
如果是使用 RPC 实例化的 web3,则只能获取到一个空数组。
const accounts = await web3.eth.getAccounts()
监听小狐狸这个插件的一些事件,以便当用户修改了账户、断开了网站连接时做应对处理。
有些事件可以不连接小狐狸,页面就能监听触发;而有些在需要连接后再操作小狐狸,页面才能监听到。
// 小狐狸MetaMask的事件监听
const events = {
accountsChanged(accounts) {
// 指的是此页面连接的账户,连接小狐狸后再切换账户,此事件才会触发
console.log('切换了账户', accounts)
},
chainChanged(chainId) {
// 16转10进制
chainId = parseInt(chainId, 16)
console.log('切换了链', chainId)
},
connect(connectInfo) {
console.log('连接合约成功', connectInfo)
},
disconnect(error) {
console.log('断开连接', error)
},
message(message) {
console.log('一些消息', message)
},
}
// 添加小狐狸的事件监听
export function addEvent() {
if (!window.ethereum) return [false, '小狐狸插件未正常启用']
window.ethereum.on('accountsChanged', events.accountsChanged)
window.ethereum.on('chainChanged', events.chainChanged)
window.ethereum.on('connect', events.connect)
window.ethereum.on('disconnect', events.disconnect)
window.ethereum.on('message', events.message)
return [true]
}
// 移除小狐狸的事件监听
export function removeEvent() {
if (!window.ethereum) return
window.ethereum.removeListener('accountsChanged', events.accountsChanged)
window.ethereum.removeListener('chainChanged', events.chainChanged)
window.ethereum.removeListener('connect', events.connect)
window.ethereum.removeListener('disconnect', events.disconnect)
window.ethereum.removeListener('message', events.message)
}
调用小狐狸方法文档:RPC API。
这里只列举两个可能常用的方法。
可能用户当前连接的网络不是自己页面需要的,则需要调用小狐狸方法来切换。
小狐狸会弹出确认提示框,用户确认后切换成功。
注意,如果用户的小狐狸没有这个网络,则会报错,error.code 为 4902。
// 以太坊主网络的 chainId 为 1,这里需要转为 16 进制
const chainId = `0x${Number(1).toString(16)}`
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId }],
})
可能用户的小狐狸钱包中,没有我们页面需要的网络,此时我们可以调小狐狸方法来添加。
小狐狸同样会弹框询问用户是否允许添加,添加完成后会要求用户切换到此网络。
下面的添加是不会成功的,因为小狐狸已经内置了这个官方的测试网络,下面的网络配置只是演示:
// goerli 测试网络的配置
const goerliTestnet = {
chainId: `0x${Number(5).toString(16)}`, // 网络的 chainId
chainName: 'Goerli 测试网络', // 网络的名称,展示给用户看的,自定义即可
nativeCurrency: {
name: 'GoerliETH', // 主要币种的名称
symbol: 'GoerliETH', // 主要币种的符号
decimals: 18, // 主要币种的精度
},
rpcUrls: ['https://goerli.infura.io/v3/'], // 节点服务的 RPC 地址
blockExplorerUrls: ['https://goerli.etherscan.io'], // 区块链浏览器地址
}
// 调用方法添加
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [goerliTestnet],
})
可能用户的小狐狸钱包中,没有我们需要的某种代币,此时我们可以调小狐狸方法来给这个账户添加。
无论此账户当前是否有这种代币,调用方法后,小狐狸必然会弹框请求用户添加这一代币。
// goerli 测试网络的配置
const CoinConfig = {
type: 'ERC20',
options: {
address: '0xb60e8dd61c5d32be8058bb8eb970870f07233155',
symbol: 'FOO',
decimals: 18,
image: 'https://foo.io/token-image.svg',
},
}
// 调用方法添加
await window.ethereum.request({
method: 'wallet_watchAsset',
params: CoinConfig,
})
下面两个方法在展示余额、发起交易时,非常常用。
因为以太坊世界的数量、余额使用的单位是wei,但平时展示给用户的都是以某币种为单位的,需要使用这个币种的精度来转换一下。
// @/utils/index.js 文件
import Decimal from 'decimal.js'
/**
* wei 为单位值转为币种单位的字符串值
* @param {*} amount 从区块链中取的的以 wei 为单位余额
* @param {*} decimals 此币种的精度
* @returns {String} 币种为单位的字符串值
*/
export function format2Balance(amount, decimals) {
if (!amount || !decimals) return amount
const decimalsFlag = Math.pow(10, decimals)
const amountDec = new Decimal(amount);
const balance = amountDec.div(decimalsFlag).toString()
return balance
}
/**
* 币种单位的值转为 wei 为单位的字符串值
* @param {*} balance 币种为单位的值
* @param {*} decimals 此币种的精度
* @returns {String} wei为单位的字符串值
*/
export function format2Amount(balance, decimals) {
if (!balance || !decimals) return balance
const decimalsFlag = Math.pow(10, decimals)
const balanceDec = new Decimal(balance);
const amount = balanceDec.mul(decimalsFlag).toString()
return amount
}
import { format2Balance } from '@/utils'
const web3 = '...' // 已实例化完成
// 获取账户地址
async function getAccount() {
const accountArr = await web3.eth.getAccounts()
const account = accountArr[0]
}
// 获取余额
async function getBalance(account) {
const amount = await web3.eth.getBalance(account)
const balance = format2Balance(amount, 18) // ETH 的精度是 18
}
// 获取链id
async function getChainId() {
const chainId = await web3.eth.getChainId()
// or
const chainId = parseInt(window.ethereum.chainId, 16)
}
// 获取网络id
async function getNetworkID() {
const netId = await web3.eth.net.getId()
}
// 获取网络类型/名称,比如 goerli
async function getNetType() {
const netType = await web3.eth.net.getNetworkType()
}
网络ID(NetworkID),主要用来在网络层标识当前的区块链网络,NetworkId 不一致的两个节点无法建立连接。
以太坊本来只有网络ID,并没有链id(ChainId)。
但以太坊发生了分叉,为了避免一个交易在签名之后被重复在不同的链上提交,在EIP155引入了ChainID,通过在签名信息中加入ChainID避免了bug。
创建账户,拿到新账户地址和私钥。
const web3 = '...' // 已实例化完成
async function createAccount() {
// 可选参数 entropy:增加熵的随机字符串。如果给出,它应该至少为 32 个字符。如果没有给出一个随机字符串,将使用randomhex生成
// 下面我就没有传参数
const result = await web3.eth.accounts.create()
console.log('账户地址', result.address)
console.log('账户私钥', result.privateKey)
}
当进行转账、查询余额时,偶尔需要用户输入地址,作为参数和区块链交互。
web3.js包含工具对象,可以使用里面的方法来校验地址是否合法,方法返回Boolean值。
因为这个工具对象和区块链无关,可以直接使用未实例化的 Web3 类。
web3 工具类的文档地址:web3.utils。
const address = '一个地址'
// 直接从未实例化的 Web3 类中读取工具方法
const isAddress = Web3.utils.isAddress(address)
// 或者从已经实例化后的 web3 中读取工具方法
const isAddress = web3.utils.isAddress(address)
签名消息是为了加密,签名的过程会使用账户地址和账户私钥进行加密,事后可以通过反向计算,算得该条加密字符串是否是某账户的私钥签名的。
连接小狐狸后,直接使用小狐狸进行消息签名,会调起小狐狸进行确认:
// 签名消息
async function sign() {
const message = '这是要签名的消息'
const account = '...' // 要签名的账户,也就是当前已连接网站的小狐狸中的当前的账户地址
const signature = await window.ethereum.request({
method: "personal_sign",
params: [message, account],
})
}
这个需要先连接小狐狸,也是会调起小狐狸,和直接调用小狐狸的签名方法效果一样。
web3.js只是把钱包的签名方法进行了包装兼容而已。
// 签名消息
async function sign() {
const message = '这是要签名的消息'
const account = '...' // 要签名的账户,也就是当前已连接网站的小狐狸中的当前的账户地址
const signature = await web3.eth.personal.sign(message, account)
}
币安链钱包比较特殊,币安链钱包中的BSC链(也是ERC20网络,币安钱包对ETH的兼容)可以仍然使用上面的 web3 调用。
但币安链BEP2网络,需要直接调用币安钱包对象的 BinanceChain.bnbSign 方法来进行签名(可至钱包文档页查看更详细内容):
const message = '这是要签名的消息'
const account = '...' // 要签名的账户,也就是当前已连接网站的小狐狸中的当前的账户地址
// 直接调用币安钱包对象的方法来签名
// 返回的 signature 和 publicKey 可能在验证时需要
let { signature, publicKey } = await BinanceChain.bnbSign(account, message)
验证签名,需要拿到被加密的原信息字符串和加密后的signature,使用web3.js的方法,能得到签名时的账户地址。
判断这个账户地址是否是预期的账户,就可以验证了。
// dataThatWasSigned 被加密的原信息字符串
// signature 加密后的信息
const account = await web3.eth.personal.ecRecover(dataThatWasSigned, signature)
以太坊中的所有代币、NFT,本质都是智能合约。
WETH这个代币,本质就是一个符合 ERC-20 的智能合约,它的地址就是合约地址。
哪个用户拥有多少WETH,都是这个合约去保存的,想要转账,也需要通知这个合约去交互。
连接合约最简单,需要下面两个参数:
TypeScript
中的接口文件,查看这个abi,就能看到这个合约支持那些属性、方法,方法要传入的参数和类型,已经返回的参数和类型。import web3 from '@/web3'
// 引入连接合约需要的地址和ABI
import { WETH_ABI, WETH_address } from '@/config'
// 连接合约,实例化WETH的合约对象
const WETH_Contract = new web3.eth.Contract(WETH_ABI, WETH_address)
合约的方法都在 methods
中。
调用非上链方法
非上链方法基本都是只读方法,不需要上链、记账,调用起来比较简单,调用后直接再调用 .call()
即可。
比如 balanceOf
就是查询某用户拥有的代币余额的方法。
const balanceWei = await WETH_contract.methods.balanceOf(account).call()
其他非上链方法同理,调用时传入响应的参数是,最后再调用 call()
即可。
调用上链方法
链上方法需要调用 send()
方法发送到链上。
需要给 send 传入参数,告知这笔交易的发起方、发送目标等,手续费也可以在这里配置,具体参数可见:methods.myMethod.send。
因为上链方法比较复杂,且方式很多,下面依次举例介绍。
下面就转账功能,进行具体的代码示例,调用合约的其他上链方法也都类似。
想要把1个WETH这种代币转给A账户地址,需要:
先连接节点服务器
再连接WETH的智能合约
再调用这个合约的转账方法,创建一笔把1个WETH转给A地址的交易申请。
把这个交易申请发送到链上,配置参数:
钱包会弹出签名确认框,用户点击确认后,会扣除对应的1WETH,和作为手续费的一定量的ETH。
注意,上面值转账某种代币,如果想转账此链的主币,比如ETH,则不需要使用合约,请见下一条「账户间转账ETH」教程。
// 第1步已完成,引入已连接好小狐狸的web3
import web3 from '@/web3'
// 引入连接合约需要的地址和ABI
import { WETH_address, WETH_ABI } from '@/config'
import { format2Amount } from '@/utils'
async function transfer(myAccount, toAccount, value) {
// 第2步,连接合约,得到WETH的合约对象,上面ABI定义的属性、方法,可以通过这个对象访问
const WETH_contract = new web3.eth.Contract(WETH_ABI, WETH_address)
// 第3步,创建一笔把1个WETH转给A地址的交易申请
// 获取精度
const decimals = await WETH_contract.methods.decimals().call() - 0
// 得到以wei为单位的数量
const amount = format2Amount(value, decimals)
// 生成交易函数对象
const transaction = WETH_contract.methods.transfer(toAccount, amount)
// 第4步,发出交易到链上,接着第5步,小狐狸自动弹出签名确认框
// 下面 send 第二个参数,也就是获取交易hash的方法,是非必传的
// 有的公司业务要求及时保存每笔交易的交易hash,可以用上
// 先组装发送到链上的配置
const config = {
from: myAccount, // 我的账户地址,这个交易是我发起的
to: WETH_address, // 代币合约地址,因为这个交易需要这个合约进行,所以要发送给这个合约
}
const result = await transaction.send(config, (error, hash) => {
if (error) {
message.warn('出错了!')
} else {
// 得到了这个交易hash,可以轮询调用web3方法,去链上查询这个交易hash的结果
// 也可以等待上面的 await,结果一样的
console.log(hash)
}
})
}
上面的第4步,发出交易,可以使用web3.js的 sendTransaction
方法替换,只不过需要把 transaction 这个交易单生成code,入参也有些变化。
sendTransaction 和合约方法无关,**sendTransaction **只是把某些请求发送到链上,基本所有上链操作都可以使用它。
具体可见教程:web3.eth.sendTransaction
以下代码,删除了前3步多余的注释:
import web3 from '@/web3'
import { WETH_address, WETH_ABI } from '@/config'
import { format2Amount } from '@/utils'
function transfer(myAccount, toAccount, value) {
const WETH_contract = new web3.eth.Contract(WETH_ABI, WETH_address)
const decimals = await WETH_contract.methods.decimals().call() - 0
const amount = format2Amount(value, decimals)
const transaction = WETH_contract.methods.transfer(toAccount, amount)
// 第4步,发出交易到链上,使用 web3.eth.sendTransaction 方法
const config = {
from: myAccount,
to: WETH_address,
data: transaction.encodeABI(), // 这里需要把上面的交易的code放在这里
}
const result = await web3.eth.sendTransaction(config, (error, hash) => {
if (error) {
message.warn('出错了!')
} else {
console.log(hash)
}
})
}
钱包基本都会提供直接调用 JSON-RPC 的方法,小狐狸插件的话,就是使用小狐狸的 window.ethereum.request 方法,去调用 JSON-RPC 的 eth_sendTransaction 方法。
小狐狸钱包的方法文档:window.ethereum.request(args)。
JSON-RPC 的 eth_sendTransaction 文档:eth_sendTransaction。
import web3 from '@/web3'
import { WETH_address, WETH_ABI } from '@/config'
import { format2Amount } from '@/utils'
function transfer(myAccount, toAccount, value) {
const WETH_contract = new web3.eth.Contract(WETH_ABI, WETH_address)
const decimals = await WETH_contract.methods.decimals().call() - 0
const amount = format2Amount(value, decimals)
const transaction = WETH_contract.methods.transfer(toAccount, amount)
// 第4步,发出交易到链上,使用钱包插件小狐狸的 window.ethereum.request 方法
const config = {
from: myAccount,
to: WETH_address,
data: transaction.encodeABI(),
}
// 注意这里,等用户点击同意后,这里会立即拿到交易hash,需要自己使用 web3 方法轮询查询交易结果
const hash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [config],
})
}
发送交易很容易,合约、web3.js和钱包插件都有对应的方法,最关键的一步是签名,需要使用私钥签名的那一步。
之所以要连接钱包的一大原因,就是需要使用钱包让用户签名,方便用户操作。
但一些运行在服务端的服务,私钥就掌握在我们自己手里,这是就可以只用用私钥签名,无需钱包了,当然在前端页面里面也行,只要有私钥。
web3.js提供了 web3.eth.accounts.signTransaction 方法,允许用户传入交易对象和账户私钥来对交易进行签名。
但这个方法传入的交易对象中必填 gas 字段,我们可以自己配置,也可以调用 web3.eth.estimateGas 来进行一个 模拟调用,发起一个假的交易申请,方法会返回完成这笔交易需要花费的 gas,直接把这个 gas 添加到交易对象中,再去进行签名即可。
此外,因为要发送的交易已经签名过了,所以需要使用 web3.eth.sendSignedTransaction 方法来发送交易。
import web3 from '@/web3'
import { WETH_address, WETH_ABI, myPrivateKey } from '@/config'
import { format2Amount } from '@/utils'
function transfer(myAccount, toAccount, value) {
const WETH_contract = new web3.eth.Contract(WETH_ABI, WETH_address)
const decimals = await WETH_contract.methods.decimals().call() - 0
const amount = format2Amount(value, decimals)
const transaction = WETH_contract.methods.transfer(toAccount, amount)
// 第4步,发出交易到链上
const config = {
from: myAccount,
to: WETH_address,
data: transaction.encodeABI(),
}
// 调用模拟调用方法,得到 gas 值
const newGas = await web3.eth.estimateGas({...config})
// 赋值 gas
config.gas = newGas
// 调用签名方法进行签名
const signResult = await web3.eth.accounts.signTransaction(
config,
myPrivateKey, // 账户的私钥
)
// 发送交易
const result = await web3.eth.sendSignedTransaction(signResult.rawTransaction, (error, hash) => {
if (error) {
message.warn('出错了!')
} else {
console.log(hash)
}
})
}
上面使用的是合约转账,转的都是代币,需要连接代币的智能合约,让智能合约去操作。
但如果想转账此网络的官方主币,比如ETH,就不用那么麻烦了,流程如下:
// 第1步已完成,引入已连接好小狐狸的web3
import web3 from '@/web3'
import { format2Amount } from '@/utils'
function transfer(myAccount, toAccount, value) {
const amount = format2Amount(value, 18) // ETH 的精度固定是18,这里可是写死
// 第2步,发出交易到链上,使用 web3.eth.sendTransaction 方法
const config = {
from: myAccount,
to: toAccount,
value: amount,
}
const result = await web3.eth.sendTransaction(config, (error, hash) => {
if (error) {
message.warn('出错了!')
} else {
console.log(hash)
}
})
}
和调用合约的上链方法类似,上面的第2步也可以换用其他的多种方式。
交易hash在发起交易时,通过传入的第二个参数回调函数来获取,也可以等待交易结果,在交易结果中的 transactionHash
就是此次交易的交易hash。
这个方法会得到当前这个hash对应的交易的状态,如果交易刚刚发生,可能查到的是null。
所以我们需要自己写轮询,半秒或一秒调一次,直到得到一个对象,读取对象中的 status 来判断交易是否成功。
import web3 from '@/web3'
const result = await web3.eth.getTransactionReceipt(hash)
if (!result) console.log('交易尚未完成')
else if (result.status) console.log('交易完成')
else console.log('交易失败')