本文将大致讲解 Ethereum 及 EVM 兼容链的余额查询、转账操作。代码示例主要用于原生币,ERC-20 代币的差异将在后面单独说明。
本文使用 Web3.js v4,以 Ethereum Sepolia 测试网为例。不同链的交易类型和费用规则可能存在差异,不能只换一个 RPC 地址,就认为所有逻辑都能直接复用。
另外,Web3.js 官方已经公告于 2025 年 3 月 4 日停止维护。本文保留这套实现,主要用于记录和理解交易流程。相关说明可以查看 Web3.js 官网。
前文已经创建好了助记词,同时也创建好了以太坊账户,获取到了账户地址。
现在,为了查询链上数据、广播交易,或者与智能合约交互,需要连接 RPC 节点。应用可以通过节点提供的 JSON-RPC 接口进行这些操作,常见的连接方式有 HTTP 和 WebSocket。
注意,连接节点时,要确认节点来源可信。也可以自行搭建节点,或者使用服务商提供的专用 RPC 服务。使用私有地址或付费服务,并不意味着返回的数据就一定可信。
下面为了让示例能够独立理解,重新生成一个账户。如果需要接着前文操作,直接复用原来的账户即可,不要每次运行都生成新的助记词。
import { generateMnemonic, mnemonicToSeedSync } from "bip39";
import { Web3 } from "web3";
import { Wallet } from "@ethereumjs/wallet";
import HDKey from "hdkey";
import type { Transaction } from "web3-types";
// 生成助记词、主节点与 Ethereum 派生节点
const mnemonic = generateMnemonic();
const seed = mnemonicToSeedSync(mnemonic);
const root = HDKey.fromMasterSeed(seed);
const ethHDKey = root.derive("m/44'/60'/0'/0/0");
if (!ethHDKey.privateKey) {
throw new Error("未获取到账户私钥");
}
const privateKey = Web3.utils.bytesToHex(ethHDKey.privateKey);
const ethAccount = Wallet.fromPrivateKey(ethHDKey.privateKey);
const address = ethAccount.getAddressString();
// 连接 Sepolia RPC 节点
const rpc = "https://ethereum-sepolia-rpc.publicnode.com";
const connection = new Web3(rpc);
// 确认实际连接的网络
const chainId = await connection.eth.getChainId();
if (chainId !== 11155111n) {
throw new Error("当前连接的不是 Sepolia 测试网");
}
上面的代码使用顶层 await,需要在支持它的 TypeScript / ESM 环境中运行;否则可以放进 async 函数中。
生成的助记词和私钥需要妥善保存,不要提交到代码仓库,也不要输出到公共日志。后面的签名在本地完成,不需要把私钥发送给 RPC 节点。
查询原生币余额,调用 getBalance 方法即可:
const balance = await connection.eth.getBalance(address);
console.log("余额,单位 wei:", balance);
console.log(
"余额,单位 ETH:",
connection.utils.fromWei(balance, "ether"),
);
这里有两个需要注意的地方:
wei,不是 ETH。bigint 返回这类数值。ETH 的单位关系如下:
1 ETH = 10^18 wei
1 Gwei = 10^9 wei
所以查询、计算和构造交易时,尽量使用整数或字符串。不要先转成 JavaScript 的 number 再计算,大金额可能超过安全整数范围,小数运算也可能产生精度问题。
以太坊的交易存在多种类型,常见的有 Type 0(Legacy)、Type 1(EIP-2930)、Type 2(EIP-1559)。此外还有用于其他场景的 Type 3、Type 4。
本文使用 Type 2,不展开其他类型。完整分类可以查看 Ethereum 交易文档。
为了限制网络资源的使用,并避免大量垃圾交易,以太坊使用一种称为 Gas 的计量机制。
这里最好先分清两个概念:
普通 ETH 转账需要消耗 Gas,调用智能合约同样需要。执行的操作越复杂,通常消耗的 Gas 越多。
通过 RPC 查询余额,或者通过 eth_call 模拟读取合约,不需要支付链上交易费。但如果读取操作发生在一笔实际执行的链上交易中,仍然会计入 Gas 消耗。
对于 EIP-1559 交易,主要涉及下面三个费用字段:
| 字段 | 含义 |
|---|---|
baseFeePerGas | 区块规定的每单位 Gas 基础费用 |
maxPriorityFeePerGas | 愿意支付的每单位 Gas 优先费上限 |
maxFeePerGas | 愿意支付的每单位 Gas 总费用上限 |
注意,maxFeePerGas 不是基础费用。它是基础费用和优先费用合计后的上限。
在 Ethereum 上,基础费会被销毁,实际优先费支付给区块提议者指定的费用接收地址。优先费可以提高交易被优先打包的可能性,但不是“付得更高就一定立即打包”。
在交易满足打包条件时,费用可以这样理解:
实际 Gas 单价 =
min(maxFeePerGas, 打包区块的 baseFeePerGas + maxPriorityFeePerGas)
实际手续费 = gasUsed × 实际 Gas 单价
其中,gasUsed 是交易实际消耗的 Gas 数量。
另外还有一个容易混淆的字段 gasLimit,它表示这笔交易允许消耗的最大 Gas 数量,不代表最后一定会消耗这么多。因此:
手续费预算上限 = gasLimit × maxFeePerGas
上面的计算适用于本文讨论的 Ethereum 普通 Type 2 交易。其他链,尤其是 Layer 2,还需要结合对应链的费用规则。相关定义可以查看 EIP-1559。
Web3.js 提供了 calculateFeeData 方法,可以获取费用建议:
const feeData = await connection.eth.calculateFeeData();
const {
gasPrice,
baseFeePerGas,
maxFeePerGas,
maxPriorityFeePerGas,
} = feeData;
if (
baseFeePerGas == null ||
maxFeePerGas == null ||
maxPriorityFeePerGas == null
) {
throw new Error("未获取到完整的 EIP-1559 费用参数");
}
几个返回值分别是:
gasPrice:节点返回的建议 Gas 单价,Legacy 交易通常使用这个字段。baseFeePerGas:查询到的区块基础费。maxPriorityFeePerGas:建议的优先费上限。maxFeePerGas:库计算出的总费用上限建议。这个方法接受两个参数:
await connection.eth.calculateFeeData(
2n,
1_000_000_000n,
);
第一个参数是计算 maxFeePerGas 时使用的基础费倍数,默认值为 2。
第二个参数是备用优先费,默认值为 1 Gwei。它不是无条件覆盖节点返回的优先费,而是在无法取得相应优先费建议时作为备用值。
库采用的计算方式是:
maxFeePerGas = baseFeePerGas × 系数 + maxPriorityFeePerGas
这里乘以系数,是为了给后续区块的基础费变化留出空间。这个公式是库采用的报价策略,并不是协议规定所有交易都必须这样设置。
如果将系数设为 1,就没有额外为基础费上涨留出空间。交易等待期间基础费上涨,可能影响它的打包速度,甚至暂时无法打包。
nonce 用于确定同一账户发出的交易顺序。
它不只是“转账次数”,合约调用、合约部署等交易也会使用 nonce。交易已经上链但执行失败,也会消耗对应的 nonce。
准备发送下一笔交易时,可以这样获取:
const nonce = await connection.eth.getTransactionCount(
address,
"pending",
);
这里使用 "pending",是为了把节点当前已知的待处理交易考虑进去。
如果不传这个参数,默认通常按 "latest" 查询,也就是根据已上链的状态获取。连续发送交易时,可能重复拿到同一个 nonce。
不过,"pending" 也不能解决所有并发问题。如果多个任务同时使用一个账户发送交易,还是需要统一分配 nonce,避免几个任务同时取到相同的值。
以转出 1 Gwei 的原生币为例:
// 替换为实际接收地址
const to = "接收地址";
// 转账金额,单位 wei
const value = 1_000_000_000n;
const gasLimit = await connection.eth.estimateGas({
from: address,
to,
value,
nonce,
maxFeePerGas,
maxPriorityFeePerGas,
});
const transaction: Transaction = {
from: address,
to,
value,
nonce,
chainId,
gas: gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
type: 2,
};
这里用变量 gasLimit 保存估算结果,再赋给 Web3.js 交易对象中的 gas 字段。
estimateGas 是根据当前状态模拟交易,给出 Gas 估算。它不保证交易真正执行时,链上状态仍然完全相同。涉及复杂合约时,可以结合具体业务预留适当余量。
如果在浏览器中观察普通 ETH 转账,经常会看到 21000。
但不能因此认为所有 ETH 转账的 gasLimit 都固定是 21000。对于没有附加数据、接收方不执行代码的普通转账,实际 Gas 消耗通常是这个值;向合约转 ETH,则可能执行额外代码。
而且,gasLimit 本身是上限。即使普通转账设置为 50000,实际也可能只消耗 21000。Gas 说明。
如果还需要兼容 Arbitrum One,就更不能直接写死。Arbitrum 的 Gas 估算会把向父链提交数据的成本折算进去,同一种操作的估算结果也可能随费用变化。Arbitrum Gas 估算说明。
获取费用参数和 Gas 估算后,可以先计算手续费预算上限:
const maxFeeBudget = gasLimit * maxFeePerGas;
const currentBalance = await connection.eth.getBalance(address);
if (currentBalance < value + maxFeeBudget) {
throw new Error("余额不足以支付转账金额和手续费预算");
}
console.log(
"手续费预算上限,单位 ETH:",
connection.utils.fromWei(maxFeeBudget, "ether"),
);
注意,这里展示的是预算上限,不是最终扣除的费用。
如果需要展示当前条件下的费用估算,可以使用当前基础费计算,但要明确它只是估算。真正打包时,基础费和实际 Gas 消耗都可能发生变化。
还有一种容易踩坑的情况:希望把账户中的原生币全部转出。
如果直接把全部余额作为 value,就没有余额支付手续费,Gas 估算或发送交易时可能报错。
这时不建议简单删除 value 来绕过报错,因为删除以后,模拟的就不是原来那笔交易。接收方是合约时,value 的变化还可能影响执行逻辑。
对于本文这种普通 ETH 转账,可以先预留手续费预算:
可转出金额 ≤ 余额 − gasLimit × maxFeePerGas
再使用调整后的金额重新估算或校验。
不过,实际手续费可能低于预留金额,所以这种做法可能留下少量余额,不能保证恰好清空账户。
交易参数准备好后,需要使用私钥签名,再把签名后的交易广播出去。
签名用于证明这笔交易经过账户授权,并保证交易内容不能被随意修改。它不是对交易内容进行加密,链上交易内容仍然可以公开查看。
try {
const signedTx = await connection.eth.accounts.signTransaction(
transaction,
privateKey,
);
const sendTransactionEvents =
connection.eth.sendSignedTransaction(signedTx.rawTransaction);
sendTransactionEvents.on("transactionHash", (hash) => {
console.log("已获得交易哈希:", hash);
});
const receipt = await sendTransactionEvents;
// 本文使用 Web3.js v4 默认返回格式
if (receipt.status !== 1n) {
throw new Error("交易执行失败");
}
console.log("交易执行成功:", receipt.transactionHash);
if (receipt.effectiveGasPrice != null) {
const actualFee =
receipt.gasUsed * receipt.effectiveGasPrice;
console.log(
"实际手续费,单位 ETH:",
connection.utils.fromWei(actualFee, "ether"),
);
}
} catch (error) {
console.error("签名、广播或等待交易结果时发生错误:", error);
}
sendSignedTransaction 返回的是 Web3PromiEvent,既可以监听事件,也可以通过 await 等待结果。
这里需要分清几个阶段:
| 阶段 | 含义 |
|---|---|
获得 transactionHash | 已获得节点返回的交易哈希,不代表交易执行成功 |
获得 receipt | 交易已被区块收录,需要检查执行状态 |
| 执行状态为成功 | 交易在该区块中执行成功 |
| 达到所需确认程度 | 根据业务要求,继续等待确认或最终确定 |
不能只因为触发了 receipt 事件,就直接认定交易成功。Web3.js 的实现会先发出回执事件,再处理失败状态;默认情况下,执行失败也可能使等待回执的 Promise 抛出异常,因此需要 try/catch。广播实现。
另外,如果自己包装 Promise,并在 transactionHash 事件里调用 resolve,那等待的只是交易哈希。后面即使执行失败,也无法再改变这个 Promise 的结果。
下面只讨论 ERC-20 代币。
与原生币不同,ERC-20 的余额和转账逻辑由代币合约维护。因此,需要知道对应网络上的合约地址,并按照合约接口进行调用。
识别一个代币时,至少要同时确认:
网络 / chainId + 合约地址
不能只看代币名称或符号。不同代币可以使用相同的名称;不同链上相同的合约地址,也不一定代表同一种资产。
例如,以太坊主网 USDT 的合约地址是:
0xdAC17F958D2ee523a2206206994597C13D831ec7
这个地址是以太坊主网地址,不能直接放进前面的 Sepolia 连接中,就认为是在操作 USDT。正式资产地址应从项目官方渠道核对。Tether 官方地址列表。
为了保持网络一致,下面继续使用 Sepolia,并使用一个测试 ERC-20 合约。合约地址需要替换成你实际部署或确认过的地址。
ABI 用于描述合约接口,帮助我们编码调用参数、解码返回结果。
对于标准 ERC-20 方法,可以只提供需要用到的接口片段。下面假设测试代币支持 balanceOf、decimals 和返回布尔值的 transfer:
const ERC20_ABI = [
{
type: "function",
name: "balanceOf",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
type: "function",
name: "decimals",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint8" }],
},
{
type: "function",
name: "transfer",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
const TOKEN_CONTRACT_ADDRESS = "Sepolia 测试代币合约地址";
const tokenContract = new connection.eth.Contract(
ERC20_ABI,
TOKEN_CONTRACT_ADDRESS,
);
这里创建的是本地合约实例,没有发生链上注册,也不会因此产生手续费。
如果使用某个具体代币,应核对实际 ABI,尤其是存在非标准行为的代币。ABI 可以从项目发布的合约资料,或者区块浏览器中已验证的合约页面获取。
const tokenBalance = await tokenContract.methods
.balanceOf(address)
.call();
const decimals = await tokenContract.methods
.decimals()
.call();
console.log("余额,单位为代币最小单位:", tokenBalance);
console.log("代币精度:", decimals);
这里返回的余额,不应统一称为 wei。
ERC-20 金额使用的是该代币的最小单位,展示金额与链上整数之间的关系是:
链上整数金额 = 展示金额 × 10^decimals
例如,以太坊主网 USDT 的精度是 6:
1 USDT = 1,000,000 个最小单位
不能因为原生 ETH 使用 18 位小数,就把所有代币都按 18 位小数处理。
另外,decimals() 在 ERC-20 标准中属于可选方法。实际项目中,如果调用失败,需要从可信的代币配置中取得精度,不能直接猜成 18。ERC-20 标准。
代币转账与原生币转账的主要差异是:
to 是代币合约地址。data 中。value 仍然表示附带的原生币金额,普通 ERC-20 转账通常为 0。下面以转出 1 个完整代币为例:
const recipient = "收款地址";
// 转出 1 个完整代币
const tokenAmount = 10n ** BigInt(decimals);
const data = tokenContract.methods
.transfer(recipient, tokenAmount.toString())
.encodeABI();
const tokenNonce = await connection.eth.getTransactionCount(
address,
"pending",
);
const tokenFeeData = await connection.eth.calculateFeeData();
if (
tokenFeeData.maxFeePerGas == null ||
tokenFeeData.maxPriorityFeePerGas == null
) {
throw new Error("未获取到完整的 EIP-1559 费用参数");
}
const tokenGasLimit = await connection.eth.estimateGas({
from: address,
to: TOKEN_CONTRACT_ADDRESS,
data,
value: 0n,
nonce: tokenNonce,
maxFeePerGas: tokenFeeData.maxFeePerGas,
maxPriorityFeePerGas: tokenFeeData.maxPriorityFeePerGas,
});
const tokenTransaction: Transaction = {
from: address,
to: TOKEN_CONTRACT_ADDRESS,
data,
value: 0n,
nonce: tokenNonce,
chainId,
gas: tokenGasLimit,
maxFeePerGas: tokenFeeData.maxFeePerGas,
maxPriorityFeePerGas: tokenFeeData.maxPriorityFeePerGas,
type: 2,
};
注意,估算代币转账的 Gas 时,需要包含实际的 data。如果只把 to 改成合约地址,却不传 data,估算的就不是这次 transfer 调用。
后面的签名和广播流程与原生币一致,把前面的 transaction 换成 tokenTransaction 即可。
发送前还需要检查代币余额,以及用于支付手续费的原生币余额。账户里只有代币、没有 Sepolia ETH,同样无法按本文方式发送交易。
对于实际到账结果,也不要只看交易状态。ERC-20 标准允许方法返回 false,某些代币还存在扣费等特殊逻辑;需要结合目标合约的行为检查 Transfer 日志或余额变化。
假设同一账户的一笔交易使用 nonce = 100,但设置的费用过低,一直没有被打包。
这时,同一条链上该账户后续使用 101、102 等 nonce 的交易,不能跳过它先执行。其他账户的交易不会因此受到影响。
如果原交易仍未上链,可以使用相同 nonce,构造一笔费用更高的交易来尝试替换。
这里不是修改已经签名的原交易,而是重新签名、广播一笔竞争同一 nonce 的交易。
替换需要满足节点交易池的加价规则。对于 Type 2,需要留意 maxFeePerGas 和 maxPriorityFeePerGas,不是随便提高一个值就一定能成功。具体规则与节点实现和配置有关。Geth 交易池配置。
使用 Web3.js 的 calculateFeeData 时,在 BSC 上可能遇到 maxFeePerGas 为 undefined 的情况。
这不一定意味着链不接受 Type 2 交易,也可能与库的判断逻辑有关。
Web3.js 的 4.x 源码中存在下面的判断:
if (baseFeePerGas) {
// 计算 maxFeePerGas
}
当基础费为 0n 时,这个条件为假,会跳过计算。BSC 的相关费用实现使用零基础费,这就可能触发这个问题。Web3.js 源码、BSC 费用实现。
所以处理时,要先区分字段不存在、字段为零、RPC 方法不支持,以及目标链的交易类型差异。
判断费用字段是否存在时,也不要简单使用:
if (!baseFeePerGas) {
// ...
}
因为这会把合法的零值一起排除。
测试环境没有原生币,可以通过水龙头领取。比如 Sepolia PoW Faucet。
按照页面要求填写账户地址,完成验证和对应任务即可。积分要求、领取额度和有效期限可能变化,以当时页面显示的规则为准。
这里所说的“挖矿”,是水龙头用于防止滥用的计算任务,不是参与以太坊出块,也不是通过计算直接生成新的 Sepolia ETH。项目说明。
部分测试代币会提供公开的 mint 或 faucet 方法,可以通过区块浏览器的合约页面调用。
大致流程是:
打开合约页面
→ Contract
→ Write Contract
→ 连接钱包
→ 找到公开领取方法
→ 填写参数并提交
但不是所有 ERC-20 都允许这样领取。mint 不是 ERC-20 必须提供的标准方法,也可能只有管理员可以调用。
另外,测试网上名字叫“USDT”的代币,不一定由 Tether 官方发行。用于测试时,要确认网络、合约地址、精度以及领取权限,避免把测试代币和主网资产混在一起。