学习网站:
Solidity 官方文档
WTF 学院

代码框架

1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MyContract {

}

第一行是SPDX许可证标识,用于声明该合约使用 MIT 开源许可证。在 Solidity 0.6.8 及以上版本中,推荐显式声明许可证,它告诉用户该合约的使用权限和限制。
第二行pragma是 Solidity 中的关键字,用于指定编译器版本规则。

版本号

在任何一个Solidity智能合约中,首先需要的就是Solidity的使用版本,它应该被标注在Solidity代码的最上面。

Solidity是一个更新频率很高的语言,和别的语言相比,它总会有新版本,所以我们需要告诉代码,要用哪个版本。

  • ^0.8.0(最常见的形式):表示支持0.8.0及以上的版本(同 >= 0.8.0)。
  • 0.8.22:表示只支持0.8.22版本。
  • = 0.8.0 <= 0.9.0:表示支持0.8.0及以上0.9.0及以下的版本。

注释

Solidity 合约可以使用一种特殊形式的注释来提供丰富的函数、返回变量等的文档。这种特殊形式是 命名为以太坊自然语言规范格式(NatSpec)。
![[photo/Pasted image 20251018174621.png]]

数据类型

值类型 + 引用类型

  1. 值类型
    • 布尔类型(bool):布尔类型表示真或假的值。
    • 整数类型:整数类型分为有符号和无符号两种。
      (有符号整数类型包括int8、int16、int32、int64等。)
      (无符号整数类型包括uint8、uint16、uint32、uint64等。)
    • 地址类型(address):地址类型表示以太坊网络上的账户地址。
    • 定长字节类型(bytes):字节类型表示一组字节数据。(例如,bytes32表示32个字节的数据,一个字节由两个16进制表示)例如,bytes32 data; 可以存储32字节的数据,常用于存储哈希值等。
    • 枚举类型(enum):自定义的枚举类型可以用来定义一组具名的常量值。
  2. 引用类型
    • 动态大小字节数组(bytes和string)
    • 字节数组(bytes):动态长度的字节数组,用于存储字节数据。例如,bytes myData; 声明了一个动态字节数组,可以根据需要存储不同长度的字节数据。
    • 字符串(string):用于存储字符序列,本质上是一个特殊的动态字节数组。例如,string myString;可以存储文本信息。
    • 数组可以是固定长度也可以是动态长度。例如,uint[] public myArray;声明了一个动态长度的无符号整数数组。数组元素可以通过索引访问,如myArray[0]访问第一个元素。
    • 映射(Mapping):一种”键 - 值”对的数据存储结构。例如,mapping(address => uint) public balances;创建了一个将 address 类型映射到 uint 类型的映射,用于存储账户余额,通过 balances[address]来获取或设置特定账户的余额。

数据类型的默认值

在 Solidity 中,变量和状态变量都有默认值。这些默认值取决于变量的类型。

  • bool类型的默认值是 false。

  • 整数类型(包括 uint 和 int)的默认值是0。

  • 地址类型(address)的默认值是0x0000000000000000000000000000000000000000或 address(0)

  • 字符串类型(string):空字符串。

  • 动态数组(包括字符串数组)和映射的默认值是一个空的、长度为0的集合。

  • 对于结构体和枚举类型,默认值是其成员变量类型的默认值。

  • enum的默认值是枚举中的第一个元素。

delete a会让变量a的值变为初始值。

1
2
3
4
5
// delete操作符
bool public _bool2 = true;
function d() external {
delete _bool2; // delete会让_bool2变为默认值,false
}

变量

1. 状态变量

存在链上,所有合约内函数均可访问,耗 gas 多,在合约内、函数外声明,可以在函数里更改值

2. 局部变量

仅在函数中有效的变量,存在内存里,不上链,gas 低

3. 全局变量

都是 solidity 预留关键字,不用声明,在函数内直接用,具体看 官方文档,常用的有如下几个:

  • msg.sender (address):消息的发送者(当前调用)
  • tx.origin (address):交易的发送者
  • msg.value (uint):与消息一起发送的 wei 数量
  • blockhash(uint blockNumber) returns (bytes32):给定区块的哈希,当 blocknumber 是最近 256 个区块之一时;否则返回零
  • block.number (uint):当前区块编号
  • block.timestamp (uint):当前区块的时间戳,以自 Unix 纪元以来的秒数表示
  • msg.data (bytes calldata):完整的 calldata
  • msg.sig (bytes4):calldata 的前四个字节(即函数标识符)

