1.Solidity中的变量类型

  1. **值类型(Value Type)**:包括1.布尔型,2.整数型3.地址类型、4.定长字节数组 5.枚举 enum、这类变量赋值时候直接传递数值。
  2. **引用类型(Reference Type)**:包括数组和结构体,变长数组bytes,这类变量占空间大,赋值时候直接传递地址(类似指针)。
  3. 映射类型(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数据存储位置有三类:storagememorycalldata。不同存储位置的gas成本不同。storage类型的数据存在链上,类似计算机的硬盘,消耗gas多;memorycalldata类型的临时存在内存里,消耗gas少。整体消耗gas从多到少依次为:storage > memory > calldata。大致用法:

  1. storage:合约里的状态变量默认都是storage,存储在链上。
  2. memory:函数里的参数和临时变量一般用memory,存储在内存中,不上链。尤其是如果返回数据类型是变长的情况下,必须加memory修饰,例如:string, bytes, array和自定义结构。
  3. calldata:和memory类似,存储在内存中,不上链。与memory的不同点在于calldata变量不能修改(immutable),一般用于函数的参数。例子:
1
2
3
4
5
function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){
//参数为calldata数组,不能被修改
// _x[0] = 0 //这样修改会报错
return(_x);
}
数据位置和赋值规则

在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)。规则如下:

  • 赋值本质上是创建引用指向本体,因此修改本体或者是引用,变化可以被同步:

    • storage(合约的状态变量)赋值给本地storage(函数里的)时候,会创建引用,改变新变量会影响原变量。例子:

      1
      2
      3
      4
      5
      6
      7
      uint[] 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
2
3
4
5
6
function global() external view returns(address, uint, bytes memory){
address sender = msg.sender;
uint blockNum = block.number;
bytes memory data = msg.data;
return(sender, blockNum, data);
}

全局变量

下面是一些常用的全局变量,更完整的列表请看这个链接

  • blockhash(uint blockNumber): (bytes32) 给定区块的哈希值 – 只适用于最近的256个区块, 不包含当前区块。
  • block.coinbase: (address payable) 当前区块矿工的地址
  • block.gaslimit: (uint) 当前区块的gaslimit
  • block.number: (uint) 当前区块的number
  • block.timestamp: (uint) 当前区块的时间戳,为unix纪元以来的秒
  • gasleft(): (uint256) 剩余 gas
  • msg.data: (bytes calldata) 完整call data
  • msg.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分别是KeyValue的变量类型。例子:

1
2
mapping(uint => address) public idToAddress; // id映射到地址
mapping(address => address) public swapPair; // 币对的映射,地址到地址
映射的规则
  • 规则1:映射的_KeyType只能选择Solidity内置的值类型,比如uintaddress等,不能用自定义的结构体。而_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
    11
    contract 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
    3
    function writeMap (uint _Key, address _Value) public{
    idToAddress[_Key] = _Value;
    }

新增:mapping创建后所有的key对应的value都默认已存在,值为默认值

4.构造函数和修饰器

构造函数

构造函数(constructor)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner地址:

1
2
3
4
5
6
address owner; // 定义owner变量

// 构造函数
constructor(address initialOwner) {
owner = initialOwner; // 在部署合约的时候,将owner设置为传入的initialOwner地址
}
修饰器

修饰器(modifier)是Solidity特有的语法,类似于面向对象编程中的装饰器(decorator),声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。

我们来定义一个叫做onlyOwner的modifier:

1
2
3
4
5
// 定义modifier
modifier onlyOwner {
require(msg.sender == owner); // 检查调用者是否为owner地址
_; // 如果是的话,继续运行函数主体;否则报错并revert交易
}

_; 表示被修饰的函数体会在这里插入。

也就是说,执行流程是:

  1. 先运行 modifier_ 之前的逻辑(如果有)。
  2. 然后执行实际的函数体(替换 _;)。
  3. 最后再运行 modifier_ 之后的逻辑(如果有)。

带有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子:

