学习视频来源:https://www.bilibili.com/video/BV1Vt411X7JF/?p=22
本博客除了包含自己的在学习过程中记录的笔记外,还包含少部分自己扩展的内容,如有错误,敬请指正。

智能合约要讲的东西是很多的,课上讲了一些核心基础,我这里整理一版更详细的基础和经典的合约及合约攻击。

1. 合约结构

  • 许可证:// SPDX-License-Identifier: MIT
  • 版本声明:用是限定编译器的可用版本范围。如:pragma solidity ^ 0.8.20( ^兼容该版本及以上小版本,>=0.8.0 <0.8.21精确范围)。0.8.x 核心改进:内置整数溢出/下溢检查、引入calldata优化、支持自定义错误。版本不匹配,大概报错。计算不报错也可能有安全问题。同一份代码不同编译器版本编译的结果可能不一样。
  • 主要元素:接口、库、状态变量、构造函数、成员函数。

合约有普通合约和抽象合约,抽象合约不能直接部署,需要子合约实现抽象方法后才能部署。

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

contract SimpleBank {
    // ===== 状态变量 =====
    mapping(address => uint256) public balances;
    address public owner;
    uint256 public totalDeposits;

    // ===== 自定义错误(推荐替代 require + string)=====
    error InsufficientBalance();
    error InvalidAmount();

    // ===== 事件 =====
    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);

    // ===== 构造函数 =====
    constructor() {
        owner = msg.sender;
    }

    // ===== 存款函数(接收 ETH)=====
    receive() external payable {
        deposit();
    }

    function deposit() public payable {
        if (msg.value == 0) revert InvalidAmount();
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    // ===== 取款函数 =====
    function withdraw(uint256 amount) public {
        if (amount == 0) revert InvalidAmount();
        if (balances[msg.sender] < amount) revert InsufficientBalance();

        balances[msg.sender] -= amount;
        totalDeposits -= amount;

        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");

        emit Withdrawn(msg.sender, amount);
    }

    // ===== 查询余额(view 函数)=====
    function getBalance(address user) public view returns (uint256) {
        return balances[user];
    }

    // ===== 只有 owner 能调用的函数 =====
    function withdrawAllToOwner() external {
        require(msg.sender == owner, "Only owner");
        uint256 amount = address(this).balance;
        (bool sent, ) = owner.call{value: amount}("");
        require(sent, "Transfer failed");
    }
}

2. 数据类型

2.1 值类型

