USDT0 在 Stable 上的行为
如果你正在从 Ethereum 移植合约,请在部署前阅读本页。 Stable 上的 USDT0 既是原生 gas 代币,又是基于同一余额的 ERC-20 代币。因此,四个在 Ethereum 上被假定成立的行为会失效:合约的原生余额可以在没有调用该合约的情况下发生变化;EXTCODEHASH 可能在零值和空哈希之间来回切换;向零地址转账会回滚;并且单个逻辑转账可能因小数余额对账而发出多个 Transfer 事件。
本页将逐一讲解这些情况,并提供安全的合约模式。如果你只读一节,请阅读 迁移检查清单。它是“把你的 Ethereum 合约移植到这里”的摘要。
双重角色概述
Stable 上的 USDT0 既是原生 gas 代币,又是 ERC-20 代币。这种双重角色模型影响余额行为、合约设计和事件处理。下面各节将逐一讲解双重角色改变预期行为的每种情况。
关于 USDT0 为何以这种方式运行的背景信息,请参阅 USDT 作为 gas。要通过真实转账体验这种行为,请参阅 发送你的第一笔 USDT0。
余额对账
USDT0 作为原生资产使用 18 位小数,作为 ERC-20 代币使用 6 位小数。原生转账和 ERC-20 转账操作的是同一底层余额,但 12 位的精度差距意味着当转账涉及亚整数精度时,系统必须对小数金额进行对账。
before
0.000001 USDT0 (ERC-20) + 0.000000000000000000 USDT0 (internal)
// address(account).balance = 0.000001000000000000
// USDT0.balanceOf(account) = 0.000001
if transfer 0.0000001 USDT0 to another account
after
0.000000 USDT0 (ERC-20) + 0.000000900000000000 USDT0 (internal)
// address(account).balance = 0.000000900000000000
// USDT0.balanceOf(account) = 0.000000这可能导致 address(account).balance 和 USDT0.balanceOf(account) 之间相差最多 0.000001 USDT0。
事件处理
每次对账转账都会发出一个额外的 Transfer 事件。单个逻辑 USDT0 转账可能产生最多两个额外的 Transfer 事件,具体取决于发送方和接收方的小数余额受到怎样的影响:
- 发送方调整:如果发送方的小数余额不足,0.000001 USDT0 会从发送方转移到储备地址。这会发出一个额外的
Transfer事件。 - 接收方调整:如果接收方的小数余额溢出,0.000001 USDT0 会从储备地址转移到接收方。这会发出一个额外的
Transfer事件。 - 两者同时调整:如果同一次转账中两种情况都发生,则绕过储备地址。发送方在主转账中直接将 0.000001 USDT0 转给接收方。不会发出额外的事件。
这些辅助事件涉及储备地址 0x5113954bbC0eD721F1C68671EBa3d91e9e9bF7b5。通过重放 Transfer 事件来跟踪 USDT0 余额的索引器和链下服务,必须过滤或考虑进出该地址的转账。
合约设计要求
原生余额可变性
在 Ethereum 上,合约的原生余额通常仅因合约执行而发生变化。在 Stable 上,合约的原生 USDT0 余额还可能因基于 ERC-20 授权的操作而变化,包括 transferFrom 和 permit。这些操作可以在不调用任何合约代码的情况下减少合约的原生余额。
因此,以下假设在 Stable 上是无效的:
只有在合约被调用时,合约的原生余额才会减少。
不要镜像原生余额
在 Ethereum 上,使用内部变量跟踪存款是很常见的做法。在 Stable 上,这是不安全的,因为 ERC-20 transferFrom 可以从外部抽空原生余额。
// UNSAFE on Stable
uint256 public deposited;
function deposit() external payable {
deposited += msg.value;
}转账前始终检查真实余额
所有原生价值转账必须在转账之前使用 address(this).balance 验证偿付能力,而不是使用内部记账变量:
// SAFE
function withdraw() external {
uint256 amount = credit[msg.sender];
credit[msg.sender] = 0;
require(address(this).balance >= amount, "insufficient balance");
payable(msg.sender).call{value: amount}("");
}状态推进必须与余额无关
依赖于进度、里程碑或完成条件的协议逻辑,必须使用非余额状态变量(如计数器或纪元)显式跟踪这些状态。原生余额应仅在支付时用于偿付能力验证。
禁止零地址转账
在 Stable 上,向 address(0) 进行原生转账和 ERC-20 转账都会回滚。
// REVERT on Stable
payable(address(0)).call{value: amount}("")
USDT0.transfer(address(0), amount);任何发送原生 USDT0 的合约逻辑都应在转账调用之前验证接收方,并显式拒绝 address(0):
// SAFE
require(recipient != address(0), "zero address recipient");
payable(recipient).call{value: amount}("");如果合约使用零地址转账作为销毁机制,则必须重新设计。如果需要不可逆的损失语义,请使用显式的销毁(sink)合约。
EXTCODEHASH 行为
在 Ethereum 上,EXTCODEHASH 操作码返回:
- 零哈希(
0x0000...):如果某地址从未被使用过(nonce=0、balance=0、无代码)。 - 空哈希(
0xc5d2…a470,即空代码的 Keccak-256 哈希):如果某地址存在但没有代码。
在 Ethereum 上,一旦某地址从零哈希转换为空哈希,它就无法再回到零哈希。在 Stable 上,由于 USDT0 支持基于 permit() 的授权,地址可以在不发送交易的情况下创建授权。结合 transferFrom(),这允许原生余额在 nonce 不递增的情况下发生变化,从而可能使 EXTCODEHASH 在零哈希和空哈希之间来回切换。
// UNSAFE on Stable
function isUnusedAddress(address addr) public view returns (bool) {
bytes32 codeHash;
assembly {
codeHash := extcodehash(addr)
}
return codeHash == bytes32(0);
}请改用显式跟踪:
// SAFE
contract SafeAddressTracker {
mapping(address => bool) public hasBeenUsed;
function markAsUsed(address addr) internal {
hasBeenUsed[addr] = true;
}
function isUnused(address addr) public view returns (bool) {
return !hasBeenUsed[addr];
}
}测试要求
针对 Stable 部署的测试套件应包括:
- 基于授权的抽空场景(
approve+transferFrom) - 使用真实原生余额强制执行偿付能力
- 不依赖
EXTCODEHASH的地址使用逻辑 - 零地址转账的显式失败案例
迁移检查清单
从 Ethereum 移植合约到 Stable 时:
- 移除内部原生余额镜像
- 将所有偿付能力检查替换为
address(this).balance - 移除所有向
address(0)的原生或 ERC-20 转账 - 审计所有 USDT0 授权
- 添加涵盖
permit和基于授权流程的测试 - 验证链下索引器能够处理来自小数余额对账的辅助
Transfer事件
关键要点
在 Stable 上正确的合约设计需要:
- 将 USDT0 视为双重角色资产
- 针对真实余额强制执行偿付能力
- 避免基于授权的抽空路径
- 消除对 Ethereum 特定余额和地址假设的依赖
链下服务和索引器应:
- 考虑来自小数余额对账的辅助
Transfer事件 - 使用直接余额查询,而不是基于事件的余额重构
推荐的后续阅读
- USDT 作为 gas — 理解 USDT0 为何同时作为原生资产和 ERC-20 代币运行。
- 发送你的第一笔 USDT0 — 在测试网上通过原生和 ERC-20 路径提交一笔 USDT0 转账。
- Ethereum 对比 — 查看从 Ethereum 移植时的每一处行为差异。

