深入剖析Solmate库 #09:SafeTransferLib.sol

  • Michael.W
  • 发布于 2026-04-29 22:57
  • 阅读 106

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 的 transfertransferFromapprove 操作。

解决的核心问题:如何安全调用 ERC20 代币的转账/授权函数,同时兼容不返回值的非标代币?

ERC20 标准(EIP-20)规定 transfer/transferFrom/approve 应返回 bool,但部分早期代币不完全遵守该规范——如 Ethereum 上的 USDT(transfer/transferFrom 均无返回值)和 BNB(transfer 无返回值)。直接用 Solidity 高级调用会因 ABI 解码失败而 revert——编译器期望从 returndata 中解出一个 bool,但 returndata 为空,解码直接炸。

SafeTransferLib 的解法:手动构造 calldata + 灵活解析返回值,兼容三种代币行为:

  1. 标准代币:返回 true(32 字节)→ 校验值是否为 1
  2. 非标代币:不返回值(0 字节),如 USDT → 校验目标地址有代码(是合约)
  3. 异常情况:返回 falsecall 失败 → revert

设计亮点

  • 全 assembly 实现,避免 Solidity 编译器插入的 ABI 编解码开销
  • 返回值写入 scratch space(0x00-0x1f),而非 free memory pointer 之后,节省内存分配
  • 地址参数用 and(addr, 0xff...ff) 掩码清理高位脏数据,防御性保证 ABI 编码规范
  • 不更新 free memory pointer(0x40),calldata 用完即弃,省 gas 且不影响正确性

二、适用场景

适合 不适合
需要兼容 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 完成)。

四、源码逐行解析

4.1 库声明与导入

import {ERC20} from "../tokens/ERC20.sol";

library SafeTransferLib { ... }
关键词 含义
library 库合约,函数默认 internal,编译时内联到调用方,不产生 DELEGATECALL
import {ERC20} 仅用于类型标注,让 token 参数携带地址信息,实际调用全走 assembly

设计决策:为什么用 library 而不是 abstract contract?因为 SafeTransferLib 是纯工具函数集,没有状态变量、没有继承关系,libraryinternal 函数会直接内联,零额外开销。

4.2 safeTransferETH — 安全 ETH 转账

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 本身就会失败
  • 不做 reentrancy guard——如果 to 是恶意合约,重入防护是调用方的责任

4.3 ERC20 三函数的共同模式

三个 ERC20 函数(safeTransferFromsafeTransfersafeApprove)共享完全相同的实现模式,仅函数选择器和参数数量不同:

  1. 获取 free memory pointer → 作为 calldata 写入起点
  2. mstore 写入函数选择器(4 字节)+ 参数(每个 32 字节)
  3. call 执行外部调用,返回值写入 scratch space
  4. 校验返回值:标准返回 true ✅ 或 无返回值+是合约 ✅ 或 失败 ❌
  5. require 兜底

下面以 safeTransferFrom 为例详细解析,后两个函数只说差异。

4.3.1 safeTransferFrom — 安全 ERC20 授权转账

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() = 对方实际返回的字节数
  • 含义:返回值 == 1 且 returndatasize ≥ 32 → 标准代币返回 true
if and(iszero(isStandard), success)
  • call 成功 但不是标准返回 → 进入 if 体进一步判断
  • call 失败(success=0)→ and 结果为 0 → 跳过 if 体 → success 保持 false
success := iszero(or(iszero(extcodesize(token)), returndatasize()))
  • extcodesize(token) > 0(是合约) returndatasize() == 0(无返回值)→ success = true
  • 否则 success = false

五种结果汇总

场景 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

4.3.2 safeTransfer — 安全 ERC20 转账

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)

返回值校验逻辑完全相同。

4.3.3 safeApprove — 安全 ERC20 授权

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

safeTransferETH(to, amount)
  │
  ├─ assembly: call(gas(), to, amount, 0, 0, 0, 0)
  │
  ├─ success == true ─> 返回(转账成功)
  │
  └─ success == false ─> require revert "ETH_TRANSFER_FAILED"

ERC20 函数(以 safeTransferFrom 为例)

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——所有边界检查交给调用方
  • 不更新 free memory pointer——calldata 用完即弃,省一次 mstore

极致 gas 优化

  • 全 assembly 实现,跳过 Solidity 编译器的 ABI 编解码
  • 返回值写入 scratch space 而非分配新内存
  • and 平铺替代 && 短路,省 JUMPI 开销

防御性编程

  • 地址参数一律掩码清理高位,不依赖调用方的类型安全
  • extcodesize 检查排除 EOA 地址的误判
  • 三种代币行为全覆盖,不遗漏边界情况

模式复用

  • 三个 ERC20 函数共享完全相同的校验逻辑,仅选择器和参数不同
  • 统一的 "构造 calldata → call → 校验返回值" 三段式结构

七、安全注意事项

风险 说明 建议
不检查 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)
重入风险 safeTransferETHcall 会触发接收方的 receive/fallback 调用方遵循 checks-effects-interactions 模式
ERC20 hook 重入 某些 ERC20(如 ERC777 兼容代币)的 transfer 会触发回调 调用方对此类代币加重入锁
gas 不足 gas() 受 63/64 规则限制,实际转发 gas = 剩余 gas - 剩余 gas/64 确保调用时留有足够 gas
dirty bits 函数在 freeMemoryPointer 处写入 calldata 但不更新 0x40 正常使用不受影响,后续内存分配会覆盖

八、与同类方案对比

OpenZeppelin SafeERC20

维度 Solmate SafeTransferLib OpenZeppelin SafeERC20
实现方式 纯 assembly 手动构造 calldata Solidity 高级调用 + Address.functionCall
gas 开销 极低(内联 + 无 ABI 编解码) 较高(多层函数调用 + ABI 编解码)
ETH 转账 ✅ 内置 safeTransferETH ❌ 不包含
代码可读性 低(需要 assembly 知识) 高(纯 Solidity)
安全检查 最少(不检查地址有效性) 较多(检查合约地址、返回值长度等)
USDT approve 兼容 需调用方处理先归零 同样需要调用方处理先归零
非标返回值兼容
库类型 libraryinternal 内联) libraryinternal 内联)
审计友好度 低(纯 assembly 审计难度高)

九、实战:在业务合约中使用 SafeTransferLib

// 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

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论