1
2
3
function changeOwner(address _newOwner) external onlyOwner{
owner = _newOwner; // 只有owner地址运行这个函数,并改变owner
}

5.事件

声明事件

事件的声明由event关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20代币合约的Transfer事件为例:

1
event Transfer(address indexed from, address indexed to, uint256 value);

我们可以看到,Transfer事件共记录了3个变量fromtovalue,分别对应代币的转账地址,接收地址和转账数量,其中fromto前面带有indexed关键字,他们会保存在以太坊虚拟机日志的topics中,方便之后检索。

关于index:一个事件最多允许存在3个自定义的index变量,每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在topics中。

假设有两个转账:

1
2
emit Transfer(Alice, Bob, 100);
emit Transfer(Alice, Carol, 200);

对应日志会是:

  • 第 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 定义_transfer函数,执行转账逻辑
function _transfer(
address from,
address to,
uint256 amount
) external {

_balances[from] = 10000000; // 给转账地址一些初始代币

_balances[from] -= amount; // from地址减去转账数量
_balances[to] += amount; // to地址加上转账数量

// 释放事件
emit Transfer(from, to, amount);
}

浏览器查看事件:
Event明细

Topics里面有三个元素,[0]是这个事件的哈希,[1][2]是我们定义的两个indexed变量的信息,即转账的转出地址和接收地址。Data里面是剩下的不带indexed的变量,也就是转账数量。

6.库合约 站在巨人的肩膀上

库合约

他和普通合约主要有以下几点不同:

  1. 不能存在状态变量
  2. 不能够继承或被继承
  3. 不能接收以太币
  4. 不可以被销毁

需要注意的是,库合约中的函数可见性如果被设置为public或者external,则在调用这个函数时会触发一次delegatecall(使用库合约里部署好的函数,不用消耗额外的gas),此时会在调用合约的上下文中执行库合约的代码。而如果被设置为internal,则不会触发(实际上是直接复制库函数到代码里,会增加gas的消耗)。对于设置为private可见性的函数来说,其仅能在库合约中可见,在其他合约中不可见。

如何使用库合约

我们用Strings库合约的toHexString()来演示两种使用库合约中函数的办法。

1.利用using for指令

通过using A for B;指令,库合约 A 中的函数可被附加到类型 B 的变量上,实现直接调用。注意:调用时,该变量会自动作为函数的第一个参数传入,无需显式传递:

1
2
3
4
5
6
// 利用using for指令
using Strings for uint256;
function getString1(uint256 _number) public pure returns(string memory){
// 库合约中的函数会自动添加为uint256型变量的成员
return _number.toHexString();
}

解释:

  • _number.toHexString() → 编译器会自动翻译成 Strings.toHexString(_number)
  • _number 被当作第一个参数自动传入。
  • 看起来就像 _number 这个变量有了自己的方法。

好处:

  • 代码更直观,尤其是当一个类型需要频繁调用库函数时。
  • 和面向对象语言里的“扩展方法”很像

2.通过库合约名称调用函数

1
2
3
4
// 直接通过库合约名调用
function getString2(uint256 _number) public pure returns(string memory){
return Strings.toHexString(_number);
}

7.发送ETH

transfer
  • 用法是接收方地址.transfer(发送ETH数额)
  • transfer()gas限制是2300,足够用于转账,但对方合约的fallback()receive()函数不能实现太复杂的逻辑。
  • transfer()如果转账失败,会自动revert(回滚交易)。

代码样例,注意里面的_toReceiveETH合约的地址,amountETH转账金额:

1
2
3
4
// 用transfer()发送ETH
function transferETH(address payable _to, uint256 amount) external payable{
_to.transfer(amount);
}
send
  • 用法是接收方地址.send(发送ETH数额)
  • send()gas限制是2300,足够用于转账,但对方合约的fallback()receive()函数不能实现太复杂的逻辑。
  • send()如果转账失败,不会revert
  • send()的返回值是bool,代表着转账成功或失败,需要额外代码处理一下。

代码样例:

1
2
3
4
5
6
7
8
9
10
error SendFailed(); // 用send发送ETH失败error