类型 说明
int8 ~ int256 有符号整数,32个
uint8 ~ uint256 无符号整数(默认 uint = uint256),32个
address 普通地址(不可转账)
address payable 可接收 ETH 的地址(支持 .transfer() / .send()
bool true / false
bytes1 ~ bytes32 固定长度字节(bytes32 最常用),一共32个
enum 自定义枚举,如 enum Status { Active, Inactive }

2.2 引用类型

类型 存储位置限制 说明
string storage / memory / calldata 动态长度 UTF-8 字符串
bytes 同上 动态字节数组(比 string 更底层)
固定数组 uint[5] 长度编译时确定
动态数组 uint[] 长度可变
struct 可嵌套组合字段 用户自定义复合类型
mapping 仅限 storage 键值对,不能用于 memorycalldata

3. 函数修饰符

3.1 内置修饰符

修饰符 调用范围 典型用途 注意事项
public 内部 + 外部 对外暴露的核心函数 状态变量加 public 自动生成 getter
external 仅外部(EOA/其他合约) 高频外部调用(省 Gas) this.f() 会触发外部调用,msg.sender 改变
internal 本合约 + 继承合约 内部逻辑封装 外部不可访问
private 仅本合约 核心私有逻辑 继承合约也无法访问

3.2 状态与行为修饰符

修饰符 行为约束 可访问内容 用途
view 只读 storage 可读状态变量,不可写 查询函数
pure 不读不写 storage 不能访问状态变量、msgblock 纯计算(如数学工具)
payable 可接收 ETH 转账、充值等
virtual 允许子类重写 父合约中声明可扩展方法
override 重写父类/接口方法 必须搭配 virtual 使用

3.3 自定义修饰符(modify)

当一个函数有多个修饰符时,执行顺序:所有的前置逻辑 -> 函数本地 -> 所有后置逻辑。前置从右到左,后置从左到右,函数本体在中间:整体是 “洋葱模型”;function test() mod1 mod2mod1(mod2(test()))
在这里插入图片描述

4. 存储机制

4.1 存储位置

  • storage:持久化存储(状态变量)
  • memory:临时内存(函数内)
  • calldata:只读外部调用参数(最省 Gas)

4.2 赋值方式

  • 值类型之间的拷贝都是深拷贝,传递的是值。
  • 不同类型直接的赋值都是深拷贝。同类型之间有深有浅。
  • storage变量:有深有浅。在函数内拷贝给另一个在函数内定义的storage就是浅拷贝,拷贝的引用。但是2个状态变量之间的赋值,就是深拷贝。
    memeroy:赋值是浅拷贝,只拷贝引用。需手动复制数据内容实现深拷贝,与java类似。可能内存会丢,但在函数执行后会被回收。
    在这里插入图片描述

4.3 存储槽

基本类型存储

  • 1个槽能存32个字节,按声明顺序打包,能放则放,不同类型的数据也可以放同一个槽。
  • 所有基础值类型(如 address、uint、bool 等)都是“原子性”的,必须完整存放在同一个存储槽中,不能跨槽拆分。目的方便读取。
  • 槽号从0开始
  • 同一个槽先填的变量占高位,后填的占低位。和EVM设计有关。

复杂类型存储

1.定长数组:元素连续存储,起始位置是该变量被分配的槽。
2.动态数组:长度存储在变量槽p,元素存储起始位置为keccak256(abi.encodePacked(uint256§))。
映射:映射本身不占用存储空间(只预留一个槽位),元素位置通过公式计算:keccak256(abi.encodePacked(uint256(key), uint256§))。
几乎不可能映射同一位置。p参与计算的目的:没有p,不同的映射相同的key,映射到同一槽号。abi.encodePacked就是紧凑打包编码为字节数组(返回值)的意思,如果有多个参数会拼接。
3.结构体:成员变量按声明顺序连续分配槽位。

4.4 Gas优化

storage优化

  1. 变量打包
  2. 复用sotrage读写结果:冷读->热读
    Storage 冷读(首次读)2100 Gas,热读(缓存读)仅 100 Gas;多次使用的 Storage 变量,先读入 Memory 复用,避免重复冷读。
  3. 数组长度用变量替换
    如遍历storage 数组(如 for (uint i=0; i<arr.length; i++),arr.length会触发多次冷读。用局部变量替换uint len = arr.length;

memory优化

  1. 避免 Memory 数组的无意义扩容

  2. 用值类型替代动态类型(Memory 中)
    string/bytes 等动态类型在 Memory 中需额外存储长度,成本略高;能用 bytes32/uint 替代的优先替换。

calldata优化

参数如果不改的话,用calldata,不要用memeroy。

语法优化

  1. 用 unchecked 减少溢出检查(确定无溢出时)、
  2. 用 require 替代 assert(错误处理)require 用于业务逻辑校验(如余额不足),失败时返还剩余 Gas;assert 用于代码逻辑校验(如溢出),失败时消耗全部 Gas。
  3. 避免浮点运算 / 复杂数学运算。EVM 无原生浮点运算,用库实现(如 ABDKMath)会消耗大量 Gas;能用整数运算替代的优先替换。如百分比。
  4. 开启编译器优化

5. 异常 revert/required/error/assert对比

  • revert(“msg”) 手动抛出带字符串的错误,灵活但 gas 开销大,失败时 revert 并退回剩余gas。
  • require是revert的简洁版,适合业务逻辑异常,失败时 revert 并退回剩余 gas。
  • error MyError(…) 是 Solidity 0.8.4+ 推荐的结构化错误机制,gas 更省、支持参数、前端易解析结果。
  • assert 用于检查内部逻辑不变式(如程序 bug),绝不应被触发,仅作调试用途。
    推荐使用error,因为其更省gas,且前端易解析结果。 因为:

revert

revert("Insufficient balance");

编译时,完整字符串"Insufficient balance"(21 字节)会被硬编码进合约 bytecode。每次触发错误时,EVM 需要将这21字节(+填充到 32 字节)作为返回数据复制出去。gas 开销高。
error

error InsufficientBalance();
revert InsufficientBalance();

编译时,只存储4字节的函数选择器:bytes4(keccak256(“InsufficientBalance()”))→例如0x12345678。触发时,revert数据只有这4字节+ABI填充(共32字节,但有效数据仅4字节),gas开销低。部署时节省大量bytecode空间执行时复制的数据量小得多error确实可以携带参数并不显著增加 gas 开销参数只在运行时传递,不存储在合约 bytecode中。
以下是您提供内容的整理版,已按要求转换为 Markdown 表格,未更改原始信息:

6. ETH 转账方式transfer/send/call对比

项目 transfer(uint256 amt) send(uint256 amt) call{value: amt}("")
单位说明 amt 可为 wei/gwei/ether,默认 wei;推荐用 1 ether 写法避免大数错误 同左 同左
返回值 bool (bool, bytes)(成功返回 (true, ""),失败返回 (false, 错误信息)
失败处理机制 失败自动 revert 失败返回 false,不 revert,需手动处理 失败时仅返回 false,不 revert,需手动处理
Gas 限制 2300(不足以触发重入攻击) 2300(不足以触发重入攻击) 不限制(重入风险高)
使用场景 1. 适合给普通用户(EOA)转账(无 receive/fallback,无风险,且无需 gas)2. 也可给合约转账 ETH,但仅当接收合约的 receive/fallback 消耗 Gas ≤ 2300 时成功;否则 revert。此时应使用 call 需要失败后自定义处理逻辑 1. 给复杂合约转账2. 失败后需自定义处理逻辑3. 希望获得失败详情4. 需要自定义 gas 上限:to.call{value: amt, gas: 100000}("")(不定义则使用剩余全部 gas)
关系 是对底层 call 操作码的更高层封装 是对底层 call 操作码的高层封装 是对底层 call 操作码的封装。call 操作码是实现跨账户调用(含 ETH 转账)的唯一操作码!

7. CREATE vs CREATE2

项目 CREATE(普通部署) CREATE2(确定性部署)
语法 address addr = address(new Deployer()); address addr = address(new Deployer{salt: salt}());
地址决定因素 部署者地址 + 部署者 nonce 部署者地址 + salt + 合约字节码哈希
地址是否可预测 否(nonce 随部署次数变化) 是(部署前就能计算)
能否复用地址 否(nonce 递增,地址必变) 能(销毁旧合约后,可重新部署到同一地址)

8. 经典合约(ERC20/721/1155)

8.1 ERC20 标准(同质化代币)

函数/事件 描述
name() public view virtual returns (string memory) 币名
symbol() public view virtual returns (string memory) 币符号
decimals() public view virtual returns (uint8) 币精度
allowance(address owner, address spender) public view virtual returns (uint256) 查询 spender 拥有 owner 的额度
approve(address spender, uint256 value) public virtual returns (bool) 调用者授予额度给 spender
transfer(address to, uint256 value) public virtual returns (bool) 调用者将钱直接转给 to
transferFrom(address from, address to, uint256 value) public virtual returns (bool) spender 调用,将 from 的钱转给 to,使用后扣减 spender 额度
_mint(address account, uint256 value) internal accountvalue 个币
_burn(address account, uint256 value) internal 销毁 accountvalue 个币
event Transfer(address indexed from, address indexed to, uint256 value) 转移事件
event Approval(address indexed owner, address indexed spender, uint256 value) 授予额度事件

:ERC20 是一个同质化代币抽象合约,子合约必须实现构造函数,传入 namesymbol。可调用 _mint 铸币给部署者,指定总量。ERC20 经多次审核,一般无 bug,问题多出现在二次开发中。

8.2 ERC721 标准(非同质化代币)

状态变量

变量 说明
string private _name; 代币名称
string private _symbol; 代币符号
mapping(uint256 tokenId => address) private _owners; 代币持有者地址
mapping(address owner => uint256) private _balances; 持有者一共多少个代币
mapping(uint256 tokenId => address) private _tokenApprovals; 单个代币 → 授权地址
mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; 全量代币 → 授予地址(与 _tokenApprovals 互补)
mapping(uint256 tokenId => string) private _tokenURIs; 代币的 tokenUri(在 ERC721URIStorage 合约中)

构造函数

constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_);

查询函数

函数 说明
balanceOf(address owner) public view virtual returns (uint256); 查某人拥有多少 NFT
ownerOf(uint256 tokenId) public view virtual returns (address); tokenId 的主人是谁
tokenURI(uint256 tokenId) public view virtual returns (string memory); 查该 token 元数据地址

转移函数

函数 说明
transferFrom(address from, address to, uint256 tokenId) public virtual; 转移 fromto
safeTransferFrom(address from, address to, uint256 tokenId) public virtual; 安全转移:调用 to.onERC721Received,验证是否返回正确 selector(防止转入黑洞合约)
safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual; 安全转移,带 data 字段(用于向合约传递额外信息,如市场、质押池等)

授权函数

函数 说明
approve(address to, uint256 tokenId) public virtual; 给某人授予权限(单个 token)
setApprovalForAll(address operator, bool approved) public virtual; 给某人授予全部 NFT 权限
getApproved(uint256 tokenId) public view virtual returns (address); 获取 tokenId 当前的被授权者
isApprovedForAll(address owner, address operator) public view virtual returns (bool); 查询 operator 是否被 owner 全量授权

操作条件:满足以下任一即可操作 NFT:msg.sender == ownermsg.sender == getApproved(tokenId)isApprovedForAll(owner, msg.sender)

8.3 ERC1155 标准(多代币标准)

状态变量

变量 说明
mapping(uint256 => mapping(address => uint256)) private _balances; 某个代币 ID,某人拥有多少
mapping(address => mapping(address => bool)) private _operatorApprovals; 批量授权(类似 ERC721,但 无单个代币授权
string private _uri; 代币元数据 URI 模板

构造函数

constructor(string memory uri_) public;

查询函数

函数 说明
balanceOf(address account, uint256 id) public view returns (uint256); 查某人持有某代币的份数
balanceOfBatch(address[] memory accounts, uint256[] memory ids) public view returns (uint256[] memory); 批量查询(accountsids 长度必须一致)
isApprovedForAll(address account, address operator) public view returns (bool); 查询是否全量授权
uri(uint256 id) public view returns (string memory); 返回代币元数据 URI(通常含 {id} 占位符)

转移函数

函数 说明
safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes memory data) public; 安全单笔转移
safeBatchTransferFrom(address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) public; 安全批量转移

data 字段用于向接收合约传递额外上下文(如游戏道具配置、市场订单 ID 等)

授权函数

函数 说明
setApprovalForAll(address operator, bool approved) public virtual; 给某人授予全部代币操作权限
isApprovedForAll(address owner, address operator) public view virtual returns (bool); 查询是否全量授权

注意:ERC1155 没有类似 ERC721 的 approve(address to, uint256 tokenId) 单 token 授权机制!

内部函数(用于扩展)

函数 说明
_setURI(string memory newUri) internal; 设置新 URI 模板
_mint(address account, uint256 id, uint256 amount, bytes memory data) internal; 铸造单种代币
_mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) internal; 批量铸造
_burn(address account, uint256 id, uint256 amount) internal; 销毁单种代币
_burnBatch(address account, uint256[] memory ids, uint256[] memory amounts) internal; 批量销毁
_beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) internal virtual; 转移前钩子
_afterTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) internal virtual; 转移后钩子

