OYLZ
博客游戏OY_UIPixelForge
备案信息: 粤ICP备18091077号-1

Ethereum 及 EVM 兼容链的余额查询与转账

2025/12/24 13:11:0
Web3

本文将大致讲解 Ethereum 及 EVM 兼容链的余额查询、转账操作。代码示例主要用于原生币,ERC-20 代币的差异将在后面单独说明。

本文使用 Web3.js v4,以 Ethereum Sepolia 测试网为例。不同链的交易类型和费用规则可能存在差异,不能只换一个 RPC 地址,就认为所有逻辑都能直接复用。

另外,Web3.js 官方已经公告于 2025 年 3 月 4 日停止维护。本文保留这套实现,主要用于记录和理解交易流程。相关说明可以查看 Web3.js 官网。

准备账户与连接 RPC 节点

前文已经创建好了助记词,同时也创建好了以太坊账户,获取到了账户地址。

现在,为了查询链上数据、广播交易,或者与智能合约交互,需要连接 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。
  • Web3.js v4 默认使用 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 与手续费

为了限制网络资源的使用,并避免大量垃圾交易,以太坊使用一种称为 Gas 的计量机制。

这里最好先分清两个概念:

  • Gas 数量:这笔交易执行时需要消耗多少资源。
  • Gas 单价:每单位 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。交易已经上链但执行失败,也会消耗对应的 nonce。

准备发送下一笔交易时,可以这样获取:

const nonce = await connection.eth.getTransactionCount(
  address,
  "pending",
);

这里使用 "pending",是为了把节点当前已知的待处理交易考虑进去。

如果不传这个参数,默认通常按 "latest" 查询,也就是根据已上链的状态获取。连续发送交易时,可能重复拿到同一个 nonce。

不过,"pending" 也不能解决所有并发问题。如果多个任务同时使用一个账户发送交易,还是需要统一分配 nonce,避免几个任务同时取到相同的值。

估算 Gas 并构造交易

以转出 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 代币。

与原生币不同,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 阻塞与替换交易

假设同一账户的一笔交易使用 nonce = 100,但设置的费用过低,一直没有被打包。

这时,同一条链上该账户后续使用 101、102 等 nonce 的交易,不能跳过它先执行。其他账户的交易不会因此受到影响。

如果原交易仍未上链,可以使用相同 nonce,构造一笔费用更高的交易来尝试替换。

这里不是修改已经签名的原交易,而是重新签名、广播一笔竞争同一 nonce 的交易。

替换需要满足节点交易池的加价规则。对于 Type 2,需要留意 maxFeePerGas 和 maxPriorityFeePerGas,不是随便提高一个值就一定能成功。具体规则与节点实现和配置有关。Geth 交易池配置。

BSC 的费用参数

使用 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 官方发行。用于测试时,要确认网络、合约地址、精度以及领取权限,避免把测试代币和主网资产混在一起。