tx.origin

在 Solidity 中,tx.origin 是一个全局变量,它表示发起当前交易的外部账户的地址。

  • 当一个外部账户直接发送一个交易时,tx.origin 就是该外部账户的地址。例如,如果你使用 MetaMask 从你的以太坊账户发送一个交易到一个智能合约,tx.origin 将是你在 MetaMask 中使用的以太坊账户地址。
  • 然而,当一个智能合约调用另一个智能合约,而这个调用链最终是由一个外部账户发起的,tx.origin 仍然是最初发起该调用链的外部账户的地址,而不是中间的智能合约地址。

假设一个外部账户 Alice 调用 contract A 的 callB 函数,contract A 再调用 contract B 的 someFunction 函数。在 contract B 的 someFunction 中:

  • msg.sender 将是 contract A 的地址,因为 contract A 是直接调用 contract B 的。
  • tx.origin 将是 Alice 的地址,因为 Alice 是最初发起整个交易的外部账户。
    在实际开发中,应谨慎使用 tx.origin,为了安全起见,通常更倾向于使用 msg.sender 进行身份验证和权限检查,因为使用 tx.origin 可能会导致合约被恶意合约利用,从而引发安全漏洞。

构造函数

在Solidity中,一个合约只能有一个构造函数。
原因:构造函数是在合约部署时执行初始化操作的特殊函数。如果允许多个构造函数,编译器将难以确定在部署合约时应该执行。
作用:初始化状态变量/执行一次性配置。

枚举

1
2
3
4
5
    //角色:普通用户、医生、管理员
    enum Role{NormalUser,Doctor,Admin}

    //病历审核状态:待定、已通过、通过失败
    enum RecordStatus{Pending,Approved,Rejected}

映射