9. 经典合约攻击

主要参考 https://solidity-by-example.org

9.1 重入攻击

合约

存钱,提款
在这里插入图片描述

攻击者

在这里插入图片描述

9.2 算术溢出攻击

合约

调用往里边存钱,但是要锁定至少7天,可以增加锁定时间。
在这里插入图片描述

攻击者

在这里插入图片描述

9.3 delegatecall攻击

问题在于逻辑合约和代理合约存储布局不一致,导致代理合约中的有些关键的数据可能篡改。然后在使用篡改后的数据干坏事。

合约

在这里插入图片描述

攻击者

在这里插入图片描述

9.4 随机数攻击

合约

利用前一个区块哈希和当前区块时间戳当随机数,猜对了就转账给猜的人。漏洞在于猜数和验证是在同一个交易里,区块哈希、时间戳等都是相同的。攻击者按规则生成数字,肯定能猜中。
在这里插入图片描述
在这里插入图片描述

9.5 拒绝服务攻击

合约

谁存进来的钱多谁当国王,如果有人成了新国王,合约要把老国王的钱退回。
在这里插入图片描述

攻击者

故意不实现receive/fallback,根本收不了钱。合约就没办法转给他
在这里插入图片描述
解决方式:合约不主动赚钱给参与者,提供提款函数,让参与者自己提。

