Stable 上的 USDT0 行为
如果您要从以太坊移植合约,请在部署前阅读此页面。 Stable 上的 USDT0 既是原生 Gas 代币,也是同一余额上的 ERC-20 代币。因此,四种以太坊假定的行为会失效:合约的原生余额可以在没有调用合约的情况下发生变化、EXTCODEHASH 可以在零和空哈希之间振荡、零地址转账会回滚,以及一次逻辑转账可能会由于小数余额核对而发出多个 Transfer 事件。
本页面将介绍每种情况并提供安全的合约模式。如果您只阅读一个部分,请阅读迁移清单。这是将您的以太坊合约移植到此处的总结。
双重角色概述
Stable 上的 USDT0 既是原生 Gas 代币,也是 ERC-20 代币。这种双重角色模型影响余额行为、合约设计和事件处理。以下各节将介绍双重角色改变预期行为的所有情况。
有关 USDT0 为什么以这种方式运作的背景信息,请参阅 USDT0 作为 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 余额的索引器和链下服务必须过滤或考虑进出此地址的转账。
合约设计要求
原生余额可变性
在以太坊上,合约的原生余额通常仅因合约执行而改变。在 Stable 上,合约的原生 USDT0 余额也可能因基于 ERC-20 授权的操作而改变,包括 transferFrom 和 permit。这些操作可以在不调用任何合约代码的情况下减少合约的原生余额。
因此,以下假设在 Stable 上无效:
合约的原生余额只有在调用合约时才会减少。
不要镜像原生余额
在以太坊上,使用内部变量跟踪存款很常见。在 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 上,原生和 ERC-20 转账到 address(0) 都会回滚。
// 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}("");如果合约使用零地址转账作为销毁机制,则必须重新设计。如果需要不可逆的损失语义,请使用显式汇集合约。
EXTCODEHASH 行为
在以太坊上,EXTCODEHASH 操作码返回:
- 零哈希 (
0x0000...):如果地址从未被使用(nonce=0,balance=0,无代码)。 - 空哈希 (
0xc5d2…a470,空代码的 Keccak-256 哈希):如果地址存在但没有代码。
在以太坊上,一旦地址从零哈希变为为空哈希,就不能再返回零哈希。在 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的地址使用逻辑 - 零地址转账的明确失败案例
迁移清单
将合约从以太坊移植到 Stable 时:
- 删除内部原生余额镜像
- 将所有偿付能力检查替换为
address(this).balance - 删除所有到
address(0)的原生或 ERC-20 转账 - 审计所有 USDT0 批准
- 添加覆盖
permit和基于授权的流程的测试 - 验证链下索引器是否处理小数余额核对产生的辅助
Transfer事件
主要收获
Stable 上正确的合约设计需要:
- 将 USDT0 视为双重角色资产
- 根据真实余额强制执行偿付能力
- 避免基于授权的耗尽路径
- 消除对以太坊特定的余额和地址假设的依赖
链下服务和索引器应:
- 考虑来自小数余额核对的辅助
Transfer事件 - 使用直接余额查询而不是基于事件的余额重建
下一步建议
- USDT0 作为 Gas:了解为什么 USDT0 既是原生资产又是 ERC-20 代币。
- 发送您的第一个 USDT0:通过原生和 ERC-20 路径在测试网上提交 USDT0 转账。
- 以太坊对比:回顾从以太坊移植时所有的行为差异。

