Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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).balanceUSDT0.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 授权的操作而改变,包括 transferFrompermit。这些操作可以在不调用任何合约代码的情况下减少合约的原生余额。

因此,以下假设在 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 事件
  • 使用直接余额查询而不是基于事件的余额重建

下一步建议