9.6 txorgin钓鱼

合约

外部用户A部署一个合约,合约的owner就是A的地址。调用transefer,但只有A有权限,通过tx.origin == owner实现
在这里插入图片描述

攻击者

钓鱼攻击,比如伪装成一个链接。让用户A点一下,实际上是让A发起了一笔交易,to是攻击者的地址。就把A的钱转移至攻击者。
在这里插入图片描述

解决方法

改成msg.sender == owner。再被钓鱼时,传过来的msg.sender是攻击合约的地址。

9.7 Honeypot(蜜罐陷阱)

这个很有意思,这个是反治黑客的例子,不是被攻击的例子。
部署一个表面上存在重入攻击漏洞的Bank合约和一个正常Logger合约,引诱黑客来攻击。但用部署的时候,用HoneyPot 合约地址替换Logger合约地址,伪装成Logger。
黑客上当链路:
1.Attack先存钱,然后通过通过Attack合约调用Bank.withdraw()提款。
2.Bank.withdraw() 先校验余额,然后调用msg.sender.call{value: 1 ether}(“”)给Attack转 1 ETH;
3.Attack的fallback()被触发,检测到Bank还有余额(1 ETH),再次调用Bank.withdraw(1 ether)(重入攻击);
4.多次重入后,当最后一次Bank.withdraw()执行到末尾时,会调用logger.log(…)(此时logger是HoneyPot);HoneyPot.log()检测到_action是 “Withdraw”,执行revert(“It’s a trap”),整个交易直接回滚。
正常用户或合约调用withdraw也和Attack一样,交易都会回滚,因为不重入,所以只烧一次。这不是个正常的Bank合约,而是用来专门诱捕黑客的合约,烧他的gas。

