SafeTransferLib.sol 以纯 assembly 手动构造 calldata,通过 returndatasize + extcodesize 双重校验兼容 USDT/BNB 等无返回值非标 ERC20,为 DeFi 协议提供零ABI 编解码开销的统一安全转账入口。
版本说明:[solmate]:main分支 [commit: 89365b8],[forge-std]:v1.15.0
源码:https://github.com/RevelationOfTuring/solmate/blob/main/src/utils/SafeTransferLib.sol
SafeTransferLib 是 solmate 的安全转账工具库,用纯 assembly 实现 ETH 转账和 ERC20 的 transfer、transferFrom、approve 操作。
解决的核心问题:如何安全调用 ERC20 代币的转账/授权函数,同时兼容不返回值的非标代币?
ERC20 标准(EIP-20)规定 transfer/transferFrom/approve 应返回 bool,但部分早期代币不完全遵守该规范——如 Ethereum 上的 USDT(transfer/transferFrom 均无返回值)和 BNB(transfer 无返回值)。直接用 Solidity 高级调用会因 ABI 解码失败而 revert——编译器期望从 returndata 中解出一个 bool,但 returndata 为空,解码直接炸。
SafeTransferLib 的解法:手动构造 calldata + 灵活解析返回值,兼容三种代币行为:
true(32 字节)→ 校验值是否为 1false 或 call 失败 → revert设计亮点:
and(addr, 0xff...ff) 掩码清理高位脏数据,防御性保证 ABI 编码规范| 适合 | 不适合 |
|---|---|
| 需要兼容 USDT 等非标代币的 DeFi 协议 | 只转 ETH 不涉及 ERC20 的简单场景 |
| 对 gas 敏感的高频转账操作 | 需要额外安全检查(如地址零检查)的场景 |
| 协议底层的统一转账入口 | 需要处理 USDT approve 先归零再设值的场景 |
| 需要在 assembly 中精确控制 calldata 的场景 | 对安全审计友好度要求极高的项目(纯 assembly 阅读门槛高) |
| 作为 Router/Vault 等合约的内部转账工具 | 需要 ERC777/ERC1155 等非 ERC20 代币支持 |
SafeTransferLib (library)
│
├── ETH Operations(1 个函数)
│ └── safeTransferETH(to, amount) ← 安全转账 ETH
│
└── ERC20 Operations(3 个函数)
├── safeTransferFrom(token, from, to, amount) ← 安全调用 transferFrom
├── safeTransfer(token, to, amount) ← 安全调用 transfer
└── safeApprove(token, to, amount) ← 安全调用 approve
依赖:import {ERC20} from "../tokens/ERC20.sol" — 仅用于函数参数类型声明,不调用 ERC20 的任何 Solidity 函数(全部通过 assembly call 完成)。
import {ERC20} from "../tokens/ERC20.sol";
library SafeTransferLib { ... }
| 关键词 | 含义 |
|---|---|
library |
库合约,函数默认 internal,编译时内联到调用方,不产生 DELEGATECALL |
import {ERC20} |
仅用于类型标注,让 token 参数携带地址信息,实际调用全走 assembly |
设计决策:为什么用 library 而不是 abstract contract?因为 SafeTransferLib 是纯工具函数集,没有状态变量、没有继承关系,library 的 internal 函数会直接内联,零额外开销。
function safeTransferETH(address to, uint256 amount) internal {
bool success;
/// @solidity memory-safe-assembly
assembly {
// call(gas, to, value, inputOffset, inputSize, outputOffset, outputSize)
// 转账 ETH:不传 calldata(inputSize=0),不将返回值拷贝到内存(outputSize=0)
// gas() 传递所有剩余 gas(因为接收方可能是合约,需要执行 receive/fallback)
success := call(gas(), to, amount, 0, 0, 0, 0)
}
// 如果 call 失败,直接 revert
require(success, "ETH_TRANSFER_FAILED");
}
作用:向目标地址转账 ETH,失败时 revert。
参数:
| 参数 | 类型 | 含义 |
|---|---|---|
to |
address |
接收地址 |
amount |
uint256 |
转账金额(wei) |
call 指令详解:
call(gas, to, value, inputOffset, inputSize, outputOffset, outputSize)
| 参数 | 值 | 含义 |
|---|---|---|
gas() |
当前剩余 gas | 传递所有可用 gas(接收方可能是合约,需执行 receive/fallback) |
to |
接收地址 | 调用目标 |
amount |
转账金额 | msg.value |
0 |
inputOffset | 无 calldata |
0 |
inputSize | 不传 calldata(纯 ETH 转账) |
0 |
outputOffset | 不将返回值拷贝到内存 |
0 |
outputSize | 不拷贝返回值 |
注意:outputSize=0 不代表对方没有返回值。如果接收方的 receive/fallback 在汇编中用 return 返回了数据,数据仍在 returndata 缓冲区中,可通过 returndatasize/returndatacopy 读取。这里只是不将返回值拷贝到内存。
设计决策:
address(0) 检查——库的哲学是极简,边界检查交给调用方call 本身就会失败to 是恶意合约,重入防护是调用方的责任三个 ERC20 函数(safeTransferFrom、safeTransfer、safeApprove)共享完全相同的实现模式,仅函数选择器和参数数量不同:
下面以 safeTransferFrom 为例详细解析,后两个函数只说差异。
function safeTransferFrom(
ERC20 token,
address from,
address to,
uint256 amount
) internal {
bool success;
/// @solidity memory-safe-assembly
assembly {
// 获取 free memory pointer,用作 calldata 的写入起点
let freeMemoryPointer := mload(0x40)
// ========== 手动构造 ABI 编码的 calldata ==========
// 写入 4 字节函数选择器:transferFrom(address,address,uint256)
// 0x23b872dd = bytes4(keccak256("transferFrom(address,address,uint256)"))
// mstore 写入 32 字节,选择器占前 4 字节,后 28 字节填充 0
mstore(freeMemoryPointer, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
// 写入第 1 个参数 from(偏移 4 字节)
// and(from, 0xff...ff) 掩码清理高 96 位,确保地址只占低 20 字节(后续地址参数同理)
mstore(add(freeMemoryPointer, 4), and(from, 0xffffffffffffffffffffffffffffffffffffffff))
// 写入第 2 个参数 to(偏移 36 = 4 + 32 字节)
mstore(add(freeMemoryPointer, 36), and(to, 0xffffffffffffffffffffffffffffffffffffffff))
// 写入第 3 个参数 amount(偏移 68 = 4 + 32 + 32 字节)
// uint256 占满 32 字节,无需掩码
mstore(add(freeMemoryPointer, 68), amount)
// ========== 执行外部调用 ==========
// call(gas, addr, value, inputOffset, inputSize, outputOffset, outputSize)
// inputSize = 100 = 4(选择器)+ 32×3(三个参数)
// outputOffset = 0, outputSize = 32:返回值写入 scratch space(0x00-0x1f)
success := call(gas(), token, 0, freeMemoryPointer, 100, 0, 32)
// ========== 返回值校验(核心逻辑)==========
//
// 等价的 Solidity 高级语法:
// if (success && !(mload(0) == 1 && returndatasize() > 31)) {
// success = (extcodesize(token) > 0) && (returndatasize() == 0);
// }
// Yul 版本用平铺的 and 替代 && 短路,省了两个 JUMPI
//
// 逻辑:call 成功但不是标准返回(返回 true)→ 进一步判断是否为无返回值的合约
if and(iszero(and(eq(mload(0), 1), gt(returndatasize(), 31))), success) {
success := iszero(or(iszero(extcodesize(token)), returndatasize()))
}
// 最终 success 为 true:call 成功 + 返回 true,或 call 成功 + 无返回值 + 是合约
}
// 如果 success 为 false,直接 revert
require(success, "TRANSFER_FROM_FAILED");
}
作用:安全调用 ERC20.transferFrom(from, to, amount),兼容非标代币。
参数:
| 参数 | 类型 | 含义 |
|---|---|---|
token |
ERC20 |
代币合约地址 |
from |
address |
转出地址 |
to |
address |
转入地址 |
amount |
uint256 |
转账金额 |
第一步:获取 free memory pointer
let freeMemoryPointer := mload(0x40)
0x40 是 Solidity 的 free memory pointer 存储位置,指向当前未使用的内存起始地址。在这里写入 calldata。
为什么不更新 0x40? calldata 写完后立刻被 call 消费,之后就没用了。不更新 free memory pointer 意味着后续 Solidity 代码会从同一位置重新分配内存,覆盖掉旧 calldata——这完全没问题,反而省了一次 mstore(0x40, ...) 的 gas。
第二步:手动构造 ABI 编码的 calldata
写入函数选择器:
mstore(freeMemoryPointer, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
0x23b872dd = bytes4(keccak256("transferFrom(address,address,uint256)"))
mstore 写入 32 字节:选择器占前 4 字节,后 28 字节填 0。这些 0 会被后续的参数写入覆盖(从偏移 4 开始)。
写入地址参数(带掩码清理):
mstore(add(freeMemoryPointer, 4), and(from, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(add(freeMemoryPointer, 36), and(to, 0xffffffffffffffffffffffffffffffffffffffff))
and(from, 0xff...ff) 掩码清理高 96 位,确保地址只占低 20 字节。
为什么要清理? EVM word 是 256 位,address 只占低 160 位。正常 Solidity 传参时编译器会自动清理高位,但如果调用方在 assembly 中操作过地址变量(如直接从 uint256 赋值),高位可能残留脏数据。SafeTransferLib 作为底层库做防御性掩码,一条 AND 指令(3 gas)几乎免费。
后续地址参数同理。
写入 amount 参数:
mstore(add(freeMemoryPointer, 68), amount)
uint256 占满 32 字节,无需掩码。
calldata 内存布局:
偏移: 0 4 36 68 100
[sig ][ from ][ to ][ amount ]
4B 32B 32B 32B
└───────────────── 共 100 字节 ────────────────────┘
第三步:执行外部调用
success := call(gas(), token, 0, freeMemoryPointer, 100, 0, 32)
| 参数 | 值 | 含义 |
|---|---|---|
gas() |
剩余 gas | 转发所有可用 gas |
token |
代币地址 | 调用目标 |
0 |
value | 不发送 ETH |
freeMemoryPointer |
inputOffset | calldata 起始位置 |
100 |
inputSize | 4(选择器)+ 32×3(参数)= 100 字节 |
0 |
outputOffset | 返回值写入 scratch space 的 0x00 位置 |
32 |
outputSize | 最多拷贝 32 字节到 scratch space |
scratch space:EVM 预留的临时内存区域(0x00-0x3f,共 64 字节),不受 free memory pointer 管理。将返回值写在这里不会干扰正常内存分配。
第四步:返回值校验(核心逻辑)
if and(iszero(and(eq(mload(0), 1), gt(returndatasize(), 31))), success) {
success := iszero(or(iszero(extcodesize(token)), returndatasize()))
}
这段代码等价于以下 Solidity 高级语法:
if (success && !(mload(0) == 1 && returndatasize() > 31)) {
success = (extcodesize(token) > 0) && (returndatasize() == 0);
}
Yul 版本用平铺的 and 替代 Solidity 的 && 短路求值,省了两个 JUMPI(各 10 gas)。EVM 的 and 是一条操作码(3 gas),两边操作数入栈前就已计算完毕,没有短路行为。
逻辑拆解:
前置条件:success = call 是否成功(未 revert)
isStandard = and(eq(mload(0), 1), gt(returndatasize(), 31))
mload(0) = scratch space 中的返回值returndatasize() = 对方实际返回的字节数if and(iszero(isStandard), success)
and 结果为 0 → 跳过 if 体 → success 保持 falsesuccess := iszero(or(iszero(extcodesize(token)), returndatasize()))
extcodesize(token) > 0(是合约)且 returndatasize() == 0(无返回值)→ success = true五种结果汇总:
| 场景 | success 最终值 | 说明 |
|---|---|---|
| call 失败(revert/OOG) | ❌ | 未进 if,success 保持 false |
| 返回 ≥32 字节,值为 1 | ✅ | 标准代币,未进 if |
| 返回 0 字节,token 有代码 | ✅ | 非标代币(USDT 等),进 if 校验通过 |
| 返回 ≥32 字节,值不为 1 | ❌ | 代币拒绝操作,进 if 但 returndatasize > 0 |
| 返回 0 字节,token 无代码 | ❌ | 调了个 EOA,进 if 但 extcodesize = 0 |
function safeTransfer(
ERC20 token,
address to,
uint256 amount
) internal {
bool success;
/// @solidity memory-safe-assembly
assembly {
// 获取 free memory pointer,用作 calldata 的写入起点
let freeMemoryPointer := mload(0x40)
// 写入函数选择器:transfer(address,uint256)
// 0xa9059cbb = bytes4(keccak256("transfer(address,uint256)"))
mstore(freeMemoryPointer, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
// 写入参数 to(掩码清理高位)
mstore(add(freeMemoryPointer, 4), and(to, 0xffffffffffffffffffffffffffffffffffffffff))
// 写入参数 amount(uint256 占满 32 字节,无需掩码)
mstore(add(freeMemoryPointer, 36), amount)
// calldata 长度 = 4 + 32×2 = 68
// 返回值写入 scratch space
success := call(gas(), token, 0, freeMemoryPointer, 68, 0, 32)
// 返回值校验逻辑同 safeTransferFrom
if and(iszero(and(eq(mload(0), 1), gt(returndatasize(), 31))), success) {
success := iszero(or(iszero(extcodesize(token)), returndatasize()))
}
}
// 如果 success 为 false,直接 revert
require(success, "TRANSFER_FAILED");
}
作用:安全调用 ERC20.transfer(to, amount)。
与 safeTransferFrom 的差异:
| 差异点 | safeTransferFrom | safeTransfer |
|---|---|---|
| 函数选择器 | 0x23b872dd (transferFrom) |
0xa9059cbb (transfer) |
| 参数数量 | 3 个(from, to, amount) | 2 个(to, amount) |
| calldata 长度 | 100 字节(4 + 32×3) | 68 字节(4 + 32×2) |
返回值校验逻辑完全相同。
function safeApprove(
ERC20 token,
address to,
uint256 amount
) internal {
bool success;
/// @solidity memory-safe-assembly
assembly {
// 获取 free memory pointer,用作 calldata 的写入起点
let freeMemoryPointer := mload(0x40)
// 写入函数选择器:approve(address,uint256)
// 0x095ea7b3 = bytes4(keccak256("approve(address,uint256)"))
mstore(freeMemoryPointer, 0x095ea7b300000000000000000000000000000000000000000000000000000000)
// 写入参数 to(spender,掩码清理高位)
mstore(add(freeMemoryPointer, 4), and(to, 0xffffffffffffffffffffffffffffffffffffffff))
// 写入参数 amount(uint256 占满 32 字节,无需掩码)
mstore(add(freeMemoryPointer, 36), amount)
// calldata 长度 = 4 + 32×2 = 68
// 返回值写入 scratch space
success := call(gas(), token, 0, freeMemoryPointer, 68, 0, 32)
// 返回值校验逻辑同 safeTransferFrom
if and(iszero(and(eq(mload(0), 1), gt(returndatasize(), 31))), success) {
success := iszero(or(iszero(extcodesize(token)), returndatasize()))
}
}
// 如果 success 为 false,直接 revert
require(success, "APPROVE_FAILED");
}
作用:安全调用 ERC20.approve(spender, amount)。
与 safeTransfer 的差异:
| 差异点 | safeTransfer | safeApprove |
|---|---|---|
| 函数选择器 | 0xa9059cbb (transfer) |
0x095ea7b3 (approve) |
to 参数含义 |
转入地址 | 被授权地址(spender) |
注意:USDT 的 approve 实现要求先将 allowance 设为 0,再设新值(否则 revert)。SafeTransferLib 不处理这个逻辑——如果需要兼容 USDT 的 approve,调用方应先 safeApprove(token, spender, 0) 再 safeApprove(token, spender, amount)。
safeTransferETH(to, amount)
│
├─ assembly: call(gas(), to, amount, 0, 0, 0, 0)
│
├─ success == true ─> 返回(转账成功)
│
└─ success == false ─> require revert "ETH_TRANSFER_FAILED"
safeTransferFrom(token, from, to, amount)
│
├─ mload(0x40) 获取 freeMemoryPointer
│
├─ 构造 calldata:selector + from + to + amount
│
├─ call(gas(), token, 0, ptr, 100, 0, 32)
│
├─ call 失败? ─────────────────────────────────────> require revert
│
├─ call 成功
│ │
│ ├─ returndatasize ≥ 32 且 mload(0) == 1 ?
│ │ └─ YES ─> 标准代币返回 true ──────────────> 返回(成功)
│ │
│ └─ NO ─> 进入 if 体
│ │
│ ├─ extcodesize(token) > 0 且 returndatasize == 0 ?
│ │ └─ YES ─> 非标代币,无返回值 ─────────> 返回(成功)
│ │
│ └─ NO ─> success = false ──────────────> require revert
address(0) 检查、不做余额检查、不做 reentrancy guard——所有边界检查交给调用方mstoreand 平铺替代 && 短路,省 JUMPI 开销extcodesize 检查排除 EOA 地址的误判| 风险 | 说明 | 建议 |
|---|---|---|
| 不检查 address(0) | safeTransferETH(address(0), amount) 会把 ETH 发送到零地址(销毁) |
调用方自行检查 to != address(0) |
| 不检查 token 地址有效性 | 如果 token 是 EOA,call 成功但 extcodesize=0,会被校验拦截 revert |
调用方确保 token 是合约地址 |
| USDT approve 的先归零问题 | USDT 要求 approve 前先将 allowance 设为 0 |
调用方先 safeApprove(token, spender, 0) |
| 重入风险 | safeTransferETH 的 call 会触发接收方的 receive/fallback |
调用方遵循 checks-effects-interactions 模式 |
| ERC20 hook 重入 | 某些 ERC20(如 ERC777 兼容代币)的 transfer 会触发回调 |
调用方对此类代币加重入锁 |
| gas 不足 | gas() 受 63/64 规则限制,实际转发 gas = 剩余 gas - 剩余 gas/64 |
确保调用时留有足够 gas |
| dirty bits | 函数在 freeMemoryPointer 处写入 calldata 但不更新 0x40 | 正常使用不受影响,后续内存分配会覆盖 |
| 维度 | Solmate SafeTransferLib | OpenZeppelin SafeERC20 |
|---|---|---|
| 实现方式 | 纯 assembly 手动构造 calldata | Solidity 高级调用 + Address.functionCall |
| gas 开销 | 极低(内联 + 无 ABI 编解码) | 较高(多层函数调用 + ABI 编解码) |
| ETH 转账 | ✅ 内置 safeTransferETH |
❌ 不包含 |
| 代码可读性 | 低(需要 assembly 知识) | 高(纯 Solidity) |
| 安全检查 | 最少(不检查地址有效性) | 较多(检查合约地址、返回值长度等) |
| USDT approve 兼容 | 需调用方处理先归零 | 同样需要调用方处理先归零 |
| 非标返回值兼容 | ✅ | ✅ |
| 库类型 | library(internal 内联) |
library(internal 内联) |
| 审计友好度 | 低(纯 assembly 审计难度高) | 高 |
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import {ERC20} from "solmate/tokens/ERC20.sol";
import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol";
/*
* @title SimpleVault — 使用 SafeTransferLib 的代币金库
* @notice 展示 SafeTransferLib 的典型用法:存入、提取 ERC20 和 ETH
*/
contract SimpleVault {
// 将 SafeTransferLib 的函数附加到 ERC20 类型上
using SafeTransferLib for ERC20;
// 用户 → 代币 → 余额
mapping(address => mapping(ERC20 => uint256)) public balances;
// 用户 → ETH 余额
mapping(address => uint256) public ethBalances;
/// @notice 存入 ERC20 代币
function deposit(ERC20 token, uint256 amount) external {
// 兼容 USDT 等非标代币,不会因缺少返回值而 revert
token.safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender][token] += amount;
}
/// @notice 提取 ERC20 代币
function withdraw(ERC20 token, uint256 amount) external {
balances[msg.sender][token] -= amount;
// checks-effects-interactions:先更新状态,再转账
token.safeTransfer(msg.sender, amount);
}
/// @notice 存入 ETH
function depositETH() external payable {
ethBalances[msg.sender] += msg.value;
}
/// @notice 提取 ETH
function withdrawETH(uint256 amount) external {
ethBalances[msg.sender] -= amount;
// checks-effects-interactions:先更新状态,再转账
SafeTransferLib.safeTransferETH(msg.sender, amount);
}
/// @notice 授权第三方协议使用金库中的代币
function approveSpender(ERC20 token, address spender, uint256 amount) external {
// 兼容 USDT:先归零再设新值
token.safeApprove(spender, 0);
token.safeApprove(spender, amount);
}
}
Mock 合约:https://github.com/RevelationOfTuring/foundry-solmate/blob/main/src/utils/MockSafeTransferLib.sol
全部 Foundry 测试合约:https://github.com/RevelationOfTuring/foundry-solmate/blob/main/test/utils/SafeTransferLib.t.sol
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
