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

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

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

推荐的后续阅读