Bank和Logger合约

在这里插入图片描述

HoneyPot合约

在这里插入图片描述

黑客发起重入攻击合约

在这里插入图片描述

9.8 区块时间戳操作攻击

合约

轮盘游戏:

  1. 玩家调用 spin() 函数,并发送 10 ETH。
  2. 如果当前区块的时间戳(block.timestamp)能被 15 整除(即 block.timestamp % 15 == 0),玩家就赢得合约中所有 ETH。
  3. 每个区块只允许一次下注(通过 pastBlockTime 防止同一区块多次调用)。
  4. 部署合约时可以附带 ETH(constructor() payable {}),通常会预先存入 10 ETH 作为奖池

在这里插入图片描述

攻击者

以太坊协议规定矿工可以矿工可以在一定范围内(通常 ±15 秒内)调整时间戳;时间戳必须大于前一个区块,且不能超过当前网络时间太多。攻击者可以作为矿工,当挖到一个新区块时,故意将block.timestamp设置为能被15整除的值,该区块中包含自己的spin()交易。获取奖励。
解决方案: 永远不要依赖block.timestamp等作为不可预测的随机源或关键胜负判定依据。

9.9 绕过合约代码大小攻击

合约

本意是通过isContract函数判断调用者是否为合约地址,将protected函数限制仅外部 EOA 账户(普通钱包) 可调用,调用成功会将pwned设为true。
在这里插入图片描述