// send()发送ETH
function sendETH(address payable _to, uint256 amount) external payable{
// 处理下send的返回值,如果失败,revert交易并发送error
bool success = _to.send(amount);
if(!success){
revert SendFailed();
}
}
call(推荐,防护要求高)
  • 用法是接收方地址.call{value: 发送ETH数额}("")

    完整用法:

    (bool success, bytes memory data) = target.call{value: ETH_AMOUNT, gas: GAS_LIMIT}(encodedData);

    target:目标合约地址(或 EOA 地址也可以发送 ETH)

    value:要发送的 ETH 数量

    gas:可选,发送给目标合约的 gas

    encodedData:调用函数的数据(ABI 编码)

  • call()没有gas限制,可以支持对方合约fallback()receive()函数实现复杂逻辑。

  • call()如果转账失败,不会revert

  • call()的返回值是(bool, bytes),其中bool代表着转账成功或失败,需要额外代码处理一下。

代码样例:

1
2
3
4
5
6
7
8
9
10
error CallFailed(); // 用call发送ETH失败error

// call()发送ETH
function callETH(address payable _to, uint256 amount) external payable{
// 处理下call的返回值,如果失败,revert交易并发送error
(bool success,) = _to.call{value: amount}("");
if(!success){
revert CallFailed();
}
}
低级调用(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合约地址_AddresssetX的参数x

1
2
3
function callSetX(address _Address, uint256 x) external{
OtherContract(_Address).setX(x);
}

补充:当名字为OtherContract的合约在同一个文件里或者已经引入了这个合约才能写 OtherContract(_Address).setX(x);

2. 传入合约变量

我们可以直接在函数里传入合约的引用,只需要把上面参数的address类型改为目标合约名,比如OtherContract。下面例子实现了调用目标合约的getX()函数。

注意:该函数参数OtherContract _Address底层类型仍然是address,生成的ABI中、调用callGetX时传入的参数都是address类型

1
2
3
function callGetX(OtherContract _Address) external view returns(uint x){
x = _Address.getX();
}
写法 参数类型 调用灵活性 安全性/可读性
OtherContract(_Address).setX(x) address 高(任何地址都能传) 低(必须自己保证地址真的是那个合约)
callGetX(OtherContract _Address) OtherContract 一般(只能传这个合约类型的地址) 高(编译器帮你检查类型)写法

两种方式 效果一样,都是在目标地址上调用 setX/getX,只是:

  • 前者用 裸地址 + 强转
  • 后者用 合约类型参数
3. 创建合约变量

我们可以创建合约变量,然后通过它来调用目标函数。下面例子,我们给变量oc存储了OtherContract合约的引用:

1
2
3
4
function callGetX2(address _Address) external view returns(uint x){
OtherContract oc = OtherContract(_Address);
x = oc.getX();
}

感觉和方法1差不多,多了一个没必要的步骤

4. 调用合约并发送ETH

如果目标合约的函数是payable的,那么我们可以通过调用它来给合约转账:_Name(_Address).f{value: _Value}(),其中_Name是合约名,_Address是合约地址,f是目标函数名,_Value是要转的ETH数额(以wei为单位)。

OtherContract合约的setX函数是payable的,在下面这个例子中我们通过调用setX来往目标合约转账。

1
2
3
function setXTransferETH(address otherContract, uint256 x) payable external{
OtherContract(otherContract).setX{value: msg.value}(x);
}

就是方法1,只是多了一个发送eth

9.call

调用setX函数

我们定义callSetX函数来调用目标合约的setX(),转入msg.value数额的ETH,并释放Response事件输出successdata

1
2
3
4
5
6
7
8
function callSetX(address payable _addr, uint256 x) public payable {
// call setX(),同时可以发送ETH
(bool success, bytes memory data) = _addr.call{value: msg.value}(
abi.encodeWithSignature("setX(uint256)", x)
);

emit Response(success, data); //释放事件
}

abi.encodeWithSignature(“func(uint256,address)”, 123, addr)🔑 含义

它的作用是:
把函数签名 + 参数一起编码成 bytes,可以作为低级调用 (.call) 的输入。

📦 分解步骤
  1. 函数签名字符串 "func(uint256,address)"

    • 这里写的是 函数名 + 参数类型(精确匹配类型)。
    • 编译器会用 keccak256("func(uint256,address)") 取哈希,然后取前 4 个字节,生成 **函数选择器 (selector)**。
    • 这 4 个字节就是告诉 EVM:“我要调用 func(uint256,address) 这个函数”。

    👉 举例:

    1
    2
    3
    keccak256("func(uint256,address)") 
    = 0x12345678abcd...
    selector = 0x12345678
  2. 参数列表 (123, addr)

    • 这些是你要传给函数的实参。
    • 123 会编码成 32 字节(uint256)。
    • addr 会编码成 32 字节(地址左填充 0)。
  3. 拼接结果

    • 最终结果是一个 bytes,内容 = selector (4 bytes) + 编码参数们

    • 就像这样:

      1
      2
      3
      0x12345678  // 选择器
      000000...07b // 123,填充成 32 字节
      000000...addr // 地址,填充成 32 字节
调用getX函数

下面我们调用getX()函数,它将返回目标合约_x的值,类型为uint256。我们可以利用abi.decode来解码call的返回值data,并读出数值。

1
2
3
4
5
6
7
8
9
function callGetX(address _addr) external returns(uint256){
// call getX()
(bool success, bytes memory data) = _addr.call(
abi.encodeWithSignature("getX()")
);

emit Response(success, data); //释放事件
return abi.decode(data, (uint256));
}

abi.encode\* :把函数参数(或数据)打包成字节数据(bytes)。

abi.decode :把字节数据(bytes)解码还原成原本的参数类型。

调用不存在的函数

如果我们给call输入的函数不存在于目标合约,那么目标合约的fallback函数会被触发。

1
2
3
4
5
6
7
8
function callNonExist(address _addr) external{
// call 不存在的函数
(bool success, bytes memory data) = _addr.call(
abi.encodeWithSignature("foo(uint256)")
);

emit Response(success, data); //释放事件
}

上面例子中,我们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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 逻辑合约 Logic
contract Logic {
uint public x; // slot 0
address public owner; // slot 1
}

// 代理合约 Proxy
contract Proxy {
uint public a; // slot 0
address public b; // slot 1

function callSetX(address _logic, uint _x) external {
_logic.delegatecall(
abi.encodeWithSignature("setX(uint256)", _x)
);
}
}
  • 名称不同(x vs a,owner vs b) ✅
  • 只要顺序和类型一致,delegatecall 会正确写到 Proxy.a

delegatecallcall类似,是Solidity中地址类型的低级成员函数。delegate中是委托/代表的意思,那么delegatecall委托了什么?

当用户A通过合约Bcall合约C的时候,执行的是合约C的函数,上下文(Context,可以理解为包含变量和状态的环境)也是合约C的:msg.senderB的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C的变量上。

call的上下文

而当用户A通过合约Bdelegatecall合约C的时候,执行的是合约C的函数,但是上下文仍是合约B的:msg.senderA的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B的变量上。

delegatecall的上下文

大家可以这样理解:一个投资者(用户A)把他的资产(B合约的状态变量)都交给一个风险投资代理(C合约)来打理。执行的是风险投资代理的函数,但是改变的是资产的状态。

什么情况下会用到delegatecall?

目前delegatecall主要有两个应用场景:

  1. 代理合约(Proxy Contract):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract)里,通过delegatecall执行。当升级时,只需要将代理合约指向新的逻辑合约即可。
  2. EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合约的代理合约。 更多信息请查看:钻石标准简介

