Solidity patterns


Solidity 设计模式汇总笔记

本笔记总结了用于优化 Gas 消耗、增强安全性以及改善开发体验的核心 Solidity 编程模式。

1. 重入保护模式 (Reentrancy Protection)

问题:当合约发起外部调用时,执行控制权会转移给另一方。攻击者可以利用此机会在原始操作完成前再次调用合约函数,从而导致资产被超额提取。

检查-效果-交互 (CEI) 模式:

重入卫兵 (Reentrancy Guards/Mutex):使用一个布尔标志(如 nonReentrant 修饰符)在函数进入时加锁,结束后解锁。如果函数在锁定状态下再次被调用,则交易回滚。

2. 存储打包模式 (Packing Storage)

问题:读写 EVM 存储插槽(32 字节)是极其昂贵的操作。

3. Permit2 模式

问题:传统 approve UX 差(每个协议都要一次授权交易)且存在风险(用户倾向无限授权)。

4. 委托调用访问控制 (onlyDelegateCall / noDelegateCall)

问题:代理架构中逻辑合约是独立合约,敏感函数若未限制,可能被直接调用导致严重后果。

5. 独立授权目标 (Separate Allowance Targets)

问题:协议升级频繁,若用户授权给易变逻辑合约,升级需重授权且逻辑漏洞可能危及已授权资产。

6. 只读委托调用 (Read-only Delegatecall)

问题:delegatecall 可能修改状态,且不能直接在 view 中使用。

思路:

7. “栈过深”解决方法 (Stack Too Deep Workarounds)

问题:EVM 栈只能直接访问顶部 32 个槽位,局部变量/参数过多会导致编译错误。


Assembly Tricks (Part 1)(新增)

来源

本篇整理自 Dragonfly 的 patterns(assembly-tricks-1):https://github.com/dragonfly-xyz/useful-solidity-patterns/tree/main/patterns/assembly-tricks-1

目标:总结这些 “short & sweet” assembly 技巧的模式本质(为什么省 gas/解决什么限制)、适用边界踩坑点,方便在生产代码里安全复用。


Pattern 1: Bubble up reverts(原样冒泡 revert data)

问题

当你用低级 call/delegatecall/staticcalltry/catch 捕获失败时,会拿到 bytes memory revertBytes。常见错误做法:

revert(string(revertBytes));

这会把“原始 revert data”重新编码成 Error(string),导致错误类型/selector 丢失(也可能破坏自定义 error 的 data)。

模式

在 assembly 里直接 revert(ptr, len) 抛出原始 revert data

assembly { revert(add(revertBytes, 0x20), mload(revertBytes)) }

使用场景

风险/边界


Pattern 2: Hash two words(两个 word 的 keccak 更便宜写法)

问题

keccak256(abi.encode(x, y)) 会触发 abi.encode 分配新内存缓冲区,开销更大。

模式

用 scratch space 0x00..0x3f 直接拼两个 32 字节再 keccak:

bytes32 hash;
assembly {
    mstore(0x00, word1)
    mstore(0x20, word2)
    hash := keccak256(0x00, 0x40)
}

使用场景

风险/边界


Pattern 3: Cast between compatible memory array types(数组类型零拷贝转型)

问题

Solidity 不允许在类型系统层面直接把 address[] 当作 IERC20[] 之类(即便它们在内存里都是 32-byte slots 的兼容表示)。朴素做法是逐元素复制,浪费 gas。

模式

memory 动态数组:变量本质是指针,直接把指针赋给另一种数组类型。

address[] memory a = ...;
IERC20[] memory b;
assembly { b := a }

使用场景

风险/边界(很重要)


Pattern 4: Cast between compatible memory structs(struct 零拷贝转型)

与数组同理,对兼容字段布局的 memory struct 可以用相同技巧:

Foo memory foo = ...;
Bar memory bar;
assembly { bar := foo }

风险/边界


Pattern 5: Shortening dynamic memory arrays(原地缩短动态数组)

背景

动态 memory 数组的首 word 存长度 mload(arr),后面紧跟元素。

模式

直接改写长度(通常只安全缩短,不要变长):

