Solidity优化-减少合约gas消耗
背景
在以太坊系公链中,合约部署和调用是需要发送交易并消耗 gas 的,而 gas 的使用量决定了该笔交易的费用。因此,设计省钱的合约是很重要的。
在部署合约时,我们希望减小合约编译后的字节码大小,来减少合约部署时的 gas 消耗。而好的代码实现,能够减少合约调用时的 gas 消耗。
减少 gas 消耗的方法
以下介绍一些减少合约 gas 消耗的具体方法。
1、编译合约时使用优化器
使用编译器 solc 编译合约时启动优化器 optimizer,它将简化复杂的表达方式,能减小编译后的合约字节码大小,从而减少部署时的 gas 消耗,同时也能减少合约调用时的消耗。
基于 opcode 的优化器会执行一系列的简化规则,把重复的代码合并,把多余的代码删除。目前(v0.8.11 版本),--optimize 参数激活的是基于 opcode 的优化器。
solc --optimize --optimize-runs 200
运行次数 --optimize-runs 指定了部署的代码的每个操作码在合同的生命周期内被执行的大致频率。这意味着它是代码大小(部署成本)和代码执行成本(部署后的成本)之间的一个折衷参数。次数越小,编译出的字节码越小,但是调用该合约函数可能需要更多 gas。
此外还有基于 Yul 的优化器,更加强大,因为它可以跨函数调用工作。
详情见文档。
2、SSTORE 指令
在链上存储变量的值,要用到 SSTORE 指令。在不同情况下,该指令消耗的 gas 不同。
https://eips.ethereum.org/EIPS/eip-1087
我们使用如下合约代码部署在 QEasyWeb3测试链上进行测试:
pragma solidity ^0.8.0;
contract Test {
uint256 x;
function emptySet(uint256 _x) public {}
function set(uint256 _x) public {
x = _x;
}
}
实际测试结果如下:
- 将 x 变量从零值设置为非零值 1,消耗 41406 gas,其中交易基础 gas 21000,推算存储实际消耗 20000 gas。
- 将 x 变量从非零值设置为零值,消耗 13197 gas,其中交易基础 gas 21000,推算存储实际返还约 8000 gas。
- 将 x 变量从非零值 1 设置为另一个非零值 10000,消耗 26418 gas,推算存储实际消耗 5000 gas。
- 将 x 变量从非零值设置为同一个非零值,消耗 22218 gas,推算存储实际消耗约 800 gas。
- 将 x 变量从零值设置为零值,消耗 22194 gas,推算存储实际消耗约 800 gas。
这里扩展一点,合约自毁和将变成从非零值设置为零值一样,是会返回 gas 的。
3、变量存储原则
从 SSTORE 指令的 gas 消耗测试结果可以看到,存储操作是非常消耗 gas 的,特别是将变量从零值设置为非零值时。
因此,我们应该考虑:
- 避免在链上存储用不上、不重要的数据,如介绍、描述信息等。
- 考虑使用事件来存储,要比将它们存储在变量中便宜得多。
- 在 IPFS 上存储较大的数据,如图片、文档等,在合约中只储存其哈希值。
- 无状态合约,即用交易数据和事件等来保存数据,而不是改变合约的存储状态。比如发送一个交易并传递你想要存储的值,而不是真正存储它。更多可见这篇文章。
同样消耗 gas 比较多的操作指令还有 CREATE 、 CREATE2 等,我们使用时应该注意。
4、选择变量数据类型
不同数据类型的存储消耗不同。在满足业务的情况下,我们应该选择 gas 消耗更小的数据类型。
在没有办法将多个变量放入同一个插槽时(在【5、紧凑状态变量打包】中说明),尽量使用 256 位的变量,例如 uint256 和 bytes32。
在使用小于 32 字节的变量数据类型时,合约的 gas 使用量可能会高于使用 32 字节的类型。这是因为 EVM 每次操作 32 个字节, 所以如果变量大小比 32 字节小,EVM 必须执行额外的操作以便将 32 字节大小缩减到到所需的大小。
我们将如下两个合约部署在 QEasyWeb3测试链上:
// 消耗 gas 68820
contract A {
uint8 x = 0;
}
// 消耗 gas 67900
contract A {
uint256 x = 0;
}
发现存储一个 uint256 变量比 uint8 变量消耗的 gas 更少。
此外,在 EVM 执行计算也需要额外的操作,除 uint256 之外的其他 uint 类型在计算时需要耗费额外的 gas 进行转换。
尽量使用定长数组,通常它们更省 gas。比如使用定长字节数组 bytes1, bytes2 ... bytes32,而不是变长字节数组 bytes。如果要使用变长字节数组,则尽量使用 bytes 而不是 []byte,后者会更加浪费存储空间。详情可见文档。
5、紧凑状态变量打包
首先,Solidity 合约数据存储的方案是为合约每个变量指定一个可计算的存储位置,数据存在容量为 2 ** 256 超级数组中,数组中每项数据的初始值为 0。
每个插槽可存储 256 位/32 字节数据:
合约状态变量存储结构相关描述可见文档。
根据文档描述,静态大小的变量(除映射 mapping 和动态数组之外的所有类型)都从位置 0 开始连续放置在存储插槽(storage slot)中的。如果可能的话,存储大小少于 32 字节的多个变量会被打包到一个存储插槽中(每个存储插槽 256 位/32 字节),规则如下:
- 存储插槽中的第一项会以低位对齐(即右对齐)的方式储存。
- 值类型仅使用存储它们所需的字节数。
- 如果存储插槽中的剩余空间不足以储存一个值类型变量,那么它会被移入下一个存储插槽。
- 结构体和数组数据会使用一个新插槽进行存储,但结构体或数组中的各项,都会以这些规则进行打包。
- 结构体和数组数据之后的变量会使用一个新插槽。
紧凑状态变量打包,就是将多个不需要用到 32 字节的值类型数据存储在一个插槽中。通过合理地排列状态变量的顺序、结构体的字段的顺序,使得尽可能多的状态变量打包到一个存储插槽中,最终使用更少的存储插槽,减少 gas 消耗。
注意,要在编译的时候使用优化器进行优化。
以定义一个结构体为例,我们将如下两个合约部署在QEasyWeb3测试链上:
contract Test {
// 字段 a, b, c 分别使用了一个存储插槽,共使用 3 个
struct A {
uint a;
uint b;
uint c;
}
A a = A(10, 20, 30);
}
contract Test {
// 字段 a, b 共需要 8 字节,可以共用一个存储插槽
// 字段 c 需要 32 字节,前一个插槽不够放,因此开启使用一个新的存储插槽
// 共使用 2 个存储插槽
struct A {
uint32 a; // uint32 类型大小为 32 位/4 字节
uint32 b;
uint c;
}
A a = A(10, 20, 30);
}
// 第一个合约部署消耗 gas 127633,第二个合约部署消耗 gas 108833,减少 18800,近 20000 gas。
因为使用了紧凑变量打包,所以第二个合约少使用了一个存储插槽,减少了 gas 消耗。
因此我们可以考虑使用更小的 uint 子类型或者 bytes 子类型,通过合理地排序它们的位置,可以将存储空间最小化。
除结构体和数组数据外的其他变量,同样部署以下两个合约进行测试:
// 部署消耗 gas 129366
contract Test {
uint128 x = 10;
uint256 y = 10;
uint128 z = 10;
}
// 部署消耗 gas 108674,少 20692 gas
contract Test {
uint256 y = 10;
uint128 x = 10;
uint128 z = 10;
}
6、紧凑状态变量赋值
当我们使用紧凑状态变量打包时,多个变量被打包在一个存储插槽中,这时,同时读取和写入该插槽中的多个变量,多个读或写会合并为一个单一的操作,这样能够节省 gas。而如果你只是读或者写该插槽中的一个变量,效果可能相反,当一个变量的值被写入一个多变量存储插槽中时,存储槽必须先被读取,然后与新值结合,这样同一个插槽中的其他数据就不会被破坏。
在实际测试中,我们也发现,某些情况下统一插槽内变量的读或写没有优化合并为一个操作。
以下四种设置方式,我们设置 a 的值为 2,b 的值为 1,看实际的 gas 消耗。
contract structWrite {
struct Object {
uint64 v1;
uint64 v2;
uint64 v3;
uint64 v4;
}
Object obj = Object(1, 1, 1, 1);
// gas cost 33211
function set1(uint64 a, uint64 b) public {
obj.v1 = a + b;
obj.v2 = a - b;
obj.v3 = a * b;
obj.v4 = a / (b + 1);
}
// gas cost 28411
function set2(uint64 a, uint64 b) public {
setObject(a + b, a - b, a * b, a / (b + 1));
}
function setObject(uint64 v1, uint64 v2, uint64 v3, uint64 v4) private {
obj.v1 = v1;
obj.v2 = v2;
obj.v3 = v3;
obj.v4 = v4;
}
// gas cost 28381
function set3(uint64 a, uint64 b) public {
uint64 v1 = a + b;
uint64 v2 = a - b;
uint64 v3 = a * b;
uint64 v4 = a / (b + 1);
obj.v1 = v1;
obj.v2 = v2;
obj.v3 = v3;
obj.v4 = v4;
}
// gas cost 28613
function set4(uint64 a, uint64 b) public {
obj = Object(a + b, a - b, a * b, a / (b + 1));
}
// gas cost 22383
function set5(uint64 a, uint64 b) public {
uint64 v1 = a + b;
uint64 v2 = a - b;
uint64 v3 = a * b;
uint64 v4 = a / (b + 1);
}
}
实际我们看几个 set 方法编译出来的 opcode,发现 set1 使用了 4 个 SLOAD 和 4 个 SSTORE,而其他 set 方法只使用了 1 个 SSLOAD 和 1 个 SSTORE,编译器对其他写法进行了优化。
编译器将 4 个字段的读取和写入优化为一次操作,而第一种写法无法优化,因此要多消耗 5000 左右的 gas。因此,我们应该避免第一种写法。
7、内联汇编打包变量
编写内联汇编 (Inline Assembly) ,手动将多个变量堆叠在一起,打包到单个插槽中。
语法:使用 assembly{ ... } 来嵌入汇编代码段。
// 编码时将多个变量一起储存。
function encode(uint64 _a, uint64 _b, uint64 _c, uint64 _d) internal pure returns (bytes32 x) {
assembly {
let y := 0
mstore(0x20, _d)
mstore(0x18, _c)
mstore(0x10, _b)
mstore(0x8, _a)
x := mload(0x20)
}
}
function decode(bytes32 x) internal pure returns (uint64 a, uint64 b, uint64 c, uint64 d) {
assembly {
d := x
mstore(0x18, x)
a := mload(0)
mstore(0x10, x)
b := mload(0)
mstore(0x8, x)
c := mload(0)
}
}
这种方式虽然节省了 gas,但是牺牲了代码的可读性,容易出错。
更多资料:Solidity Tutorial : all about Assembly
8、无需使用默认值初始化变量
无需使用默认值初始化变量。
// 部署消耗 gas 67054
contract Test {
uint256 x;
}
// 部署消耗 gas 67912
contract Test {
uint256 x = 0;
}
9、常量
在 solidity 中,声明为 constant 或者 immutable 的状态变量即常量。
constant 修饰的常量的值在编译时确定,而 immutable 修饰的常量的值在部署时确定。详情可见文档。
尽量使用常量,常量是合约字节码的一部分,不占用存储插槽,使用常量比变量更省 gas。
在部署时,常量消耗的 gas 更少。
// 消耗 83681 gas,相比使用变量节省 20078 gas
contract A {
uint256 public constant x = 1000;
}
// 消耗 90046 gas,相比使用变量节省 13713 gas
contract A {
uint256 public immutable x = 1000;
}
// 消耗 103759 gas
contract A {
uint256 public x = 1000;
}
在读取时,常量消耗的 gas 也更少。
contract A {
uint256 public result;
uint256 public constant x = 100; // 调用 cal 方法消耗 41236 gas
// uint256 public immutable x = 100; // 调用 cal 方法消耗 41236 gas
// uint256 public x = 100; // 调用 cal 方法消耗 42036 gas,读取存储变量消耗多消耗 800 gas,
function cal() public {
result = x;
}
// function cal() public { // 调用 cal 方法消耗 41236 gas
// result = 100;
// }
}
10、函数修饰符
使用函数修饰符 view、pure。
- 函数声明为 view,表示该函数不修改状态。
- 函数声明为 pure,表示该函数不读取或修改状态。
详情可见文档。
在以太坊中,如果不对状态进行修改,则可以发起一笔调用进行查询或其他操作,调用是不需要费用的。如果要对状态产生变更,则需发起一笔交易,交易是需要消耗 gas 和支付费用的。
在智能合约中,函数如果声明为 view 或者 pure ,则外部账户直接调用这些函数只需发起一次调用即可。如果不加这些修饰符,以太坊网络会把你的操作理解为一笔交易。
需要注意的是,如果在一笔交易中,某个未声明为 view 或者 pure 的合约函数的内部调用了声明为 view 或者 pure 的函数,还是需要消耗 gas 的。
部署下面的合约进行测试,其中 add 和 sub 方法都没有读取或修改状态,add 方法没有声明为 pure,则需要发起一笔交易并支付交易费用才能调用,而 sub 方法声明为 pure,则无需发起交易只发起调用即可,无需费用。
contract Test {
function add(uint _x, uint _y) public returns (uint) {
return _x + _y;
}
function sub(uint _x, uint _y) public pure returns (uint) {
return _x - _y;
}
}
11、避免重复修改状态变量
避免重复修改状态变量,比如在循环中重复修改状态变量的值。
contract Test {
uint256 public count;
// bad,消耗 gas 58582
function set1() public {
for (uint256 i = 0; i < 10; i++) {
count++;
}
}
// good,消耗 gas 24046
function set2() public {
uint256 temp;
for (uint256 i = 0; i < 10; i++) {
temp++;
}
count = temp;
}
}
12、使用短路规则
操作符 || 和 && 适用常见的短路规则。
这意味着,假设f(x) 和 g(y) 返回 true 的概率一样,那么:
- 在表达式 f(x) || g(y) 中,如果 f(x) 的计算结果为真,则不会执行 g(y)。因此应该将贵的方法放在后面。
- 在表达式 f(x) && g(y) 中,如果 f(x) 的计算结果为假,则不会执行 g(y)。因此应该将贵的方法放在后面。
当然,实际情况是还需要考虑两个方法执行的失败概率,从而整体评估方法的排序。
13、布尔类型
在 solidity 中,布尔类型 bool 实际为 uint8,即使用 8 位的存储空间,每个存储插槽能装入 32 个布尔类型值。而布尔值只能有两个值:True 或 False,其实只需要在单个存储位中就可以保存布尔值。
在有非常多个布尔类型变量,或者是需要布尔类型的数组时,你可以考虑使用一个 uint256 变量,并使用其所有 256 位来表示各个布尔值。
要从 uint256 中获取单个布尔值,请使用以下函数:
function getBoolean(uint256 _packedBools, uint256 _boolNumber) public view returns(bool) {
uint256 flag = (_packedBools >> _boolNumber) & uint256(1);
return (flag == 1 ? true : false);
}
要设置或清除布尔值:
function setBoolean(
uint256 _packedBools,
uint256 _boolNumber,
bool _value
) public view returns(uint256) {
if (_value)
return _packedBools | uint256(1) << _boolNumber;
else
return _packedBools & ~(uint256(1) << _boolNumber);
}
可以使用 BitMap 代替 mapping(uint256 => bool), 同样是使用了位操作处理。
14、默克尔树
使用默克尔树。在合约中保存一组数据的 merkleRoot,提供 verify 方法验证某条数据在这组数据中。相比使用一个 mapping 或数组来保存全部数据,减少了 gas 消耗。
以 ERC20 代币空投为例。参考 ENS 空投合约。
核心代码:
bytes32 public merkleRoot;
function claimTokens(uint256 amount, address delegate, bytes32[] calldata merkleProof) external {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
(bool valid, uint256 index) = MerkleProof.verify(merkleProof, merkleRoot, leaf);
require(valid, "ENS: Valid proof required.");
require(!isClaimed(index), "ENS: Tokens already claimed.");
claimed.set(index);
emit Claim(msg.sender, amount);
_delegate(msg.sender, delegate);
_transfer(address(this), msg.sender, amount);
}
function verify(
bytes32[] memory proof,
bytes32 root,
bytes32 leaf
) internal pure returns (bool, uint256) {
bytes32 computedHash = leaf;
uint256 index = 0;
for (uint256 i = 0; i < proof.length; i++) {
index *= 2;
bytes32 proofElement = proof[i];
if (computedHash <= proofElement) {
// Hash(current computed hash + current element of the proof)
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
// Hash(current element of the proof + current computed hash)
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
index += 1;
}
}
// Check if the computed hash (root) is equal to the provided root
return (computedHash == root, index);
}
同样可参考 Uniswap 空投使用的 merkle-distributor 以及 OneSwap 空投。
15、压缩交易输入数据
在函数参数较多的时候,我们可以压缩输入数据,类似紧凑状态变量打包,当有多个小于 32 字节大小的参数时,将多个参数打包为一个参数。
参考 Compress input in smart contract
16、调用外部合约
调用外部合约函数比调用内部函数消耗更多 gas。除非必要,否则不建议拆分多个合约,可以使用多个继承来管理和组织代码。
首先测试内部函数调用的 gas 消耗:
contract Math {
function add(uint _x, uint _y) public pure returns (uint) {
return _x + _y;
}
}
contract Test is Math {
uint sum;
// 消耗 41710 gas
function calculate(uint _x, uint _y) public {
sum = add(_x, _y);
}
}
再测试外部函数调用的 gas 消耗,先部署被调用外部合约,得到合约地址:
contract Math {
function add(uint _x, uint _y) public pure returns (uint) {
return _x + _y;
}
}
再部署调用合约,并测试:
contract Math {
function add(uint _x, uint _y) public pure returns (uint) {
return _x + _y;
}
}
contract Test {
uint sum;
address constant MathContractAddr = 0x9549DfbBd66b3Cc078AD834C74b9EE1808Ef3AEB;
// 消耗 43693 gas
function calculate(uint _x, uint _y) public {
sum = Math(MathContractAddr).add(_x, _y);
}
}
17、状态变量重复读取
多次读取状态变量,不会重复使用 SLOAD 指令,而是将值缓存起来。
我们部署下面两个合约进行测试:
contract Test {
uint one = 1;
// 消耗 22218 gas
function test() public returns (uint) {
return one + one + one;
}
}
contract Test {
uint one = 1;
// 消耗 22218 gas
function test() public returns (uint) {
return one + 1 + 1;
}
}
因此我们不用另外增加一个内存变量来避免重复读取。
18、操作合约和数据合约分离
在使用工厂合约创建合约的情况下,可以将创建的合约分离为操作合约和数据合约。
操作合约只创建一次,工厂合约每次只创建数据合约,而不是每次都创建一整个合约,从而减少 gas 消耗。
19、将复杂的计算逻辑放在链下
考虑在链下进行复杂的计算逻辑,在链上存储结果。
20、尽量使用批量操作
因为一笔交易的基础 gas 消耗是 21000,批量操作相比多次操作,能减少多次的交易带来的基础 gas 消耗。
BTTC跨链分享
一、原理
BTTC跨链
- 参考了Polygon,是基于侧链的公共区块链扩展解决方案;
- 主要是通过双向锚定的跨链桥来实现与主网的链接、以及互相操作
- 基本原理:
- 充值:通过锁定主链上的资产,并在侧链发行相关资产;
- 提币:如果想要回到主链,只需要销毁侧链上的资产,并在主链上解锁相关资产。
- 实现:
- 采用POS(Proof of Stake)机制
- 部署多节点验证
- 通过侧链进行智能合约的扩展
- 兼容Ethereum链架构的智能合约及其他功能
跨链桥
跨链桥提供了一条在 侧链 和 主链 之间的可信双向交易通道。
当代币通过跨链桥传递时,它的总流通量不会被影响
- 离开Ethereum的代币会被锁定,同时在Polygon网络上铸造与其等量的映射代币。
- 将代币从Polygon转回Ethereum时,Polygon上的代币将被销毁,同时将解锁Ethereum上的等量原始代币。
架构
BTTC是三层架构:
- 根链智能合约层:
- 由部署在各公链(Ethereum / TRON / BSC)上的,一系列去中心化的智能合约组成;
- 负责收集跨入/跨出 BTTC 的交易,validator的质押管理、委托管理,以及验证侧链状态的检查点/快照等功能;
- Validator层:验证BitTorrent-Chain区块,定期发送Checkpoint至支持的TRON及其他区块链网络
Bridge:负责监听各链路事件,发送事件消息等。
Core:共识模块,包括Checkpoint(BitTorrent-Chain链的状态快照)的验证,Statesync事件&Staking事件的共识。
REST-Server:提供相关API服务。
实现:Delivery
- BTTC层:区块生产者层,是一条与以太坊 (EVM) 完全兼容的,且由一组去中心化的验证人 (validators) 共同治理的 PoS 链。
Root Contracts
目前支持与BTTC跨链的根链有 TRON, Ethereum, BSC。BTTC设计的框架支持后续增加其它的公共区块链,仅需要在该公链上部署以下3类合约即可:
- Staking管理:主要处理 BTTC 验证者节点的质押、slashing等
- StakeManagerExtension, StakingInfo, StakeManagerProxy, ValidatorShare, StakeManager, ValidatorShareFactory 等
- 代币映射:包括和子代币相映射的根代币所遵循的合约标准,mint权限委托的Predicate合约等
- DummyERC20, ERC20Predicate, ERC20PredicateProxy, MintableERC20Predicate, MintableERC20PredicateProxy 及 ERC721相关的合约等
- 资产转移及状态同步管理:deposit、withdraw tx 及状态同步所要调用的合约方法包含在里面
- RootChain, RootChainManager, EventsHub, StateSender 等
Child Contracts
BTTC子链上为支持跨链所部署的合约有:
- ChildChainManager:处理代币映射、销毁等逻辑;
- ChildToken 模板:支持跨链的代币类型是 20、721,并支持子代币Mintable;
- 状态同步相关:StateReceiver, ChildTunnel等。
代币映射
使用 BTTC 跨链桥需要先将 Root Token、Child Token 进行映射。
操作步骤:
- 实现子代币合约
- 标准子代币(20,721):继承项目代码中 的 ChildERC20,确保有deposit以及withdrawTo方法
- 自定义子代币:实现自己的子代币合约
- 将子代币合约部署到 BTTC 网上
- 参考 deploy_child_chain_contracts.js进行部署;
- 部署时需要 BTTC 上的 ChildChainManager 合约地址;
- 提交映射请求
- 在这里提交映射请求,其中 The Token Contract Address on Ethereum/BSC/TRON 是根链上的根代币合约地址,The Token Contract Address on BTTC 是部署在BTTC上的子代币合约地址。
- 审核时间是3~5个工作日
根链权限
deposit 需要根链锁币,withdraw 需要解锁相关资产。
代币映射时,会指定代币类型,每种类型有对应的 Predicate 合约。
锁币:交易发起者 Approve 对应的 Predicate 合约,批准合约Predicate消费代币,将 token 从发起者账户转给 Predicate 地址;
解锁:Predicate 地址给接收者账户转 token。
Token Type | Predicate |
---|---|
ERC20 | ERC20Predicate |
ERC721 | ERC721Predicate |
MintableERC20 | MintableERC20Predicate |
MintableERC721 | MintableERC721Predicate |
二、跨链流程
前提
进行充提币操作之前,需要先下载插件钱包并连接钱包中的账户地址。
目前BTTC网页支持2种钱包插件,分别为 TronLink与 MetaMask。
- TronLink
- 不支持签名BTTC网络交易,所以仅支持使用TronLink将资产存入BTTC
- MetaMask
- 支持Ethereum、BSC网络资产映射到BTTC
- 添加BTTC网络(即自定义RPC)后,可以发送或取出BTTC网络的资产
2.1 根链 <-> BTTC
deposit
状态转移数据格式:
/* StateSynced event内数据格式如下
{
id: counter++,
receiver: childChainManagerAddress,
calldata: abi.encode(
DEPOSIT,
abi.encode(user, rootToken, CHAIN_ID, depositeData)
)
}
*/
type MsgEventRecord struct {
From // 提交tx的Delivery节点地址
TxHash // deposit tx 的 tx hash
LogIndex // 日志的index
BlockNumber // 日志所在的block number
ContractAddress // StateSynced 事件的 receiver,即 ChildChainManagerAddress
Data // StateSynced 事件的 data
ID // StateSynced 事件的 id, StateSender 合约维护一个counter,每次发出 StateSynced事件则加1
ChainID // 子链 ID
RootChainType // 根链类型,TRON? ETH? BSC?
}
checkpoint
Delivery层将BTTC层生产的区块聚合成一棵Merkle树,并定期将Merkle根发布到根链,这种定期发布称为检查点。
Checkpoint很重要:
- 在根链上提供侧链的最终确定性。
- 提供侧链提取资产到主链的燃烧证明。
checkpoint 同步流程
- Delivery bridge 同步到最新的BTTC new header,验证header后发现该提交checkpoint,则 当前的 proposer 为每条根链创建对应的 checkpoint tx,并发送给 BTTC 节点。该 tx.Msg 数据格式如下:
type MsgCheckpoint struct {
Proposer // checkpoint 的提议者
StartBlock // checkpoint 开始的区块number
EndBlock // checkpoint 结束的区块number
RootHash // Merkle root
AccountRootHash
BorChainID // 子链 ID
Epoch
RootChainType // 根链类型,TRON? ETH? BSC?
}
StartBlock:访问根链RootChain合约的CurrentHeaderBlock及GetHeaderInfo方法可以获取根链记录的最新checkpoint,该checkpoint.end+1即为下一个checkpoint的start;
假设正常的checkpoint [StartBlock, EndBlock] 如下:[1,128], [129, 256],...若第一个checkpoint([1, 128]),由于各种原因导致根链没有接收并更新合约内存储的内容,则要发送给该根链的 checkpoint 是 [1, 256]
- 这些checkpoint tx被打包进block,执行后将生成 EventTypeCheckpoint 事件的日志;validator会对执行结果进行 Precommit 投票,收集到超过2/3的 Precommit 投票才有效。
// Emit event for checkpoint
ctx.EventManager().EmitEvents(sdk.Events{
sdk.NewEvent(
types.EventTypeCheckpoint,
sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory),
sdk.NewAttribute(types.AttributeKeyProposer, msg.Proposer.String()),
sdk.NewAttribute(types.AttributeKeyStartBlock, strconv.FormatUint(msg.StartBlock, 10)),
sdk.NewAttribute(types.AttributeKeyEndBlock, strconv.FormatUint(msg.EndBlock, 10)),
sdk.NewAttribute(types.AttributeKeyRootHash, msg.RootHash.String()),
sdk.NewAttribute(types.AttributeKeyAccountHash, msg.AccountRootHash.String()),
),
})
- bridge 监听到该事件,proposer 将创建和广播 checkpoint tx 给对应的根链。
- 给 ETH、BSC 发送 checkpoint tx:获取之前提交的、针对对应根链的 checkpoint tx,将其转换成 stdTx,再封装成 tx 的 SideTxMsg,提交一个新的tx,该tx调用 RootChain 合约的 submitCheckpoint 方法,其中 SideTxMsg 以及 checkpoint tx 的投票作为参数传递。
- 调用 StakeManager.sol 的 checkSignatures 方法对投票进行验签
- 根链的 RootChainStorage 合约保存 checkpoint;
// RootChainStorage.sol
mapping(uint256 => HeaderBlock) public headerBlocks
// RootChain.sol submitCheckpoint()
function _buildHeaderBlock(
address proposer,
uint256 start,
uint256 end,
bytes32 rootHash
) private returns (bool) {
...
HeaderBlock memory headerBlock = HeaderBlock({
root: rootHash,
start: nextChildBlock,
end: end,
createdAt: now,
proposer: proposer
});
headerBlocks[_nextHeaderBlock] = headerBlock;
...
}
// RootChain.sol
function getLastChildBlock() external view returns (uint256) {
return headerBlocks[currentHeaderBlock()].end;
}
- ETH、BSC 执行该 tx 后,会 emit NewHeaderBlock 事件
- TRON 处理流程相同
- bridge 监听到来自根链的 NewHeaderBlock 事件后,proposer 给 BTTC 发布 checkpoint ack tx。
exit withdraw
调用RootChainManager合约的exit方法来解锁并从ERC20Predicate合约接收代币。这个方法接收一个参数:代币的销毁证明。
调用这个方法之前必须要等待包含销毁交易的checkpoint提交成功。销毁证明由RLP编码生成如下字段:
- headerNumber:包含销毁交易的checkpoint起始块
- blockProof:确保区块头是提交的默克尔根所在树中叶子的证明
- blockNumber:包含销毁交易的区块号
- blockTime:包含销毁交易的区块时间
- txRoot:区块的交易根
- receiptRoot:区块的receipt root
- receipt:销毁交易的receipt
- receiptProof:销毁交易receipt的默克尔根
- branchMask:表示receipt在Merkle Patricia Tree中位置的一个32位参数
- receiptLogIndex:用于从receipt中读取的日志索引
销毁证明可以自己生成,也可以调用 BTTC SDK 生成
2.2 根链 <-> 根链
TRON、Ethereum、BSC网络之间的跨链
- 资产先存入 BTTC 网络,再取出至目标链网络;
- 目前支持此跨链方式的代币有 BTT、TRX、JST、NFT、SUN、USDD、WIN;(均为原始代币是部署在 TRON 上的代币)
以 WIN 为例:原始代币 WIN 部署在 TRON 链上,其对应的子代币合约为 WIN_t ,Ethereum 链代币对应的子代币合约为 WIN_e,BSC链代币对应的子代币合约为 WIN_b。
子代币合约
WIN_e、WIN_b 两个合约与 WIN_t 所映射的子代币合约略有不同:
- 增加了 originToken 成员变量(部署时设置 WIN_t 为其 originToken);
- 增加了两个方法:
- swapIn:用 originToken 换取子代币
- 将 originToken 从交易发起者账户里转给子代币合约地址;
- 为交易发起者账户 mint 子代币;(与deposit不同的是,交易发起者即可进行 mint,不需要 DEPOSITOR_ROLE 权限)
- swapOut:用子代币换取 originToken
- 交易发起者账户 burn 子代币;
- 将 originToken 从子代币合约地址转给交易发起者账户;
- swapIn:用 originToken 换取子代币
根链代币合约
原始代币:部署在 TRON 上
其它根链代币:非标准代币,继承自 IMintableERC20,部署时需要指定主链上的 MintableAssetPredicate(例如:MintableERC20Predicate。Asset表示资产类型,下同) 合约为铸币者
MintableERC20Predicate 与 ERC20Predicate 的 exit() 方法实现不同:
- ERC20Predicate:直接从 Predicate 地址转账给 withdrawer;
- MintableERC20Predicate:若 Predicate 地址没有足够的余额,可先给 Predicate mint 不足的余额,再转给 withdrawer;
TRON给其它根链转账
操作:TRON 的账户 Addr_t 签名发起交易,转 100 WIN 给 BSC 的账户 Addr_b;
步骤 | TRON Addr_t | BTTC Addr_b | BTTC Addr(WIN_b) | BSC Addr_b |
---|---|---|---|---|
充值 | -100 WIN | +100 WIN_t (mint) | ||
提币1: swapIn | -100 WIN_t, +100 WIN_b (mint) | +100 WIN_t | ||
提币2: withdrawTo | -100 WIN_b (burn) | |||
收币 | +100 WIN |
其它根链给TRON转账
操作:从 BSC 的账户 Addr_b 转100 WIN给 TRON 的账户 Addr_t;
步骤 | BSC Addr_b | BTTC Addr_b | BTTC Addr(WIN_b) | TRON Addr_t |
---|---|---|---|---|
充值 | -100 WIN | +100 WIN_b (mint) | ||
提币1: swapOut | -100 WIN_b (burn), +100 WIN_t | -100 WIN_t | ||
提币2: withdrawTo | -100 WIN_t (burn) | |||
收币 | +100 WIN |
BSC给Ethereum转账
操作:从 BSC 的账户 Addr_b 转100 WIN给 Ethereum 的账户 Addr_e;
步骤 | BSC Addr_b | BTTC Addr_b | BTTC Addr(WIN_b) | BTTC Addr(WIN_e) | Ethereum Addr_e |
---|---|---|---|---|---|
充值 | -100 WIN | +100 WIN_b (mint) | |||
提币1: swapOut | -100 WIN_b (burn), +100 WIN_t | -100 WIN_t | |||
提币2: swapIn | -100 WIN_t (burn), +100 WIN_e | +100 WIN_t | |||
提币3: withdrawTo | -100 WIN_e (burn) | ||||
收币 | +100 WIN |
Ethereum给BSC转账
操作:从 Ethereum 的账户 Addr_e 转100 WIN给 BSC 的账户 Addr_b;
步骤 | Ethereum Addr_e | BTTC Addr_e | BTTC Addr(WIN_b) | BTTC Addr(WIN_e) | BSC Addr_b |
---|---|---|---|---|---|
充值 | -100 WIN | +100 WIN_e (mint) | |||
提币1: swapOut | -100 WIN_e (burn), +100 WIN_t | +100 WIN_t | |||
提币2: swapIn | -100 WIN_t (burn), +100 WIN_b | -100 WIN_t | |||
提币3: withdrawTo | -100 WIN_b (burn) | ||||
收币 | +100 WIN |
2.3 关于 BTT
BTT在各条链上的代币合约:
TRON:
- 继承自 TRC20 的代币,合约创建者初始有 9900 1e8 1e18 * 1e3;
- BTTC上映射的子代币:原始代币,由 GENESIS 创建,对应的合约地址是 0x0000000000000000000000000000000000001010;
genesis-contracts/bttc-contracts/contracts/child/MRC20.sol
- 同其它子代币不一样的是,deposit 与 withdraw 方法并不调用 mint 或 burn,而是直接 transfer
Ethereum & BSC:
- 继承自 IMintableERC20 的代币;
- BTTC上映射的子代币:继承自 ChildERC20 的代币,有 swapIn、swapOut;
- 其它子代币:
- 需要与 originToken 进行兑换;
- 同时要调用 mint、burn 方法,发送事件 Transfer(address(0), account, amount);
- BTT子代币:
- 没有 originToken,不需要兑换;
- 直接操作 msg.sender 的 balance 及 _totalSupply,达到 mint、burn 的效果,发送事件 Transfer(address(0x1), msg.sender, msg.value);
- 其它子代币:
三、总结
3.1 安全
使用侧链进行跨链的风险:
- 侧链自身的安全性:
- 一旦侧链出现故障,转移到侧链的资产便极有可能丢失;
- BTTC部署了多个节点进行验证和状态同步(StateSync, Checkpoint)
- 跨链过程的安全性:
- 公证人和侧链的运行节点一旦作恶,便可以从主链上转移用户的资产;
- BTTC侧链采用PoS共识机制,实现了一定程度上的去中心化;
每个侧链交易都会收集Precommit投票,只有超过2/3的节点进行了投票,交易结果才有效;
根链同步BTTC的staking情况,能验证针对侧链状态的Precommit投票。
3.2 vs Polygon
- 跨链桥
- Polygon 有 Plasma 桥、PoS 桥
- Plasma 比 PoS 更安全,但资金退出流程复杂,有7天的挑战期;
- BTTC 只有 PoS 桥
- Polygon 有 Plasma 桥、PoS 桥
- 支持跨链的公共区块链
- Polygon 目前仅支持 Ethereum;
- BTTC 支持 TRON/Ethereum/BSC;
3.3 侧链 vs 中继
侧链 | 中继 | |
---|---|---|
从属关系 | 从属于主链 | 没有 |
作用 | 区块链的可扩展性 | 跨链数据的传输 |
实现 | 将主链上的资产转移到侧链上来处理 | 从各主链抽象分离出来的一个跨链操作层 |
代表 | Polygon,BTTC | Cosmos,Polkadot |
BTTC层初始化配置
BTTC层初始化配置
创世块包含配置网络的所有基本信息。它基本上是 Bor 链的配置文件。要启动 Bor 链,用户需要将文件的位置作为参数传递。
Borgenesis.json
用作创世块和参数。这是 Bor 创世纪的一个例子config
:
"config": {
"chainId": 15001,
"homesteadBlock": 1,
"eip150Block": 0,
"eip150Hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"bor": {
"period": 1,
"producerDelay": 4,
"sprint": 64,
"backupMultiplier": 2,
"validatorContract": "0x0000000000000000000000000000000000001000",
"stateReceiverContract": "0x0000000000000000000000000000000000001001"
}
}
bor配置参数解释
period:出块时间
producerDelay:两次sprint之间的时间
sprint:每次sprint的块数
backupMultiplier:确定摆动时间的备用乘数 「后加」
validatorContract:Bor验证者集创始合约地址
stateReceiverContract:状态接收者创世合约地址
bor初始验证者地址设置
bttc层bor共识第一个sprint周期内的验证者集合,是从初识化合约中硬编码的数据获取的,
对于genesis中alloc->0000000000000000000000000000000000001000->code
默认的验证者地址是在BorValidatorSet.sol合约中的getInitialValidators
设置的(查看代码)
/// Get current validator set (last enacted or initial if no changes ever made) with current stake.
function getInitialValidators() public view returns (address[] memory, uint256[] memory) {
address[] memory addrs = new address[](3);
addrs[0] = 0xfA841eAAcf03598bAadF0266eF6097C654DE5465;
addrs[1] = 0x80CFb197Be875eE45294AC31406E6483e3eAb02E;
addrs[2] = 0x0aB3ab4542ED5FA2A67B2B8DAbC82C42162853A6;
uint256[] memory powers = new uint256[](3);
powers[0] = 1;
powers[1] = 1;
powers[2] = 1;
return (addrs, powers);
}
对于私链的自定义可以通过提供的模版方式进行修改
首先clone https://github.com/bttcprotocol/genesis-contracts.git
然后修改初始化验证者列表,为自己需要的地址集合
https://github.com/bttcprotocol/genesis-contracts/blob/master/validators.json
然后就可以生成我们自己的初始合约
node generate-borvalidatorset.js --bttc-chain-id 9527 --delivery-chain-id delivery-9527
--bttc-chain-id:是对应bttc层的链id
--delivery-chain-id: 是设置delivery层的链id
重新创建后getInitialValidators中获取的就是我们自定义的初始话验证者地址集合了
使用genesis初始化bttc层启动后,第一个sprint周期内,会使用自定义的验证者集合进行出块,
查询此初始期间block的miner为0x0000000000000000000000000000000000000000
默认的话,下一周期开始与delivery层进行数据获取 // TODO
如果是为了单独测试bttc层,避免delivery层的干扰,可以禁用delivery层,也就是第一个sprint周期后,也不会向
delivery层获取验证者集合,一直消费合约初始化的验证者集合。
关闭delivery层链接的方式是,启动参数添加
--bor.withoutheimdall
需要注意,当前最新代码,该withoutheimdall参数目前只支持启动参数传入,config方式参数未支持。
如果添加参数后,将会执行WithoutHeimdall判断逻辑,相应的代码逻辑为
miner->commitNewWork->commit->FinalizeAndAssemble->(headerNumber%c.config.Sprint == 0)->checkAndCommitSpan->needToCommitSpan->fetchAndCommitSpan->WithoutHeimdall
getNextHeimdallSpanForTest / HeimdallClient.FetchWithRetry
周期验证者地址确认后,将和其他pos链一样,各个当前周期验证者进行依次出块,以及区块同步,这里就不多做解释了
bttc官方提供的脚本环境
主网:https://github.com/bttcprotocol/launch/tree/master/mainnet-v1/without-sentry/bttc
测试网:https://github.com/bttcprotocol/launch/tree/master/testnet-1029/without-sentry/bttc
"bor": {
"period": 2,
"producerDelay": 6,
"sprint": 64,
"backupMultiplier": 2,
"validatorContract": "0x0000000000000000000000000000000000001000",
"stateReceiverContract": "0x0000000000000000000000000000000000001001",
"overrideStateSyncRecords": {
},
eth节点压力过大时,RPC节点 txpool中pending交易堆积,不能正常广播到其他节点
问题已在以下issue中被讨论
https://github.com/ethereum/go-ethereum/issues/22308
目前先尝试bsc中的方案,验证后再做补充
https://github.com/bnb-chain/bsc/pull/570