11.ABI编码

自我理解:与计算机对话涉及两种语言,一种是底层的01001(可按照进制法则直接转换成16进制),一种是各国语言(中英文等)
两种语言之间可以按照匹配法则直接转换(在soliidty中是转换成16进制,可以理解为底层语言,区别不大),而abi编码的作用就是这个(encode和encodPacked),只是补全有没有0的区别

1
2
3
function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){
(success, ) = address(this).call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_bytes, _bytes1, _num)));
}
总结
  • 为什么有 ABI? 因为区块链只认识字节码,需要统一的“翻译标准”让外部调用函数。
  • 作用? 把函数调用编码成十六进制数据,把返回值解码成人类可理解的数据。
  • 原理? 函数选择器 + 32字节对齐的参数编码/解码规则。
  • 应用? ABI JSON 文件是前端、钱包、RPC 调用合约的唯一桥梁。
ABI编码

abi.encode

将给定参数利用ABI规则编码。ABI被设计出来跟智能合约交互,他将每个参数填充为32字节的数据,并拼接在一起。如果你要和合约交互,你要用的就是abi.encode

1
2
3
function encode() public view returns(bytes memory result) {
result = abi.encode(x, addr, name, array);
}
1
2
3
4
5
6
7
000000000000000000000000000000000000000000000000000000000000000a    // x
0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c71 // addr
00000000000000000000000000000000000000000000000000000000000000a0 // name 参数的偏移量
0000000000000000000000000000000000000000000000000000000000000005 // array[0]
0000000000000000000000000000000000000000000000000000000000000006 // array[1]
0000000000000000000000000000000000000000000000000000000000000004 // name 参数的长度为4字节
3078414100000000000000000000000000000000000000000000000000000000 // name

abi.encodePacked

将给定参数根据其所需最低空间编码。它类似 abi.encode,但是会把其中填充的很多0省略。比如,只用1字节来编码uint8类型。当你想省空间,并且不与合约交互的时候,可以使用abi.encodePacked,例如算一些数据的hash时。需要注意,abi.encodePacked因为不会做填充,所以不同的输入在拼接后可能会产生相同的编码结果,导致冲突,这也带来了潜在的安全风险。

1
2
3
function encodePacked() public view returns(bytes memory result) {
result = abi.encodePacked(x, addr, name, array);
}

编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a7a58c0be72be218b41c608b7fe7c5bb630736c713078414100000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006,由于abi.encodePacked对编码进行了压缩,长度比abi.encode短很多。

abi.encodeWithSignatureabi.encodeWithSelector

其实它们是等价的,只是写法不同:

1
2
3
4
5
6
7
8
9
// encodeWithSignature
abi.encodeWithSignature("foo(uint256,address)", x, addr);

// 等价于 encodeWithSelector
abi.encodeWithSelector(
bytes4(keccak256("foo(uint256,address)")),
x,
addr
);

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
2
3
function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) {
(dx, daddr, dname, darray) = abi.decode(data, (uint, address, string, uint[2]));
}

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,接收地址totokenId
  • safeTransferFrom:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver接口)。参数为转出地址from,接收地址totokenId
  • approve:授权另一个地址使用你的NFT。参数为被授权地址approvetokenId
  • getApproved:查询tokenId被批准给了哪个地址。
  • setApprovalForAll:将自己持有的该系列NFT批量授权给某个地址operator
  • isApprovedForAll:查询某地址的NFT是否批量授权给了另一个operator地址。
  • safeTransferFrom:安全转账的重载函数,参数里面包含了data
🔹 继承关系总结图
1
2
3
4
5
6
7
8
IERC165


IERC721
├── IERC721Metadata
└── IERC721Enumerable

IERC721Receiver (独立的,不继承上面)
1. ERC165

最基础的接口识别标准:

1
2
3
interface IERC165 {
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}

所有标准接口都会继承它,用来做“接口检测”。

2. IERC721

ERC721 的主接口,继承 ERC165:IERC721ERC721标准的接口合约,规定了ERC721要实现的基本函数。它利用tokenId来表示特定的非同质化代币,授权或转账都要明确tokenId;而ERC20只需要明确转账的数额即可。

3. IERC721Metadata(扩展接口)

描述 NFT 元数据(名字、符号、tokenURI):

4.IERC721Enumerable(扩展接口)

提供可枚举性(遍历 tokenId 等):