声明映射的格式为`mapping(_KeyType => _ValueType)。

1
2
3
4
5
    //映射:判断某个地址是否已经注册,避免重复注册
    mapping(address=>bool)public isUserRegistered;
   
    //注册系统时检查该地址是否已经被注册
    require(!isUserRegistered[msg.sender],"Error!User has already registered.");

映射的存储位置只能是storage,不能声明为memorycalldata

数组

存储位置 storage|memory|calldata

Solidity 中有三种数据存储位置,用于指定变量在区块链上的存储方式和生命周期。

  1. storage:数据存储在区块链的永久存储中(合约的状态变量默认为此类型),修改会消耗gas。只要合约存在,这些数据就一直存在,相当于把重要文件存放在一个长期的仓库中。
  2. memory:数据临时存储在内存中,仅在函数执行期间存在。函数调用结束后,数据就会被销毁。就像在一个临时的工作区域处理数据,处理完就清理掉。修改不消耗额外gas(但分配内存会消耗)。
  3. calldata:用于函数的外部输入参数(如external函数的参数),是只读的临时数据,与memory类似但更节省 gas。

    为什么映射必须存在storage中?
    映射的底层实现依赖于区块链的永久存储结构(storage)。它本质上是一种“键-值”对的哈希表,其数据存储在合约的状态存储中,通过键的哈希计算直接定位值的存储位置,这种机制与storage的布局紧密绑定。

访问权限public|private|internal|external

  • public:任何人可调用(包括合约内部、外部合约、外部账户)。
  • private:仅能被当前合约内部访问(子类也不能调用)。
  • internal:当前合约及子类(继承它的合约)可调用。
  • external:仅外部可调用(外部合约或外部账户),合约内部不能直接调用(需用 this.)。例如接口函数、供外部交互的函数(如转账 transfer),使用externalpublic更节省gas。

函数修饰符 view|pure

作用:限制函数对合约状态的读写行为,目的是明确函数是否会修改区块链状态,同时帮助编译器优化gas消耗。

  • view:看!只能读取数据不能更改数据。
  • pure:纯!纯用给的参数。

    view且非pure,可以读取、修改状态变量。

修饰器 modifier

例如,有一个智能合约函数用来更新重要的数据,在调用这个函数之前,可能需要验证调用者是不是合约的拥有者。这个验证步骤就可以写成一个modifier 。当你定义好这个modifier并且应用到函数上之后,每次调用这个函数,就会先执行modifier里的验证规则,只有验证通过了,函数的主体部分才会执行。

1
2
3
4
5
6
7
8
9
10
    // 定义modifier
modifier onlyOwner() {
require(msg.sender == owner, "只有合约所有者才能执行此操作");
_;_
}
//某个函数添加onlyOwner关键字,每次执行前先按照修饰器逻辑检查
function setValue(uint256 _newValue) public onlyOwner {
// 这里可以添加具体设置值的逻辑,比如更新某个变量的值
}
}

接口 interface

  1. Functions in interfaces must be declared external.
  2. Functions in interfaces cannot have modifiers.
  3. 接口不仅能写函数,还能写状态变量。

继承 is

继承是面向对象编程很重要的组成部分,可以显著减少重复代码。

  • override:子合约重写了父合约中的函数,需要加上override关键字。
  • virtual: 父合约中的函数,如果希望子合约重写,需要加上virtual关键字。
1
2
3
4
//is这个关键字说明A是B的子合约
contract A is B{
//逻辑
}

导入 import

在Solidity中,当你有多个文件并且想把一个文件导入另一个文件时,可以使用 import 语句。

1
2
3
4
5
//引入第三方库
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

//引入其他文件
import "./someothercontract.sol";

事件 event

事件是合约和区块链通讯的一种机制。你的前端应用“监听”某些事件,并做出反应
当合约执行到emit语句时,会将事件数据写入区块链日志。

1
2
3
4
5
6
7
8
9
    //事件:用户注册成功之后触发(注意:事件需要移至全局区域)
    event UserRegistered(
        uint256 indexed userId,
        address indexed userAddress,
        string userName,
        Role role
    );
    //触发事件 关键字emit
    emit UserRegistered(newUserId,msg.sender,_userName,_role);

异常处理

Solidity提供了多种异常处理机制,主要包括 requirerevertassert 三个关键字。

  1. require(检查条件,"异常的描述"),当检查条件不成立的时候为false,则触发异常,回滚所有状态变更,并将错误提示信息返回给调用者;如果为true,则继续执行。

    若触发异常,会消耗已执行到当前步骤的 gas,但不会消耗后续未执行代码的 gas(相对经济)

  2. revert("错误提示信息");直接终止执行,回滚状态变更,返回错误信息,属于主动触发,更适合在复杂逻辑(如多分支判断)后抛出异常。

    require 相同,仅消耗到当前步骤的 gas。

  3. assert(条件表达式),如果 条件表达式 为false,触发异常并回滚状态;若为true,继续执行。

    若触发异常,会消耗所有剩余 gas(非常昂贵),因此不适合用于验证用户输入或外部条件。

Solidity 0.8.4 引入了自定义错误,相比字符串错误信息(require/revert 的提示),自定义错误更节省 gas,尤其适合高频调用的函数。

  1. 定义自定义错误:在合约内用 error 关键字声明(类似事件)。
  2. 触发自定义错误:用 revert 关键字触发。
1
2
3
4
5
6
7
8
9
10
11
// 定义自定义错误(可带参数)
error InsufficientBalance(uint256 available, uint256 required);
error InvalidReceiver(address receiver);

// 触发自定义错误
if (to == address(0)) {
revert InvalidReceiver(to);
}
if (balances[msg.sender] < amount) {
revert InsufficientBalance(balances[msg.sender], amount);
}

特殊函数

fallback()与receive()

维度 纯 ETH 交易 函数调用交易
目的 仅转移 ETH 触发合约特定函数逻辑
交互对象 EOA 或合约账户(但不调用函数) 必须是合约账户(调用其函数)
data字段 为空(0x 非空,包含函数选择器和参数
触发的合约行为 若接收方是合约,仅触发fallback/receive 触发指定函数的完整逻辑
典型场景 用户间 ETH 转账 代币转账、NFT 铸造、DeFi 操作等
例子 contractAddress.send(1 ether) contract.deposit{value: 1 ether}()
receive()是 Solidity 0.6.0 引入的特殊函数,专门用于处理纯 ETH 转账(即交易的 data 为空,仅发送 ETH)。
声明条件
  1. 函数声明必须为 external payablepayable 表示可接收 ETH,external 表示仅外部可调用)。
  2. 不能有参数,不能有返回值。
  3. 不能有函数修饰符(如 viewpure 等)。
    触发条件
  4. 交易的 to 是当前合约地址;
  5. 交易的 value(发送的 ETH 数量)大于 0;
  6. 交易的 data 字段为空(0x);
  7. 合约已定义 receive() 函数(若未定义,会尝试触发 fallback())。
1
2
3
4
//正确声明
receive() external payable {
// 处理纯 ETH 接收的逻辑(如记录余额)
}

fallback() 是更早存在的特殊函数,用于处理未被匹配的交易,包括两种场景:带数据的 ETH 转账,或调用不存在的函数。
语法规则

  • 函数声明为 external payablepayable 可选:若需接收 ETH,则必须加 payable;若不接收,可省略)。
  • 不能有参数,不能有返回值。
  • 不能有函数修饰符。
    触发条件
    满足以下任一条件时触发:
  1. 交易向合约发送 ETH(value > 0),且 data 字段非空(带数据),且合约未定义 receive() 函数;
  2. 调用合约中不存在的函数(无论是否带 ETH)。
1
2
3
4
5
6
7
8
9
// 可接收 ETH 的 fallback
fallback() external payable {
// 处理带数据的 ETH 或未定义函数调用
}

// 不可接收 ETH 的 fallback(若收到 ETH 会报错)
fallback() external {
// 仅处理不带 ETH 的未定义函数调用
}

散列函数keccak256

keccak256是 Solidity 中内置的一个全局函数,用于计算数据的 Keccak-256 散列值。

它是Ethereum 内部有一个散列函数,它用了SHA3版本。它接收任意长度的输入,并输出一个固定长度的 32 字节 (256 位)的散列值。字符串的一个微小变化会引起散列数据极大变化。

功能:提供数据的唯一性完整性验证,并实现一些高级的合约逻辑。

  1. 唯一性:就是将任意数据(如字符串、数字、地址等)转化为一个唯一的、不可逆的 32 字节“指纹”。用于数据承诺 (Data Commitments)、生成唯一 ID以及地址的生成(例如,通过 ecrecover 验证签名时,需要先对数据进行散列)
  2. 完整性验证:对输入数据进行微小的改动,都会导致输出的散列值发生巨大的、不可预测的变化(雪崩效应)。用于验证数据在传输或存储过程中是否被篡改。显而易见,下面这个例子中,输入字符串只改变了一个字母,输出就已经天壤之别了。
1
//6e91ec6b618bb462a4a6ee5aa2cb0e9cf30f7a052bb467b0ba58b8748c00d2e5 keccak256("aaaab"); //b1f078126895a1424524de5321b339ab00408010b7cf0e6ed451514981e58aa9 keccak256("aaaac");
  1. 生成伪随机数:虽然散列函数本身是确定性的,但在无法预知输入的情况下,其输出看起来是随机的。有限地用于生成不会影响核心安全的“伪随机数”,例如游戏中的装饰性元素。 由于矿工可以预知或影响输入,绝对不能keccak256作为生成安全随机数的唯一方法来决定资金分配或关键游戏结果。
  2. 高级逻辑实现:Merkle证明,在实现默克尔树 (Merkle Tree)相关的逻辑时,keccak256 是基础组件,用于验证一个数据点是否属于一个预先承诺的集合(例如空投、白名单)。

类型转换

Solidity不允许“跨尺寸直接转换”,主要是为了防止意外的数据丢失。比如从大尺寸转到小尺寸时的数据截断。但允许“扩大转换”,将小尺寸类型转换为大尺寸类型是允许的,因为这不会丢失任何数据,只是在高位填充零。
举个例子,uint256(uint160(msg.sender)),address占20个字节,uint160也占20个字节,uint256占32个字节,那为什么不直接转换?尽管是“扩大”,但 Solidity 也避免这种不明确的、跨语义类型的直接跳跃,它强制您使用中间类型。

存储槽

在以太坊智能合约中,存储槽(Storage Slot)是用于存储合约状态变量的基本单位

  1. 定义:*每个存储槽是一个固定大小的 32 字节 (256 位) 空间。
  2. 编号: 存储槽从 槽 0 (Slot 0)开始,依次编号为 $0, 1, 2, 3, \ldots$。
  3. 持久性: 合约中被声明为状态变量的数据(即在函数外部声明的变量)都永久地存储在这些槽中。修改存储槽是EVM中最昂贵的操作之一(因为数据必须被永久记录到区块链上)。

这些存储位置的分配有什么影响?数据是怎么排列存储到对应的位置上的?
Solidity编译器采用一套严格的规则来决定状态变量如何被打包和分配到存储槽中,目的是尽可能节省Gas成本。基本原则是合约中的状态变量是按照它们声明的顺序,从槽0开始依次分配的。其次,为了节省空间和 Gas,Solidity 会尝试将多个小于 32 字节的变量打包到一个存储槽中

1
2
3
4
5
uint256 totalBalance; // 槽 0 (32 字节)

uint8 flag1; // 槽 1 开始
uint8 flag2;
uint256 timestamp; // 槽 2 开始 (因为 flag1 和 flag2 加起来只有2字节)
  1. 结构体 (struct) 或静态大小的数组:
    总是从一个新的存储槽开始。结构体/静态数组之后的任何状态变量,也总是从一个新的存储槽开始。
  2. 映射与动态数组:
    它们本身在状态变量序列中只占用 一个 32 字节的槽 P(主槽Slot P)。这个槽 P 存储的不是数据,而是指向数据存储位置的指针。那么数据位置在哪里?它们内部的元素被存储在另一个计算出的位置,这个位置是通过对槽 P 进行 keccak256 哈希计算得出的。
    数据存储的起始位置= keccak256(P)
  3. bytes和string
    短数据(Short): 如果长度小于 32 字节,数据和长度信息会被打包存储在同一个槽 P 中。
    长数据(Long): 如果长度大于等于 32 字节,槽 P 只存储数据的长度,而实际数据被存储在 $\text{keccak256}(\text{P})$ 处
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Privacy {
    bool public locked = true;
    uint256 public ID = block.timestamp;
    uint8 private flattening = 10;
    uint8 private denomination = 255;
    uint16 private awkwardness = uint16(block.timestamp);
    bytes32[3] private data;



    constructor(bytes32[3] memory _data) {
        data = _data;
    }



    function unlock(bytes16 _key) public {
        require(_key == bytes16(data[2]));
        locked = false;
        }

1.Solidity 变量按声明顺序存储,存储槽分配如下:

  • locked(bool) → ​槽0​(1字节)
  • ID(uint256) → ​槽1​(32字节)
  • flattening(uint8)、denomination(uint8)、awkwardness(uint16) → ​槽2​(4字节,填充到32字节)
  • data[0] → ​槽3
  • data[1] → ​槽4
  • data[2] → ​槽5
    2.读取槽5的数据:使用工具(如 web3.js 或 ethers.js
1
const data2 = await web3.eth.getStorageAt(contractAddress, 5);

扩展
如何将data2 转换为 bytes16(取前16字节)作为_key?

  • 首先,存储槽5中的数据bytes32是一个32字节的十六进制字符串,比如0x开头加上64个字符。bytes16需要的是前32个字符(16字节)。
  • 例如,如果data20xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899,那么前16字节是0xaabbccddeeff00112233445566778899,对应的bytes16就是取前32个字符(去掉0x后的前32位)。

eg1.在合约中直接使用 bytes16(data[2]) 时,Solidity 会自动截取 data[2] 的前 16 字节。

1
2
bytes32 value = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;
bytes16 key = bytes16(value); // 结果为 0x1234567890abcdef1234567890abcdef

RPC协议

JSON-RPC 是一种无状态、远程过程调用 (RPC) 协议,它使用 JSON (JavaScript Object Notation) 作为数据传输格式。
在以太坊生态系统的语境中,JSON-RPC 是一种至关重要的通信协议,它是智能合约、以太坊节点和用户界面 (Web App/Wallet) 之间进行交互的桥梁。
Solidity 本身是一种编程语言,用于编写智能合约。它不直接包含 JSON-RPC,但你部署的合约必须通过 JSON-RPC 才能被外界访问。

abi函数

1
address user1 = 0x1234...; bytes memory packed = abi.encodePacked(user1); // 结果:0x0000000000000000000000001234... (20字节)

encodePacked vs encode 的区别

  • abi.encodePacked()紧密打包,去掉填充,节省空间
  • abi.encode()标准打包,有固定的填充规则

Call函数

1
// 示例1:纯转账(不调用函数) (bool success, ) = address(cryptoKeeper1).call{value: 10 ether}(""); // 示例2:调用函数并转账 (bool success, ) = address(cryptoKeeper1).call{value: 1 ether}( abi.encodeWithSignature("deposit()") ); // 示例3:只调用函数,不转账 (bool success, bytes memory result) = address(token).call( abi.encodeWithSignature("balanceOf(address)", user1) );