攻击者

一个合约在构造函数执行期间,其代码还未最终写入区块链,此时extcodesize(address(this))返回 0,会被isContract误判为“非合约地址”,就可以调用protected函数了。
在这里插入图片描述
部署完成再调用是攻击不成功的
在这里插入图片描述

9.10 抢跑攻击

合约

玩家找一个值solution,使得其哈希值=给定的哈希值。
在这里插入图片描述

攻击者

攻击者可通过监听未打包的交易,用更高的 Gas 价格抢先提交相同答案,夺走奖励。
解决方案:针对这类猜谜合约,行业通用的修复思路是 先提交哈希(solution的盐值加密后的值),后验证答案(Commit-Reveal 模式),核心是让答案在提交阶段不可见,验证阶段才公开。验证时提供solution 和slat
在这里插入图片描述

9.11 合约替换攻击

https://solidity-by-example.org/hacks/deploy-different-contracts-same-address/

如何替换一个已部署的合约,同地址替换代码?

  • 直接用 CREATE2 部署 Proposal 不可行
    CREATE2 部署合约的地址由「部署者地址 + salt 值 + 合约字节码哈希」三者唯一决定。只要合约字节码从 Proposal(合法)变为 Attack(恶意),字节码哈希必然改变,最终生成的地址也会完全不同,无法实现 “同地址替换代码” 的目标。

  • 必须用 CREATE 部署子合约(Proposal/Attack)
    CREATE 部署子合约的地址仅由「部署者(父合约)地址 + 部署者 nonce(部署次数)」决定,与子合约字节码无关。这意味着:只要部署子合约的父合约地址和 nonce 不变,无论部署的是 Proposal 还是 Attack,子合约地址都会完全一致 —— 这是实现 “地址不变、代码替换” 的核心前提。

  • 保证父合约地址不变:若直接用 CREATE 部署父合约(Deployer),父合约地址由攻击者钱包地址 + 钱包 nonce决定,钱包 nonce 会随交易递增,销毁旧父合约后重新部署的新父合约地址必然改变。因此必须对父合约(Deployer)使用 CREATE2 部署:CREATE2 的地址由DeployerDeployer 地址 + 固定 salt 值 + Deployer 字节码决定,只要这三个要素不变,即使销毁旧父合约,重新部署的新父合约地址也会和旧地址完全一致,实现父合约地址的 “永久固定”。

  • 保证父合约 nonce 不变:销毁并重置父合约:旧父合约部署 Proposal 后,其 nonce 会从 0 变为 1(部署一次子合约,nonce+1)。若不销毁旧父合约,即使地址不变,调用deployAttack()时 nonce 会变为 2,生成的 Attack 地址仍与 Proposal 地址(Addr_X)不同。因此必须先调用selfdestruct销毁旧父合约:合约被销毁后,其地址变为 “空合约”,代码和存储清空,nonce 也随之重置。此时通过 CREATE2 重新部署的新父合约,地址仍为原地址,且 nonce 恢复为初始值 0—— 部署 Attack 时 nonce 变为 1,与部署 Proposal 时的 nonce 完全一致。

  • 最终实现合约替换:新父合约(地址不变、nonce=0)调用deployAttack()部署恶意 Attack 合约时,因 CREATE 部署规则,Attack 的地址与原 Proposal 的地址(Addr_X)完全重合,且字节码替换为恶意逻辑,最终实现 “复用合法地址、执行恶意代码” 的攻击目标。

合约替换攻击的本质: “CREATE2 固定父地址 + 部署子合约 + 重置父nonce + CREATE 复刻子地址”

  • 对父合约用 CREATE2:锁定父地址,保证销毁后可复用;
  • 对子合约用 CREATE:让子地址仅依赖父地址 + nonce,与代码无关;
  • 销毁父合约:重置 nonce 为 0,复刻子合约部署时的状态;
  • 最终实现:Attack 与 Proposal 地址完全一致,代码却被替换为恶意逻辑。
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