uint256[] memory arr = new uint256[](100);
assembly { mstore(arr, 99) }

使用场景

风险/边界


Pattern 6: “Shorten” static arrays / slicing(静态数组截断与切片)

静态数组没有 length 前缀,不能用 mstore 改长度。可用“指针复用”创建子视图:

uint256[10] memory arr;
uint256[9] memory shortArr;
assembly { shortArr := arr }

甚至可以把指针偏移 0x20,从而创建共享 slice:

uint256[10] memory arr;
uint256[8] memory slice;
assembly { slice := add(arr, 0x20) } // 跳过第一个元素

风险/边界


总结:什么时候值得用这些 assembly tricks


Pattern: Code-as-storage(把数据存进合约 bytecode)— BigDataStoreV1(独立 section)

这个模式在做什么

当你要存很大的 blob(图片/base64/json/证明数据)时,SSTORE 非常贵。Code-as-storage 的思路是:

本文用一个简单约定(带 header):

这样读取时可以先校验 MAGIC/VERSION,避免把任意合约地址的 bytecode 当成数据读出来。

BigDataStoreV1(完整代码:mstore8 可读版)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

/// @dev Runtime code layout:
/// [0..3]  MAGIC   = 0xB10BDA7A
/// [4]     VERSION = 0x01
/// [5..]   DATA
contract BigDataStoreV1 {
    constructor(bytes memory data) {
        assembly {
            // size = data.length
            let size := mload(data)

            // Overwrite the bytes length word with zeroes (we'll use it as scratch for header)
            mstore(data, 0)

            // Write 5-byte header into the LAST 5 bytes of the (now zero) length word:
            // mem[data+27 .. data+31] = B1 0B DA 7A 01
            mstore8(add(data, 27), 0xB1)
            mstore8(add(data, 28), 0x0B)
            mstore8(add(data, 29), 0xDA)
            mstore8(add(data, 30), 0x7A)
            mstore8(add(data, 31), 0x01)

            // Return runtime bytecode:
            // - starts at data+27 (header position)
            // - length is size+5 (header + payload)
            //
            // After deployment, extcodecopy(loc, ...) can read:
            // MAGIC || VERSION || DATA
            return(add(data, 27), add(size, 5))
        }
    }
}

如何加载(读取 + 校验)CodeStoreV1

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

library CodeStoreV1 {
    bytes4 internal constant MAGIC = 0xB10BDA7A;
    uint8 internal constant VERSION = 0x01;

    function loadBytes(address loc) internal view returns (bytes memory out) {
        // 1) Read code length (not storage!)
        uint256 n;
        assembly { n := extcodesize(loc) }
        require(n >= 5, "CODESTORE_TOO_SMALL");

        // 2) Read first 5 bytes: MAGIC(4) + VERSION(1)
        bytes memory head = new bytes(5);
        assembly {
            // bytes memory layout: [len(32)][data...]
            extcodecopy(loc, add(head, 0x20), 0, 5)
        }

        // 3) Parse & validate header
        bytes4 gotMagic;
        uint8 gotVer;
        assembly {
            // mload(head+32) reads 32 bytes; first 5 bytes are our header
            gotMagic := mload(add(head, 0x20))           // first 4 bytes
            gotVer := byte(4, mload(add(head, 0x20)))    // 5th byte (index 4)
        }
        require(gotMagic == MAGIC, "BAD_MAGIC");
        require(gotVer == VERSION, "BAD_VERSION");

        // 4) Copy the payload bytes (skip 5-byte header)
        uint256 dataSize = n - 5;
        out = new bytes(dataSize);
        assembly {
            extcodecopy(loc, add(out, 0x20), 5, dataSize)
        }
    }

    function loadString(address loc) internal view returns (string memory) {
        return string(loadBytes(loc));
    }
}

逐行解释:return(add(data, 27), size+5) 是什么意思?

在 constructor 里的 assembly { return(ptr, len) } 不是“函数返回值”,而是:

因此:

部署完成后:

逐行解释:读取为什么用 extcodesize/extcodecopy

这是 Code-as-storage 的“读取存储”方式(读的是 code 区,不是 SLOAD):

在上面的 loadBytes 中: