1.assert与require

特点 require assert
异常类型 Error Panic
Gas 处理 剩余 gas 退回 剩余 gas 消耗掉
用途 外部输入条件检查(用户错误) 内部逻辑保证(代码错误)
信息提示 可自定义错误信息 不能加提示,只能抛出 Panic特点

assert 条件为 false 时:

  • 会触发一个 Panic 异常(error code 0x01
  • 消耗掉函数调用中剩余的所有 gas(不退回)
  • 回滚所有状态修改

官方推荐:

  • assert 用于检查 永远应该为 true 的内部条件,也就是“不可能出错的逻辑”。
  • 如果 assert 失败,说明代码里有 bug(比如整数溢出、不变式被破坏)。
Solidity 0.8.x 的变化

在 0.8.x 版本后:

  • 编译器会自动为 整数溢出、下溢 插入 assert,所以这类错误会抛出 Panic,而不是普通错误。

  • 例如:

    1
    2
    uint8 x = 255;
    x += 1; // 自动触发 assert,因为溢出
Panic 错误码(常见)
  • 0x01assert(false) 失败

  • 0x11:算术运算溢出或下溢

  • 0x12:除以 0

  • 0x21:转换到不存在的 enum 成员

  • 0x31:空数组 pop

  • 0x32:数组越界

  • 0x41:分配内存过大

2.接收ETH receive和fallback

接收ETH函数 receive

receive()函数是在合约收到ETH转账时被调用的函数。一个合约最多有一个receive()函数,声明方式与一般函数不一样,不需要function关键字:receive() external payable { ... }receive()函数不能有任何的参数,不能返回任何值,必须包含externalpayable

当合约接收ETH的时候,receive()会被触发。receive()最好不要执行太多的逻辑因为如果别人用sendtransfer方法发送ETH的话,gas会限制在2300receive()太复杂可能会触发Out of Gas报错;如果用call就可以自定义gas执行更复杂的逻辑(这三种发送ETH的方法我们之后会讲到)。

我们可以在receive()里发送一个event,例如:

1
2
3
4
5
6
// 定义事件
event Received(address Sender, uint Value);
// 接收ETH时释放Received事件
receive() external payable {
emit Received(msg.sender, msg.value);
}

有些恶意合约,会在receive() 函数(老版本的话,就是 fallback() 函数)嵌入恶意消耗gas的内容或者使得执行故意失败的代码,导致一些包含退款和转账逻辑的合约不能正常工作,因此写包含退款等逻辑的合约时候,一定要注意这种情况。

回退函数 fallback

fallback()函数会在调用合约不存在的函数时被触发。可用于接收ETH,也可以用于代理合约proxy contractfallback()声明时不需要function关键字,必须由external修饰,一般也会用payable修饰,用于接收ETH:fallback() external payable { ... }

我们定义一个fallback()函数,被触发时候会释放fallbackCalled事件,并输出msg.sendermsg.valuemsg.data:

1
2
3
4
5
6
event fallbackCalled(address Sender, uint Value, bytes Data);

// fallback
fallback() external payable{
emit fallbackCalled(msg.sender, msg.value, msg.data);
}
1
2
3
4
5
6
7
8
9
10
11
12
触发fallback() 还是 receive()?
接收ETH
|
msg.data是空?
/ \
是 否
/ \
receive()存在? fallback()
/ \
是 否
/ \
receive() fallback()
函数 是否自动执行 触发条件 是否接收 ETH 典型用法
constructor ✅ 部署时一次 部署合约 ✅ 如果写了 payable 初始化合约,接受部署时资金
receive() 收到 ETH 且 msg.data 为空 专门收款
fallback() 1. 调用不存在函数 2. 收到 ETH 且 msg.data 非空或无 receive ✅ 如果加了 payable 容错处理、代理转发

receive()payable fallback()均不存在的时候,向合约直接发送ETH将会报错(你仍可以通过带有payable的函数向合约发送ETH)。

当合约是一个可以接受eth的合约(写有带payable的receive或fallback或constructor),那么其他合约与这个合约交互时,参数地址就要加上payable

接收方式 是否需要 receive / fallback
transfer / send / call(“”) ✅ 需要
调用 payable 函数,并携带eth ❌ 不需要 ,正常情况不会触发回退函数
构造函数 payable(构造函数带payable,在初始化合约时可以转账) ❌ 不需要 ,不会触发回退函数,因为回退函数未初始化
selfdestruct 强制转账 ❌ 不需要 ,不会触发回退函数

情况A(直接转账):你走进银行,把钱扔进柜台:”给你钱”
→ 柜员需要决定如何处理(存款?缴费?)
→ 对应 receive()/fallback()

情况B(调用payable函数携带eth):你走进银行:”我要存钱到我的账户”
→ 柜员直接执行存款流程
→ 对应调用 deposit() 函数—不触发回退函数

3.staticcall和call

特性 call staticcall
能否修改状态 ✅ 可以 ❌ 不可以
能否转账 ETH ✅ 可以 ❌ 不可以
返回值 (success, returndata) (success, returndata)
常见用途 执行函数、转账 只读查询数据特性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract A {
uint public x;

function setX(uint _x) external {
x = _x;
}

function getX() external view returns(uint) {
return x;
}

}
contract B {
function demo(address a) external {
// call 修改状态
(bool ok1,) = a.call(abi.encodeWithSignature("setX(uint256)", 123));
require(ok1);

// staticcall 查询
(bool ok2, bytes memory data) = a.staticcall(abi.encodeWithSignature("getX()"));
require(ok2);
uint value = abi.decode(data, (uint));
}
}

call 调用 setX 成功修改了 x

staticcall 调用 getX 成功拿到返回值 123

如果用 staticcall 去调 setX 会直接报错。

4.关于原生币转账和回退函数的执行

🌟 为什么 ETH 转账没有“转账逻辑”代码?

因为 ETH 是原生资产(native asset),它的余额不是存储在合约里,而是由 **EVM 在状态树(State Trie)中直接维护 balances[address]**。

也就是说:

  • ERC20 代币需要在合约里 balances[address] += amount
  • 但 ETH 不需要,它没有对应的 Token 合约
  • 它的余额是 EVM 内部在执行 CALL 时自动更新的

🧠 ETH 转账的真正流程(在 EVM 层)

当你执行:

1
address(contract).call{value: 1 ether}("");

EVM 执行的步骤如下:

1:CALL 指令准备 value

EVM 读取 CALL 的参数,其中包括:

1
2
value = 1 ether
to = contractAddress

2:EVM 自动更新账户余额(状态树 State Trie)

EVM 直接执行:

1
2
balances[msg.sender] -= value
balances[contract] += value

注意:这一步完全在 EVM 层,而不是在 Solidity 合约代码中。

3:如果目标是合约,执行 receive / fallback

余额更新后,才进入合约执行:

1
receive()或fallback()

4:如果回退函数执行失败,会 revert 整个调用

意味着:

  • 余额更新和代码执行是 原子操作
  • 回退函数报错 → 整笔 ETH 转账回滚

🎯 关键点总结

✔ ETH 转账没有合约“转账逻辑”,因为它是 EVM 内置的

ETH 是“协议层资产”,不是“合约层资产”。

✔ EVM 在执行 CALL 时自动更新余额

在 CALL 指令中,value 字段自动触发余额变化。

✔ 回退函数不是“转账逻辑”,只是额外的代码执行

真正转账逻辑是:

  • 不在 Solidity

  • 不在你的合约

  • 不在代币合约

  • 而是在 EVM 的内部规范中实现

5.预言机

区块链本身不能主动访问外部数据(价格、天气、体育比分等)。
但是大多数 DApp(交易所、借贷协议、保险)又必须使用真实世界的数据。

典型应用:

  • DeFi:资产价格(借贷、清算、AMM、衍生品)
  • GameFi:随机数
  • NFT:元数据
  • 保险:气象、灾害数据
  • 预言机自动化:定时任务

于是需要一个可信组件:预言机 = 把链下数据安全地提供给链上的智能合约。

广义预言机 = [数据获取] + [数据验证] + [数据聚合] + [数据上链]

预言机 = 合约用来获得外部状态(价格、天气、利率)的方式

只要合约需要“外部状态”(不属于自己控制范围的数据)就需要预言机。

6.强制转账selfdestruct

用法:是一种合约无法拒绝的转账方式,不需要合约有任何接收函数,在部署攻击合约时带上value,调用attack时,会把攻击合约的所有余额转到目标合约

1
2
3
4
5
6
7
8
9
contract AttackForce{

constructor ()payable{

}

function attack (address payable target)external{
selfdestruct(target);
}

7.合约的状态变量存储解析

soliidty合约的状态变量存储以32个字节为一个单位,状态变量存储是依靠哈希表,存储索引从0开始递增,最大为2^256-1,如果多个变量长度加起来在32字节以内可以一起放在一个存储单位,对于变长数组作为状态变量的特殊情况,存储逻辑如下:变长数组所在索引作为数组的长度存储位置,数组单个元素的存储位置则是hash(数组长度所在索引)+元素在数组的索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// - 按声明顺序分配存储槽
// - 每个变量占用完整字节数,但可以共享 slot
// - 不能跨 slot 存储(除非是动态类型)

// 示例:
contract Packing {
uint128 a; // slot 0, 字节 0-15
uint128 b; // slot 0, 字节 16-31 ✓

uint256 c; // slot 1, 需要新槽
uint64 d; // slot 2, 字节 0-7
uint64 e; // slot 2, 字节 8-15
uint64 f; // slot 2, 字节 16-23
uint64 g; // slot 2, 字节 24-31 ✓
}
1
2
3
4
5
6
7
你的描述:hash(数组长度所在索引) + 元素索引
精确描述:keccak256(bytes32(slot_of_length)) + index

// 关键点:
1. slot_of_length 必须编码为 32 字节
2. keccak256 输入是单个 32 字节值,不是字符串
3. 这个值是确定的,与数组内容无关

映射存储规则:

1
2
3
4
5
6
7
8
mapping(key => value) myMap;  // 存储在 slot N

// 元素的存储位置:
keccak256(abi.encode(key, slot_N))

// 示例:mapping(address => uint) balances;
// balances[0xabc...] 存储在:
// keccak256(abi.encode(address(0xabc...), slot_of_mapping))

还有结构体存储规则等,遇到在行搜索

8.构造器版本变化

Solidity 从 0.4.22 版本之前,构造器是名字和合约名一样的函数,此版本之后引入了 constructor 关键字作为构造函数的全新、推荐写法。

版本 构造函数写法 说明
v0.4.22 之前 function 合约名() public { ... } 函数名必须和合约名完全一致(包括大小写),容易因拼写错误变成普通函数,引发安全漏洞。
v0.4.22 及之后 constructor(...) { ... } 使用统一的 constructor 关键字,清晰明了,避免了因函数命名错误带来的风险。