;)
1.Solidity中的变量类型
- **值类型(Value Type)**:包括1.布尔型,2.整数型3.地址类型、4.定长字节数组 5.枚举 enum、这类变量赋值时候直接传递数值。
- **引用类型(Reference Type)**:包括数组和结构体,变长数组bytes,这类变量占空间大,赋值时候直接传递地址(类似指针)。
- 映射类型(Mapping Type): Solidity中存储键值对的数据结构,可以理解为哈希表
soliidty的数据强转规则:
数值类型是保留低位(右边),bytes类型是保留高位。 —-(增加还是截断都是)
截断和补位规则
| uint:从右往左看,保持低位数据不变 | 小->大:左边(高位)补充0 uint8 -> uint16 : 0x12 -> 0x0012 |
| 大->小:左边(高位)截断 uint16 -> uint8 : 0x1234 -> 0x34 | |
| bytes:从左往右看,保持高位数据不变 | 小->大 :右边(低位)补充0 bytes1 ->bytes2 : “0x12” -> “0x1200” |
| 大->小:右边(低位)截断 bytes2 ->bytes1 : “0x1234” -> “0x12” | |
| 地址→bytes32左侧补零 | |
| bytes->uint , | bytes过大选取低位,bytes过小则高位补0(uint考虑角度) |
| uinit->bytes | uint过大报错,需要截断到刚刚适配,uint过小高位补0(uint考虑角度) |
2.变量数据存储和作用域storage/memory/calldata
**引用类型(Reference Type)**:包括数组(array)和结构体(struct),由于这类变量比较复杂,占用存储空间大,我们在使用时必须要声明数据存储的位置。
数据位置
Solidity数据存储位置有三类:storage,memory和calldata。不同存储位置的gas成本不同。storage类型的数据存在链上,类似计算机的硬盘,消耗gas多;memory和calldata类型的临时存在内存里,消耗gas少。整体消耗gas从多到少依次为:storage > memory > calldata。大致用法:
storage:合约里的状态变量默认都是storage,存储在链上。memory:函数里的参数和临时变量一般用memory,存储在内存中,不上链。尤其是如果返回数据类型是变长的情况下,必须加memory修饰,例如:string, bytes, array和自定义结构。calldata:和memory类似,存储在内存中,不上链。与memory的不同点在于calldata变量不能修改(immutable),一般用于函数的参数。例子:
1 | function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){ |
数据位置和赋值规则
在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)。规则如下:
赋值本质上是创建引用指向本体,因此修改本体或者是引用,变化可以被同步:
storage(合约的状态变量)赋值给本地storage(函数里的)时候,会创建引用,改变新变量会影响原变量。例子:1
2
3
4
5
6
7uint[] x = [1,2,3]; // 状态变量:数组 x
function fStorage() public{
//声明一个storage的变量 xStorage,指向x。修改xStorage也会影响x
uint[] storage xStorage = x;
xStorage[0] = 100;
}
变量的作用域
Solidity中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)
全局变量是全局范围工作的变量,都是solidity预留关键字。他们可以在函数内不声明直接使用:
1 | function global() external view returns(address, uint, bytes memory){ |
全局变量
下面是一些常用的全局变量,更完整的列表请看这个链接:
blockhash(uint blockNumber): (bytes32) 给定区块的哈希值 – 只适用于最近的256个区块, 不包含当前区块。block.coinbase: (address payable) 当前区块矿工的地址block.gaslimit: (uint) 当前区块的gaslimitblock.number: (uint) 当前区块的numberblock.timestamp: (uint) 当前区块的时间戳,为unix纪元以来的秒gasleft(): (uint256) 剩余 gasmsg.data: (bytes calldata) 完整call datamsg.sender: (address payable) 消息发送者 (当前 caller)msg.sig: (bytes4) calldata的前四个字节 (function identifier)msg.value: (uint) 当前交易发送的wei值block.blobbasefee: (uint) 当前区块的blob基础费用。这是Cancun升级新增的全局变量。blobhash(uint index): (bytes32) 返回跟当前交易关联的第index个blob的版本化哈希(第一个字节为版本号,当前为0x01,后面接KZG承诺的SHA256哈希的最后31个字节)。若当前交易不包含blob,则返回空字节。这是Cancun升级新增的全局变量。
3.映射类型 mapping
在映射中,人们可以通过键(Key)来查询对应的值(Value),比如:通过一个人的id来查询他的钱包地址。
声明映射的格式为mapping(_KeyType => _ValueType),其中_KeyType和_ValueType分别是Key和Value的变量类型。例子:
1 | mapping(uint => address) public idToAddress; // id映射到地址 |
映射的规则
规则1:映射的
_KeyType只能选择Solidity内置的值类型,比如uint,address等,不能用自定义的结构体。而_ValueType可以使用自定义的类型。下面这个例子会报错,因为_KeyType使用了我们自定义的结构体:1
2
3
4
5
6// 我们定义一个结构体 Struct
struct Student{
uint256 id;
uint256 score;
}
mapping(Student => uint) public testVar;规则2:映射的存储位置必须是
storage,因此可以用于合约的状态变量,函数中的storage变量和library函数的参数(见例子)。不能用于public函数的参数或返回结果中,因为mapping记录的是一种关系 (key - value pair)。规则3:如果映射声明为
public,那么Solidity会自动给你创建一个getter函数,可以通过Key来查询对应的Value。1
2
3
4
5
6
7
8
9
10
11contract yinshe{
struct Student{
uint256 id;
uint256 score;
}
mapping(uint => Student) public testVar;
function f() public {
testVar[1] = Student(1,100);
Student memory s1 = testVar[1];
}
}规则4:给映射新增的键值对的语法为
_Var[_Key] = _Value,其中_Var是映射变量名,_Key和_Value对应新增的键值对。例子:1
2
3function writeMap (uint _Key, address _Value) public{
idToAddress[_Key] = _Value;
}
新增:mapping创建后所有的key对应的value都默认已存在,值为默认值
4.构造函数和修饰器
构造函数
构造函数(constructor)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner地址:
1 | address owner; // 定义owner变量 |
修饰器
修饰器(modifier)是Solidity特有的语法,类似于面向对象编程中的装饰器(decorator),声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
我们来定义一个叫做onlyOwner的modifier:
1 | // 定义modifier |
_; 表示被修饰的函数体会在这里插入。
也就是说,执行流程是:
- 先运行
modifier中_之前的逻辑(如果有)。 - 然后执行实际的函数体(替换
_;)。 - 最后再运行
modifier中_之后的逻辑(如果有)。
带有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子:
1 | function changeOwner(address _newOwner) external onlyOwner{ |
5.事件
声明事件
事件的声明由event关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20代币合约的Transfer事件为例:
1 | event Transfer(address indexed from, address indexed to, uint256 value); |
我们可以看到,Transfer事件共记录了3个变量from,to和value,分别对应代币的转账地址,接收地址和转账数量,其中from和to前面带有indexed关键字,他们会保存在以太坊虚拟机日志的topics中,方便之后检索。
关于index:一个事件最多允许存在3个自定义的index变量,每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在topics中。
假设有两个转账:
1 | emit Transfer(Alice, Bob, 100); |
对应日志会是:
- 第 1 条日志
- topics[0] = keccak256(“Transfer(address,address,uint256)”)//事件签名
- topics[1] = Alice
- topics[2] = Bob
- data = 100
- 第 2 条日志
- topics[0] = keccak256(“Transfer(address,address,uint256)”)
- topics[1] = Alice
- topics[2] = Carol
- data = 200
释放事件
我们可以在函数里释放事件。在下面的例子中,每次用_transfer()函数进行转账操作的时候,都会释放Transfer事件,并记录相应的变量。
1 | // 定义_transfer函数,执行转账逻辑 |
浏览器查看事件:
Topics里面有三个元素,[0]是这个事件的哈希,[1]和[2]是我们定义的两个indexed变量的信息,即转账的转出地址和接收地址。Data里面是剩下的不带indexed的变量,也就是转账数量。
6.库合约 站在巨人的肩膀上
库合约
他和普通合约主要有以下几点不同:
- 不能存在状态变量
- 不能够继承或被继承
- 不能接收以太币
- 不可以被销毁
需要注意的是,库合约中的函数可见性如果被设置为public或者external,则在调用这个函数时会触发一次delegatecall(使用库合约里部署好的函数,不用消耗额外的gas),此时会在调用合约的上下文中执行库合约的代码。而如果被设置为internal,则不会触发(实际上是直接复制库函数到代码里,会增加gas的消耗)。对于设置为private可见性的函数来说,其仅能在库合约中可见,在其他合约中不可见。
如何使用库合约
我们用Strings库合约的toHexString()来演示两种使用库合约中函数的办法。
1.利用using for指令
通过using A for B;指令,库合约 A 中的函数可被附加到类型 B 的变量上,实现直接调用。注意:调用时,该变量会自动作为函数的第一个参数传入,无需显式传递:
1 | // 利用using for指令 |
解释:
_number.toHexString()→ 编译器会自动翻译成Strings.toHexString(_number)。_number被当作第一个参数自动传入。- 看起来就像
_number这个变量有了自己的方法。
好处:
- 代码更直观,尤其是当一个类型需要频繁调用库函数时。
- 和面向对象语言里的“扩展方法”很像
2.通过库合约名称调用函数
1 | // 直接通过库合约名调用 |
7.发送ETH
transfer
- 用法是
接收方地址.transfer(发送ETH数额)。 transfer()的gas限制是2300,足够用于转账,但对方合约的fallback()或receive()函数不能实现太复杂的逻辑。transfer()如果转账失败,会自动revert(回滚交易)。
代码样例,注意里面的_to填ReceiveETH合约的地址,amount是ETH转账金额:
1 | // 用transfer()发送ETH |
send
- 用法是
接收方地址.send(发送ETH数额)。 send()的gas限制是2300,足够用于转账,但对方合约的fallback()或receive()函数不能实现太复杂的逻辑。send()如果转账失败,不会revert。send()的返回值是bool,代表着转账成功或失败,需要额外代码处理一下。
代码样例:
1 | error SendFailed(); // 用send发送ETH失败error |
call(推荐,防护要求高)
用法是
接收方地址.call{value: 发送ETH数额}("")。完整用法:
(bool success, bytes memory data) = target.call{value: ETH_AMOUNT, gas: GAS_LIMIT}(encodedData);
target:目标合约地址(或 EOA 地址也可以发送 ETH)value:要发送的 ETH 数量gas:可选,发送给目标合约的 gasencodedData:调用函数的数据(ABI 编码)call()没有gas限制,可以支持对方合约fallback()或receive()函数实现复杂逻辑。call()如果转账失败,不会revert。call()的返回值是(bool, bytes),其中bool代表着转账成功或失败,需要额外代码处理一下。
代码样例:
1 | error CallFailed(); // 用call发送ETH失败error |
低级调用(low-level call)的特殊性:
address.call{value: ...}()是 Solidity 设计上专门允许的写法。- 这时候即使
_addr只是普通address,编译器也不会报错,因为低级调用本身可以携带 ETH(相当于强制允许)。 - 而在 高级调用(transfer / send) 的时候,就必须用
address payable,因为编译器要求你明确表明“这个地址是可以收钱的”。
换句话说:
transfer/send→ 需要address payable,因为这些方法就是支付接口。.call{value: …}()→ 对address也能用,因为这是低级调用,本质是个“万能接口”,编译器不会限制。也就是说call可以适用于任何合约,而transfer和send只是有使用有有payable修饰的合约
8.调用其他合约
1. 传入合约地址
我们可以在函数里传入目标合约地址,生成目标合约的引用,然后调用目标函数。以调用OtherContract合约的setX函数为例,我们在新合约中写一个callSetX函数,传入已部署好的OtherContract合约地址_Address和setX的参数x:
1 | function callSetX(address _Address, uint256 x) external{ |
补充:当名字为OtherContract的合约在同一个文件里或者已经引入了这个合约才能写 OtherContract(_Address).setX(x);
2. 传入合约变量
我们可以直接在函数里传入合约的引用,只需要把上面参数的address类型改为目标合约名,比如OtherContract。下面例子实现了调用目标合约的getX()函数。
注意:该函数参数OtherContract _Address底层类型仍然是address,生成的ABI中、调用callGetX时传入的参数都是address类型
1 | function callGetX(OtherContract _Address) external view returns(uint x){ |
| 写法 | 参数类型 | 调用灵活性 | 安全性/可读性 |
|---|---|---|---|
OtherContract(_Address).setX(x) |
address |
高(任何地址都能传) | 低(必须自己保证地址真的是那个合约) |
callGetX(OtherContract _Address) |
OtherContract |
一般(只能传这个合约类型的地址) | 高(编译器帮你检查类型)写法 |
两种方式 效果一样,都是在目标地址上调用 setX/getX,只是:
- 前者用 裸地址 + 强转
- 后者用 合约类型参数
3. 创建合约变量
我们可以创建合约变量,然后通过它来调用目标函数。下面例子,我们给变量oc存储了OtherContract合约的引用:
1 | function callGetX2(address _Address) external view returns(uint x){ |
感觉和方法1差不多,多了一个没必要的步骤
4. 调用合约并发送ETH
如果目标合约的函数是payable的,那么我们可以通过调用它来给合约转账:_Name(_Address).f{value: _Value}(),其中_Name是合约名,_Address是合约地址,f是目标函数名,_Value是要转的ETH数额(以wei为单位)。
OtherContract合约的setX函数是payable的,在下面这个例子中我们通过调用setX来往目标合约转账。
1 | function setXTransferETH(address otherContract, uint256 x) payable external{ |
就是方法1,只是多了一个发送eth
9.call
调用setX函数
我们定义callSetX函数来调用目标合约的setX(),转入msg.value数额的ETH,并释放Response事件输出success和data:
1 | function callSetX(address payable _addr, uint256 x) public payable { |
abi.encodeWithSignature(“func(uint256,address)”, 123, addr)🔑 含义
它的作用是:
把函数签名 + 参数一起编码成 bytes,可以作为低级调用 (.call) 的输入。
📦 分解步骤
函数签名字符串
"func(uint256,address)"- 这里写的是 函数名 + 参数类型(精确匹配类型)。
- 编译器会用
keccak256("func(uint256,address)")取哈希,然后取前 4 个字节,生成 **函数选择器 (selector)**。 - 这 4 个字节就是告诉 EVM:“我要调用
func(uint256,address)这个函数”。
👉 举例:
1
2
3keccak256("func(uint256,address)")
= 0x12345678abcd...
selector = 0x12345678参数列表
(123, addr)- 这些是你要传给函数的实参。
123会编码成 32 字节(uint256)。addr会编码成 32 字节(地址左填充 0)。
拼接结果
最终结果是一个
bytes,内容 =selector (4 bytes)+编码参数们。就像这样:
1
2
30x12345678 // 选择器
000000...07b // 123,填充成 32 字节
000000...addr // 地址,填充成 32 字节
调用getX函数
下面我们调用getX()函数,它将返回目标合约_x的值,类型为uint256。我们可以利用abi.decode来解码call的返回值data,并读出数值。
1 | function callGetX(address _addr) external returns(uint256){ |
abi.encode\* :把函数参数(或数据)打包成字节数据(bytes)。
abi.decode :把字节数据(bytes)解码还原成原本的参数类型。
调用不存在的函数
如果我们给call输入的函数不存在于目标合约,那么目标合约的fallback函数会被触发。
1 | function callNonExist(address _addr) external{ |
上面例子中,我们call了不存在的foo函数。call仍能执行成功,并返回success,但其实调用的目标合约fallback函数。
附加:当在call的对象是自己时,不会修改链上状态,所以无意义,要避免这种使用。如果是对自己用delegatecall则会修改成功
10.Delegatecall
对应规则:只看逻辑合约,逻辑合约里修改的是第几个状态变量,代理合约就修改第几个,类型符不符合不重要,因为EVM 的 storage 里只有 32 字节(256 位)的“槽位”,根本没有 address / uint 的类型概念;address 只是 Solidity 在“解释这 32 字节时用的规则”。不关心这是不是地址、整数、bool
要求:合约B和合约C的状态变量要按照顺序一一对应,名字可以不一样要求:合约B和合约C的状态变量要按照顺序一一对应,名字可以不一样:
在 Solidity 的 delegatecall 场景下,关键不是变量名字,而是 存储槽(storage slot)的位置。
- 每个状态变量在合约里都有一个 固定的存储槽(slot),Solidity 会按顺序分配:
- 第一个状态变量 → slot 0
- 第二个状态变量 → slot 1
- …
- delegatecall 只是执行逻辑代码,但所有的 存取操作都是针对调用者合约的存储槽。
所以:
- 变量 名称可以不同,只要它们在 相同 slot 上存储的类型和大小一致。
- 如果顺序或类型不一致,delegatecall 可能把数据写错,导致严重错误。
🔹 举例
1 | // 逻辑合约 Logic |
- 名称不同(x vs a,owner vs b) ✅
- 只要顺序和类型一致,delegatecall 会正确写到 Proxy.a。
delegatecall与call类似,是Solidity中地址类型的低级成员函数。delegate中是委托/代表的意思,那么delegatecall委托了什么?
当用户A通过合约B来call合约C的时候,执行的是合约C的函数,上下文(Context,可以理解为包含变量和状态的环境)也是合约C的:msg.sender是B的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C的变量上。
而当用户A通过合约B来delegatecall合约C的时候,执行的是合约C的函数,但是上下文仍是合约B的:msg.sender是A的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B的变量上。
大家可以这样理解:一个投资者(用户A)把他的资产(B合约的状态变量)都交给一个风险投资代理(C合约)来打理。执行的是风险投资代理的函数,但是改变的是资产的状态。
什么情况下会用到delegatecall?
目前delegatecall主要有两个应用场景:
- 代理合约(
Proxy Contract):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract)里,通过delegatecall执行。当升级时,只需要将代理合约指向新的逻辑合约即可。 - EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合约的代理合约。 更多信息请查看:钻石标准简介。
11.ABI编码
自我理解:与计算机对话涉及两种语言,一种是底层的01001(可按照进制法则直接转换成16进制),一种是各国语言(中英文等)
两种语言之间可以按照匹配法则直接转换(在soliidty中是转换成16进制,可以理解为底层语言,区别不大),而abi编码的作用就是这个(encode和encodPacked),只是补全有没有0的区别
1 | function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){ |
总结
- 为什么有 ABI? 因为区块链只认识字节码,需要统一的“翻译标准”让外部调用函数。
- 作用? 把函数调用编码成十六进制数据,把返回值解码成人类可理解的数据。
- 原理? 函数选择器 + 32字节对齐的参数编码/解码规则。
- 应用? ABI JSON 文件是前端、钱包、RPC 调用合约的唯一桥梁。
ABI编码
abi.encode
将给定参数利用ABI规则编码。ABI被设计出来跟智能合约交互,他将每个参数填充为32字节的数据,并拼接在一起。如果你要和合约交互,你要用的就是abi.encode。
1 | function encode() public view returns(bytes memory result) { |
1 | 000000000000000000000000000000000000000000000000000000000000000a // x |
abi.encodePacked
将给定参数根据其所需最低空间编码。它类似 abi.encode,但是会把其中填充的很多0省略。比如,只用1字节来编码uint8类型。当你想省空间,并且不与合约交互的时候,可以使用abi.encodePacked,例如算一些数据的hash时。需要注意,abi.encodePacked因为不会做填充,所以不同的输入在拼接后可能会产生相同的编码结果,导致冲突,这也带来了潜在的安全风险。
1 | function encodePacked() public view returns(bytes memory result) { |
编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a7a58c0be72be218b41c608b7fe7c5bb630736c713078414100000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006,由于abi.encodePacked对编码进行了压缩,长度比abi.encode短很多。
abi.encodeWithSignature与abi.encodeWithSelector
其实它们是等价的,只是写法不同:
1 | // encodeWithSignature |
encodeWithSignature:与abi.encode功能类似,只是第一个参数为函数签名,比如"foo(uint256,address,string,uint256[2])"。当调用其他合约的时候可以使用。输入函数签名字符串,自动算 selector,适合开发阶段/方便调用。
encodeWithSelector:与abi.encodeWithSignature功能类似,只是第一个参数为函数选择器,为函数签名Keccak缓存的前4个字节。 你自己提供 selector,更灵活、更省 gas,常用于底层合约逻辑。encodeWithSignature:输入函数签名字符串,自动算 selector,适合开发阶段/方便调用。
ABI解码
abi.decode
abi.decode用于解码abi.encode生成的二进制编码,将它还原成原本的参数。
1 | function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) { |
12.ERC20
ERC20是以太坊上的代币标准,来自2015年11月V神参与的EIP20。它实现了代币转账的基本逻辑:
- 账户余额(balanceOf())
- 转账(transfer())
- 授权转账(transferFrom())
- 授权(approve())
- 代币总供给(totalSupply())
- 授权转账额度(allowance())
- 代币信息(可选):名称(name()),代号(symbol()),小数位数(decimals())
13.ERC721
- balanceOf:返回某地址的NFT持有量
balance。 ownerOf:返回某tokenId的主人owner。- transferFrom:普通转账,参数为转出地址
from,接收地址to和tokenId。 safeTransferFrom:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver接口)。参数为转出地址from,接收地址to和tokenId。- approve:授权另一个地址使用你的NFT。参数为被授权地址
approve和tokenId。 - getApproved:查询
tokenId被批准给了哪个地址。 setApprovalForAll:将自己持有的该系列NFT批量授权给某个地址operator。isApprovedForAll:查询某地址的NFT是否批量授权给了另一个operator地址。safeTransferFrom:安全转账的重载函数,参数里面包含了data。
🔹 继承关系总结图
1 | IERC165 |
1. ERC165
最基础的接口识别标准:
1 | interface IERC165 { |
所有标准接口都会继承它,用来做“接口检测”。
2. IERC721
ERC721 的主接口,继承 ERC165:IERC721是ERC721标准的接口合约,规定了ERC721要实现的基本函数。它利用tokenId来表示特定的非同质化代币,授权或转账都要明确tokenId;而ERC20只需要明确转账的数额即可。
3. IERC721Metadata(扩展接口)
描述 NFT 元数据(名字、符号、tokenURI):
4.IERC721Enumerable(扩展接口)
提供可枚举性(遍历 tokenId 等):







