您正在查看: Surou 发布的文章

Gas 技巧:Solidity 中利用位图大幅节省Gas费

有过合约开发经验的同学都可能知道的,以太坊中最昂贵的操作是存储数据(SSTORE)。所以大家也应该一直寻找方法来减少存储需求。让我们来探讨一个特别有用的方法:位图
注:在 Uniswap 的代码中,有很多使用位图来优化 gas 的技巧。

如何实现一个简单的位图

假设我们想存储10个布尔值。通常,我们会用一个简单的布尔数组来实现这一点,例如:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract BitmapTest {
    bool[10] implementationWithBool;

    function setDataWithBoolArray(bool[10] memory data) external {
        implementationWithBool = data;
    }

    function readWithBoolArray(uint256 index) external returns (bool) {
        return implementationWithBool[index];
    }
}

而使用 Bitmap 位图,可以用一个 uint10代替bool数组来实现。uint10将在存储中用10 位(bits 比特位)表示。
例如,这里有一些用比特(bit)表示的十进制数字:

  • 0: 0000000000
  • 1: 0000000001
  • 512: 0100000000
  • 729: 1011011001
  • 1023: 1111111111

我们可以用一些额外的数学方法来利用这种位表示法。为了得到这个整数的第n位,我们可以使用位运算

让我们来看看729这个数字,在常规方式下,用一个bool数组来读取第4个bool值,它只是一个array[4]。对于位图,我们可以通过使用左移运算符<<将1向左移,来代替创建第二个数字。

1 << 4 = 0000000001 << 4 = 0000010000

现在使用位和运算符&,我们可以得到第n位的值(从0开始计算)。

729 & (1 << 4) = 1011011001 & 0000010000

其结果是

  • 1011011001 &
  • 0000010000 =
  • 0000010000

只要这个结果 大于0 ,原数的第n位就是1,所以现在我们可以实现位图:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract BitmapTest {
    uint256 implementationWithBitmap;

    function setDataWithBitmap(uint256 data) external {
        implementationWithBitmap = data;
    }

    function readWithBitmap(uint256 indexFromRight) external returns (bool) {
        uint256 bitAtIndex = implementationWithBitmap & (1 << indexFromRight);
        return bitAtIndex > 0;
    }
}

选择位图大小

你可能已经注意到,我们在上面的实现中选择了uint256。虽然uint10在技术上是足够的,但这实际上会导致比使用uint256更高的Gas成本。这是因为EVM在32个字节的寄存器(256位)上操作,任何低于这个数字的都需要额外的转换。

所以你应该总是选择 uint256 吗?

也不是,这取决于你的使用情况。用一个uint256,你可以表示256位。那么你想存储的数据是否适合一个256位的布尔数组?如果是,那么就继续使用单个uint256。

如果不能,例如布尔数组可以任意增长,那么就把位图本身打包成一个数组。我将在最后用一个例子来探讨这两种选择。

比较Gas成本

让我们先来看看10位例子中的Gas成本差异。用原来的布尔数组,交易的执行成本是:

  • setDataWithBoolArray: 140,583 gas
  • ReadWithBoolArray: 1,281 gas

现在有了位图,我们可以大大改善这个情况:

  • setDataWithBitmap: 78,043 gas
  • readWithBitmap: 1,129 gas

使用场景1:设置布尔开关

现在来看看第一个使用场景: 布尔开关通常被用来激活系统中的某些选项。
比方说,你建立了一个像Uniswap一样的DEX,你可以自动触发的交易。你可以根据交易的来源来激活某些设置。例如,你可能有如下开关

  • NO_FEES (无交易费)
  • ...
  • SENDING_FEES_TO_GOVERNANCE (发送费用到治理)
  • DELAY_TRADE_EXECUTION (延迟交易执行)

这些选项可能不会超过256个,所以你可以很容易地将这些选项存储在一个uint256中。

使用场景2:参与者的名单

你可能想向任何参与过你的合约的人支付奖励。这可能是一个任意的大列表。你可以在一个映射中保存每个参与者,或者用一个uint256数组来代替位图。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract ParticipatedWithBitmap {
    uint256[] public participantsBitmap;

    function setParticipants(uint256[] memory participantsBitmap_) external onlyOwner {
        participantsBitmap = participantsBitmap_;
    }

    function hasParticipated(uint256 bitmapIndex, uint256 indexFromRight) external view returns (bool) {
        uint256 bitAtIndex = participantsBitmap[bitmapIndex] & (1 << indexFromRight);
        return bitAtIndex > 0;
    }
}

欢迎订阅专栏 学习更多 Solidity 高阶优化技巧。

转载:https://learnblockchain.cn/article/5287

一文读懂以太坊签名:ECDSA、RLP、EIP155、EIP191、EIP712

思维导图

我把以太坊签名分为对消息签名与对交易签名,这两种签名都是基于ECDSA算法与流程,本章就让我们来搞清楚两种签名具体的内容。

签名概述

签名的作用或目的?

  • 身份认证:证明你拥有地址的私钥;
  • 不可否认:确认你的确发布过该消息;
  • 完整性:确保信息没有被篡改;

具体见维基百科:wikipedia签名的性质

什么是签名?

当我刚开始接触签名这个名词时,我也很困惑,此处的签名和现实世界中合同的签名有什么不同?当我签署一份租房合同,当我们租期到时,如果对屋内的物品所有损坏,房东可凭借这份合同上的内容对我进行索赔(扣押金),如果我进行抵赖,说我没签署过这份合同,那么房东可去司法机构进行签名笔迹认证。以太坊中的签名也是如此,在租房合同中签名是笔迹,在以太坊中的签名就是一段数据,这段数据的作用和我签署租房合同的签名笔迹没有任何不同,节点们(矿工们、验证者们)可以凭借这段数据进行身份认证,即证明这些消息就是我签署的(因为只有我拥有私钥),同时,我想抵赖也是不可能的,因为这段数据具备不可否认性,第三方也不可能对消息进行篡改,因为这段数据具备完整性。如果对上面阐述的内容暂时不理解也没关系,继续往下看,多看一些资料很快就会理解的。此时我们只需记住,以太坊或者计算机中这个"签名"与现实世界中的向"合同上签名"是一个意义。下面先概括一下以太坊的签名与验证过程:

  • 签名过程:ECDSA_正向算法(消息 + 私钥 + 随机数)= 签名
  • 验证过程:ECDSA_反向算法(消息 + 签名)= 公钥

在以太坊、比特币中这个算法是经过二开的ECDSA(原始的ECDSA只有r、s组成,以太坊、比特币的ECDSA由r、s、v组成)。

使用ECDSA签名并验证

什么是ECDSA

ECDSA可理解为以太坊、比特币对消息、交易进行签名与验证的算法与流程。在智能合约层面,我们不必多关注其算法的细节,只需理解其流程,看得懂已有项目代码,可以在项目写出对应功能代码即可。

流程

  • 签名即正向算法(消息 + 私钥 + 随机数)= 签名,其中消息是公开的,私钥是隐私的,经过ECDSA正向算法可得到签名,即r、s、v(不用纠结与r、s、v到底什么,只需要知道这就是签名即可)。
  • 验证即反向算法(消息 + 签名)= 公钥,其中消息是公开的,签名是公开的,经过ECDSA反向算法可得到公钥,然后对比已公开的公钥。

签名交易

关键词

  • RLP:一种序列化的方式,其与网络传输中json的序列化/反序列化有一些不同,RLP不仅兼顾网络传输,其编码特性更确保了编码后的一致性,因为每笔交易过程中要进行Keccak256,如果不能保证编码后的一致性,会导致其Hash值不同,那么验证者就无法验证交易是否由同一个人发出。
    若对上面的阐述不理解,继续看下面的内容。
    编码方式详情见详解以太坊RLP编码(不用过度研究)。
  • Keccak256 :以太坊的Hash算法,生成32个字节Hash值。

签名交易流程

1. 构建原始交易对象

  • nonce: 记录发起交易的账户已执行交易总数。Nonce的值随着每个新交易的执行不断增加,这能让网络了解执行交易需要遵循的顺序,并且作为交易的重放保护。
  • gasPrice:该交易每单位gas的价格,Gas价格目前以Gwei为单位(即10^9wei),其范围是大于0.1Gwei,可进行灵活设置。
  • gasLimit:该交易支付的最高gas上限。该上限能确保在出现交易执行问题(比如陷入无限循环)之时,交易账户不会耗尽所有资金。一旦交易执行完毕,剩余所有gas会返还至交易账户。
  • to:该交易被送往的地址(调用的合约地址或转账对方的账户地址)。
  • value:交易发送的以太币总量。
  • data:
    • 若该交易是以太币交易,则data为空;
    • 若是部署合约,则data为合约的bytecode;
    • 若是合约调用,则需要从合约ABI中获取函数签名,并取函数签名hash值前4字节与所有参数的编码方式值进行拼接而成,具体参见文章Ethereum的合约ABI拓展
  • chainId:防止跨链重放攻击。 ->EIP155

2. 签署交易

签署交易可使用MetaMask和ethers库。

  • MetaMask
    前端:使用MetaMask进行签名为前端技术栈,目前比较流行为nextjs+ethers,我对前端不太了解,这里不做展开。
  • ethers库
    后端:使用ethers库可以进行交易的签名,详情见如下代码:
const ethers = require("ethers")
require("dotenv").config()

async function main() {
    // 将RPC与私钥存储在环境变量中
    // RPC节点连接,直接用alchemy即可
    let provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL)
    // 新建钱包对象
    let wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
    // 返回这个地址已经发送过多少次交易
    const nonce = await wallet.getTransactionCount()
    // 构造raw TX
    tx = {
      nonce: nonce,
      gasPrice: 100000000000,
      gasLimit: 1000000,
      to: null,
      value: 0,
      data: "",
      chainId: 1, //也可以自动获取chainId = provider.getNetwork()
    }
    // 签名,其中过程见下面详述
    let resp = await wallet.signTransaction(tx)
    console.log(resp)
    // 发送交易
    const sentTxResponse = await wallet.sendTransaction(tx);
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

wallet.signTransaction中发生了什么?

  1. 对(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)进行RLP编码;
  2. 对上面的RLP编码值进行Keccak256 ;
  3. 对上面的Keccak256值进行ECDSA私钥签名(即正向算法);
  4. 对上面的ECDSA私钥签名(v、r、s)结果与交易消息再次进行RPL编码,即RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s),可得到如下编码;
    0xf8511485174876e800830f42408080802da077cdb733c55b4b9b421c9e0e54264c6ccf097e5352ab117e346b69ac09755606a03ef912e80a022713cf5ccc3856f2d4159bf47de7c14090b66ef155b10d073776

    细心的同学可能会问,为什么步骤1中包含chainId字段,而步骤4中再次编码时没有chainId字段?原始消息内容都不一样,怎么可能会验证通过?先别急,这是因为chainId 是被编码到签名的 v参数中的,因此我们不会将chainId本身包含在最终的签名交易数据中(下面一小节也有阐述)。当然,我们也不会提供任何发送方地址,因为地址可以通过签名恢复。这就是以太坊网络内部用来验证交易的方式。

验证过程

交易签名发送后,以太坊节点如何进行身份认证、不可否认、完整性?

  1. 对上面最终的RPL解码,可得到(nonce, gasPrice, gasLimit, to, value, data, v, r, s);
  2. 对(nonce, gasPrice, gasLimit, to, value, data)和(v,r,s)ECDSA验证(即反向算法),得到签名者的address,细心的同学可以看到第一个括号少了chainId,这是因为chainId在ECDSA私钥签名(即正向算法) 时被编码到了v,所以由v可以直接解码出chainId(所以在对上面的RLP编码值进行Keccak256;这一步,肯定是把chainId复制了一下,给对上面的Keccak256值进行ECDSA私钥签名(即正向算法);这一步用);
  3. 对上面得到的签名者的address与签名者公钥推导的address进行比对,相等即完成身份认证、不可否认性、完整性

我们可以去MyCrypto - Ethereum Wallet Manager,将wallet.signTransaction生成的编码复制进去,对上述验证步骤有一个直观的感受,比对一下ECDSA反向算法得出的"from"是不是自己的地址?

安全问题

我们注意到原始交易对象里由Nonce和ChainID两个字段,这是为了防范双花攻击\重放攻击(双花与重放是相对的,本质都是重复使用一个签名,用自己的签名做对自己有利的重复叫双花,用别人的签名做对自己有利的重复叫重放):

  • Nonce:账户交易计数,以太坊的账户模型中记录着每个账户Nonce,即此账户发起过多少次交易。
  • ChainId:分叉链区分,比如我在以太坊链上给evil进行一笔转账交易,evil又可以去以太坊经典链上重放这笔交易,这时如果我在以太坊经典上也有资产,那么会遭受损失。所以EIP155提议加入ChainId,以防止跨链重放。以太坊ChainId为1,以太坊经典ChainId为61。

节点验证之后的动作

这里和签名的关系不大,便不再详述,大体可以看看下面这个帖子:
https://blog.csdn.net/LJFPHP/article/details/81261050

签名消息( =presigned message = 预签名)

大家看完上面的签名交易后,再看本章的签名消息,可能会有有些懵的感觉,会将其混为一谈,误以为这两个东西是平行的,各自发给节点的。这是对以太坊交易流程不清晰导致的,只需记住一点,发给节点的只能是交易签名+相应参数,其大概可分为三种情况:

  • 单纯的转账交易:就是上一章节的内容,由(nonce, gasPrice, gasLimit, to, value, data:空,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to, value, data:空, v, r, s)发往节点。注意,这里data是空的。
  • 部署合约交易:由(nonce, gasPrice, gasLimit, to:空, value, data:合约创建字节码,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to:空, value, data:合约创建字节码, v, r, s)发往节点。注意,to是空的, data是合约创建字节码,节点看到to是空的就知道这是部署合约交易。不明白什么是合约创建字节码,可以看看OpenZeppelin这个系列文章,我对这系类文章有相应笔记,待后续整理发出。。
  • 调用合约函数交易:由(nonce, gasPrice, gasLimit, to, value, data:selector+函数参数,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to, value, data:selector+函数参数, v, r, s)发往节点。注意, data是selector+函数参数,例如bytes4(keccak256(bytes("foo(uint256,address,string,uint256[2])"))), x, addr, name, array。对于这个不明白的同样看上面OpenZeppelin文章即可。

综上,无论是部署合约交易还是调用合约函数交易都是改变data的值。 我们本章所讲的消息签名就是调用合约函数交易,调用对应合约函数(一般为验证verfiy函数),消息签名作为参数。

针对上面调用合约函数交易+消息签名的内容,我们配合流程图与层叠图加深理解:
流程图:

层叠图:

通用消息签名方法


当我在看EIP-191这个提案时(https://eips.ethereum.org/EIPS/eip-191),有些困惑,其标准格式到底是第一个红框还是第二个红框?经过看网上资料和琢磨一定时间后,其中的逻辑应该是这样的,我们可以把签名方法划分为三种:

  • 通用消息签名方法;
  • EIP-191标准签名方法;
  • EIP-712标准签名方法;

我们不用把他们想的太复杂,简单点来说通用签名方法就是添加了"\x19Ethereum Signed Message:\n"这个字符串的签名,如metamask的personal_sign方法和ECDSA库的toEthSignedMessageHash方法,添加这个字符串只是单纯的为了表明这是以太坊的签名,让我们看看通用签名方法的例子--NFT白名单。

metamask的personal_sign方法:

ECDSA库的toEthSignedMessageHash方法:

NFT白名单签名举例

让我们来写一个NFT白名单合约,想想如何实现白名单这个功能?

  • 把白名单地址存入数组,当目标地址调用mint函数时进行判断。 ->因为storage会耗费大量gas,项目方还没开始赚钱在deploy合约时就先破产了。后续白名单用户在Mint对白名单数组的读opcode又会耗费大量gas,白名单用户本来就想占点便宜,结果还花费了大量gas。所以这个方法几乎没有人用。
  • 用Merkle Tree来校验白名单,具体方法看0xAA师傅的这篇文章https://github.com/AmazingAng/WTF-Solidity/blob/main/36_MerkleTree/readme.md,这个方法的只需要storage一个root值,但相较于上面的方法已经非常节省gas。
  • 对白名单进行链下签名,这个方法是当下通用的,以我们目前NFT这个例子来说,也会需要storage一个owner,但相较于Merkle Tree在白名单用户mint的时候会少传一个数组,少很多opcode,也就为用户节省了gas。下面让我们来实操一下。

前提

技术栈:hardhat(社区hardhat-deploy包)+ethers.js,以下我只针对于白名单这个知识点进行讲解,不涉及技术栈的知识。
我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址。

合约

  • constructor,确定NFT名称、符号与signer,即签名者地址;
  • recoverSigner,对签名进行解析,返回地址。这里是白名单签名逻辑的核心,我们传入签名与签名时的参数,用ECDSA反向运算解析出签名者地址;
  • verify,确定signer就是签名者;
  • mintNft,调用verify,判断通过进行_safeMint;
    
    // SPDX-License-Identifier: UNLICENSED
    pragma solidity ^0.8.7;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

error SignatureNFT__VerifyFailed();

contract SignatureNFT is ERC721 {
// signer address
address public immutable i_signer;

constructor(address signer) ERC721("Test Signature NFT", "TSN") {
    // set signer address
    i_signer = signer;
}

// mint NFT
function mintNft(
    address account,
    uint256 tokenId,
    bytes memory signature
) public {
    if (!verify(account, tokenId, signature)) {
        revert SignatureNFT__VerifyFailed();
    }
    _safeMint(account, tokenId);
}

// Check if signer and i_signer are equal
function verify(
    address account,
    uint256 tokenId,
    bytes memory signature
) public view returns (bool) {
    address signer = recoverSigner(account, tokenId, signature);
    return signer == i_signer;
}

// Return signer address
function recoverSigner(
    address account,
    uint256 tokenId,
    bytes memory signature
) public pure returns (address) {
    // 后招
    bytes32 msgHash = keccak256(abi.encodePacked(account, tokenId));
    bytes32 msgEthHash = ECDSA.toEthSignedMessageHash(msgHash);
    address signer = ECDSA.recover(msgEthHash, signature);
    return signer;
}

}


#### 签名脚本
- 逻辑:hash(签名参数) -> signMessage(hash(签名参数));
- 生产环境中,我们将signers[index].address换为白名单地址,将signers[0]换位signer即可;

```javascript
const { ethers } = require("hardhat")

// 对要签名的参数进行编码
function getMessageBytes(account, tokenId) {
    // 对应solidity的Keccak256
    const messageHash = ethers.utils.solidityKeccak256(["address", "uint256"], [account, tokenId])
    console.log("Message Hash: ", messageHash)
    // 由于 ethers 库的要求,需要先对哈希值数组化
    const messageBytes = ethers.utils.arrayify(messageHash)
    console.log("messageBytes: ", messageBytes)
    // 返回数组化的hash
    return messageBytes
}

// 返回签名
async function getSignature(signer, account, tokenId) {
    const messageBytes = getMessageBytes(account, tokenId)
    // 对数组化hash进行签名,自动添加"\x19Ethereum Signed Message:\n32"并进行签名
    const signature = await signer.signMessage(messageBytes)
    console.log("Signature: ", signature)
}

async function main() {
    signers = await ethers.getSigners()
    // 我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址
    for (let index = 1; index < 4; index++) {
        await getSignature(signers[0], signers[index].address, index)
    }
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

部署脚本

在部署脚本中,我们直接用deployer作为constructor参数传入,即用deployer当作signer;

const { network, ethers } = require("hardhat")

module.exports = async ({ getNamedAccounts, deployments }) => {
    const { deployer } = await getNamedAccounts()
    console.log(deployer)
    const { deploy, log } = deployments
    const chainId = network.config.chainId

    const args = [deployer]

    await deploy("SignatureNFT", {
        contract: "SignatureNFT",
        from: deployer,
        log: true,
        args: args,
        waitConfirmations: network.config.waitConfirmations || 1,
    })
    log("[==>]------Deployed!------------------------------------------")
}

module.exports.tags = ["SignatureNFT"]

我们就选取最后一个Signature,即项目方给accounts[3]的签名。

测试脚本

分别对每个函数进行测试,其中mintNft为模拟交易
注意:
当我对mintNft函数进行错误性测试时(即第二个it,"incorrect mintNft shoule trigger error of SignatureNFTVerifyFailed"),我开始写的是.to.be.revertedWith("SignatureNFTVerifyFailed"),但出现了如下报错,于是我肯定ECDSA.recover这个函数中有先行报错,但我把其中四个revert报错都试过来了,还是会有but it reverted with a custom error这个提示,大家有兴趣可以找找这个先行报错在哪里,我就不找了,直接用.to.be.revertedWithCustomError()吧...


const { getNamedAccounts, ethers, network } = require("hardhat")
const { assert, expect } = require("chai")

describe("SignatureNFT", function () {
    let signatureNFT, deployer

    beforeEach(async function () {
        // 我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址
        accounts = await ethers.getSigners()
        console.log(`accounts[0]:${accounts[0].address}`)
        console.log(`accounts[1]:${accounts[1].address}`)
        // 通过hardhat.config.js配置deployer就是accounts[0]
        // 当然直接用accounts[0]也行,这里显得直观些
        deployer = (await getNamedAccounts()).deployer
        console.log(`deployer:${deployer}`)
        // 部署合约
        await deployments.fixture(["SignatureNFT"])
        // 获得合约对象,若getContract没有account传入,则为deployer连接
        signatureNFT = await ethers.getContract("SignatureNFT")
    })

    // test constructor
    describe("constructor", function () {
        it("i_signer is deploy_address when deploy constract", async function () {
            const signer = await signatureNFT.i_signer()
            assert.equal(signer, deployer)
        })
    })

    //test recoverSigner
    describe("recoverSigner", function () {
        it("recoverSigner could return address of account[0](deployer) when contract deploy in default chain", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const signer = await signatureNFT.recoverSigner(account.address, tokenId, signature)
            assert.equal(signer, deployer)
        })
    })

    //test varify
    describe("varify", function () {
        it("function varify should return ture", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const verify = await signatureNFT.verify(account.address, tokenId, signature)
            assert.equal(verify, true)
        })
    })

    // correct mintNft
    describe("mintNft", function () {
        it("the third tokenId should belong to account[3] when mint the third signature", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const verify = await signatureNFT.mintNft(account.address, tokenId, signature)
            const owner = await signatureNFT.ownerOf(3)
            assert.equal(owner, account.address)
        })

        // incorrect mintNft shoule trigger error of SignatureNFT__VerifyFailed
        it("incorrect mintNft shoule trigger error of SignatureNFT__VerifyFailed", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            // 我们规定accounts[3]只能Mint tokenId_3,他想Mint tokenId_2,那肯定不可以。
            const tokenId = 2
            const account = accounts[3]
            // 这里有个注意点
            await expect(
                signatureNFT.mintNft(account.address, tokenId, signature)
            ).to.be.revertedWithCustomError(signatureNFT, "SignatureNFT__VerifyFailed")
        })
    })
})

综上

通用签名方法实现逻辑为:

  • 签名逻辑:parameter... -> keccak256得到"消息Hash" -> 以太坊签名消息Hash -> 加入私钥进行运算得到"签名”。
    • 消息HASH -> keccak256(abi.encodePacked(parameter...) 。
    • 以太坊签名消息 -> keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)) 。
  • 合约验证逻辑:ecrecover(_msgHash, v, r, s) 可以"消息"和"签名"中的vrs推出地址,然后我们在我们需要的程序中对其进行对比即可。

EIP-191

我们再一次看看这张图,第一个红框就是EIP-191的实现格式,以0x1900,0x1945开头的数据段可视为EIP191,它有什么作用?在https://eips.ethereum.org/EIPS/eip-191的Motivation中可以看出,EIP-191提出了在签名中加入合约自身的address参数,以防止重放攻击的手法。
我们这里不多阐述EIP-191,因为在生产中,人们似乎用更高端的EIP-712更多,EIP-712相当于继承了EIP-191,而且签名时还能小狐狸上显示签名内容,既高端又安全。那么这里我们就略过EIP-191,直接进行EIP-712,对EIP-191有兴趣的同学可以去看看https://eips.ethereum.org/EIPS/eip-191 最下方的例子。

把Version byte写为0x01就是EIP-712啦。

关于重放攻击

例子Motivation中用多签钱包举例,实际上任何两个实现逻辑相同的合约,都会存在重放攻击的风险,如我们举例NFT白名单,试想项目方想布置另一套NFT,只是修改了URI就进行部署了,那么毫无疑问上一个项目的白名单撸穿。
这是一个油管视频截图(https://www.youtube.com/watch?v=jq1b-ZDRVDc, 再看看这个https://solidity-by-example.org/hacks/signature-replay/) ,很好的解释了如何防范重放攻击:

  • 没有任何防护的多签钱包,Eve重放Signature就会再次取出eth
    • 在encodePacked的时候加入nonce参数,这就相当于每个Signature具备唯一性;
    • 在合约逻辑上加入一个mapping,当一个Signature用过后就将其设置为true,表示已用过;
  • 如果这个多签钱包的代码被被另一个项目方复用,正巧我在Eve和Alice也想在此放置资产,怎么防止重放?
    • 在encodePacked的时候加入合约的address参数,这就相当于每个Signature对每个地址具备唯一性;
  • 一个合约工厂用create2在可预测地址创建多签钱包,并且带有selfdestruct(),管理员没事还运行selfdestruct(),如何防范重放?(哪个奇葩会写出这样的合约)
    • 防不了了,Alice在A地址的多签钱包存了10ETH,然后给Eve一个Signature,同意其取5个ETH,Eve正常取了5个之后想要重放攻击,因为有nonce并且Signature被记录,所以重放不了,但合约的owner在某种情况下selfdestruct()了合约,这是nonce恢复初始值,但Alice没注意到selfdestruct()事件,仍然向A地址的多签钱包存钱,这时Eve就可以重放Signature了,再拿走5ETH。

这里我再加一个:

  • 加入chainId,防范在不同链的、逻辑相同的合约的重放攻击

EIP-712

在学习EIP-712时,不妨让我们先看一下其具体实现。以代码下是UniswapV2ERC20合约的实现,在20行bytes32 digest由三部分组成:

  • '\x19\x01' ,固定字符串;
  • DOMAIN_SEPARATOR,由constructor中定义;
  • keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)),其中PERMIT_TYPEHASH是constant变量

而EIP-712就是由以上三部分组成。

string public constant name = 'Uniswap V2';
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
constructor() public {
    uint chainId;
    assembly {
        chainId := chainid
    }
    DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
    );
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
    bytes32 digest = keccak256(
        abi.encodePacked(
            '\x19\x01',
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
        )
    );
    address recoveredAddress = ecrecover(digest, v, r, s);
    require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
    _approve(owner, spender, value);
}

"面子"上的区别

在https://eips.ethereum.org/EIPS/eip-712 中,我们可以看出,EIP-712与通用签名消息方法、EIP-191的"面子"上的区别为在钱包签名时显示的不同,遵循EIP-712方法可使签名内容可视化,这会提高我们签名时的安全性,试想,你的主机被黑客控制,准备签名的Message被替代,凭借肉眼,你肯定看不出签名的内容有何不同。

"里子"上的区别

EIP-712 结构式

encode(domainSeparator,message)=x19x01∣∣domainSeparator∣∣hashStruct(message)

EIP-712 结构解析

对比我们上述"通用消息签名方法"中只是对要签名的参数进行序列化、keccak256、添加"\x19Ethereum Signed Message:\n32"后再次序列化与keccak256、签名相比,EIP-712是有着结构化上的要求的,我这里针对其结构式进行一个思维导图上的解析,大家如果想看文字上的描述,可以研读如下资料:

下面让我们对照UniswapV2ERC20和思维导图看看domainSeparator和hashStruct(meaasge)具体实现。

签名域-domainSeparator

domainSeparator由两部分组成,第一部分为对结构体的keccak256(encodeType),第二部分为结构体的具体实现(encodeData);
domainSeparator结构体如下所示,一般来说salt(随机数)会省略;

struct EIP712Domain{
    string name, //用户可读的域名,如DAPP的名字
    string version, // 目前签名的域的版本号
    uint256 chainId, // EIP-155中定义的chain ID, 如以太坊主网为1
    address verifyingContract, // 用于验证签名的合约地址
    bytes32 salt // 随机数
}
  • 再让我们对照UniswapV2ERC20来记几个结论:
    • encodeType与encodeData都要按照如上的结构体顺序来实现,其中字段可以省略,但不可以颠倒顺序,如UniswapV2ERC20所示,省略了salt字段,但是按照name、version、chainId、verifyingContract来排序的;
    • 针对string 或者 bytes 等动态类型,即长度不定的类型,其取值为 keccak256(string) 、 keccak256(bytes) 即内容的hash值;
    • 我们可以看到这里用的是abi.encode而非abi.encodePacked,这是因为domainSeparator结构体要求每个字段占据256位,以便于前端分割。
      DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            // encodeType
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            // encodeData
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
      );

签名对象-hashStruct(message)

  • 一般来讲,hashStruct(message)与domainSeparator格式相同,也是由由两部分组成,第一部分为对自定义结构体的keccak256(encodeType),第二部分为自定义结构体的具体实现(encodeData);
  • 由注释可知,PERMIT_TYPEHASH就是Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)的hash,注意自定义对象名要首字母大写;
  • encodeData与encodeType顺序要相同;
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
    bytes32 digest = keccak256(
          abi.encodePacked(
              '\x19\x01',
              DOMAIN_SEPARATOR,
              keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
          )
      );

    然后就是熟悉的套路,ecrecover出address,进行比较,最后授权。

    
    address recoveredAddress = ecrecover(digest, v, r, s);
      require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
      _approve(owner, spender, value);
### 签名脚本
用uniswapV2结构体进行举例,注意我们要先确定verifyingContract,即pair的地址。
```javascript
const { ethers } = require("hardhat")

async function signTypedData(signer) {
    const domain = {
        name: "Uniswap V2",
        version: "1",
        chainId: 1,
        verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
    }

    // The named list of all type definitions
    const types = {
        Permit: [
            { name: "owner", type: "address" },
            { name: "spender", type: "address" },
            { name: "value", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" },
        ],
    }

    // The data to sign
    const value = {
        owner: xx,
        spender: xx,
        value: xx,
        nonce: xx,
        deadline: xx,
    }

    const signature = await signer._signTypedData(domain, types, value)
    return signature
}

async function main() {
    signers = await ethers.getSigners()
    signature = await signTypedData(signers[0])
    console.log(signature)
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

uniswap为什么要应用EIP712?

实质上还是和NFT白名单的目的一样——为了节省GAS,uniswapV2分为pair合约UniswapV2Pair.sol和路由合约UniswapV2Router02.sol。当我们要移除流动性时需要通过路由合约来操作pair合约,试想如果没有应用消息签名,其操作路径为先在pair合约进行授权,然后在路由合约中移除流动性,也就是两笔交易。而应用消息签名只需要一笔交易即可完成。可想而知,面对Uniswap这种海量交易的情况下,也节省海量的GAS,也节省了海量的链上资源。

后记:

  • 以上为签名相关的知识点讲解,内容肯定有疏忽或者理解不到位的情况,欢迎各位大佬交流指正。
  • 封面出自Jose Aguinaga文章,如涉及侵权,请站内私信,我会及时整改。
  • 如需转载本文,请注明出处。
  • ethsamgs 2022.11.8

参考&致谢

转载:https://learnblockchain.cn/article/5012

更好Solidity合约调试工具: console.log

是时候用Hardhat EVM 替换ganache了,Hardhat EVM 是一个用于本地开发的以太坊网络,提供了更好的堆栈跟踪功能和console.log() 输出日志。

Hardhat EVM 是一个用于本地开发的以太坊网络,提供了更好的堆栈跟踪功能和console.log() 输出日志。

Hardhat EVM 及 console.log


在以太坊上建立智能合约看起来越来越像人类可以做的事情,这一切正在发生。

在 19 年 10 月, 我们推出了Hardhat EVM:一种ganache-cli替代方案,其实现了Solidity的堆栈跟踪功能。 这是迈向更好的开发人员体验的重要一步,现在我们发布了另一个备受期待的Buidler EVM功能:用于Solidity的 console.log()。

译者注: 用 npx hardhat node启动 Builder EVM后,其他就和使用 Ganache 完全一样。

Hardhat EVM是为开发而设计的本地以太坊网络。 它允许您部署合约,运行测试和调试代码, 并且Hardhat EVM是被设计为可启用高级工具的平台。

当前从Solidity记录数据的主要方法是触发事件(emitting events),但是这种方法有很大限制:它仅适用于成功的交易。 这是因为EVM不会在交易失败时触发事件。而往往是交易失败时,开发人员需要了解发生了什么情况,因此这对开发来说是很悲惨的。

Hardhat EVM拥有强大的执行检查架构,使我们能够实现可靠console.log ,它将始终可用,即使在交易失败的时候,它还可以与您选择的测试工具一起使用 。

使用 console.sol

使用它很简单。 只需导入@nomiclabs/buidler/console.sol (备注:现在升级为了 import "hardhat/console.sol"),然后在函数中加入console.sol,就像在JavaScript中一样使用它即可,例如:

然后使用Hardhat EVM作为开发网络使用Hardhat运行测试。

可以使用任何工具(不仅是Buidler)编译合约,因此需要,可以放心的保留着log的调用。 诸如Tenderly之类的工具将集成日志的抓取功能,因此,您甚至可以根据需要将日志记录代码部署到测试网和主网。 在其他网络中运行时,调用console.log不会执行任何操作,但会产生gas费用。

Hardhat EVM的最新版本还增加了对Solidity 0.6支持以及新的JSON-RPC方法evm_snapshot和evm_revert ,从而允许项目使用快照迁移到Buidler并继续其测试优化。

结合堆栈跟踪功能,标志着智能合约开发生产力的新篇章。

带着 Hardhat EVM的console.log去兜兜风!

mkdir console/
cd console/
npm init -y
npm install --save-dev @nomiclabs/buidler
npx buidler # and create a sample project
npx buidler test

使用Hardhat,你很快会忘记Solidity调试给你的挫败感

在Truffle项目中使用console.log

在现有的 truffle 项目中也可以非常容易的使用console.log,先在项目下安装 Hardhat :

npm install --save-dev @nomiclabs/hardhat
// 或
yarn add @nomiclabs/hardhat

然后在合约文件中引入 import "hardhat/console.sol";,然后在需要的地方加入console.log() 打印即可

接着就是部署和测试,在 truffle 项目,一般使用的是 Ganache 网络,现在我们使用Builder EVM替代Ganache,修改truffle-config.js 配置:

  networks: {
    development: {
       host: "127.0.0.1",     
       port: 8545,   
       network_id: "*"
    }
  }

Ganache的默认 RPC 端口通常是 7545, Hardhat EVM 默认 RPC 端口是8545,因此我们修改development网络的端口为8545。

启动Hardhat EVM后,就可以进行部署了,使用命令npx hardhat node启动Hardhat EVM ,Hardhat EVM 会为我们分配 20 个账号、每个账号有 10000 个以太币。
如果是第一次启动,会提示我们创建一个项目,可以选择"Create an empty hardhat.config.js",即创建一个空的hardhat.config.js 。

之前就可以和之前开发 Truffle 项目完全一致了,开启另一个命令终端,使用truffle migrate命令进行部署,如果我们在构造函数中加入了console.log() ,那么在Builder EVM终端里,就可以参看到日志了。

转载:https://learnblockchain.cn/article/1371

Solidity 十大常见安全问题

在2018年,我们(CheckMarx)曾对智能合约安全状况进行过初步研究,重点是Solidity编写的智能合约。 当时,我们根据公开的合约源代码(译者注:本文称之为已扫描合约,本文出现的 x% 是以此为基数)编写了最常见的10 个智能合约安全问题。 两年过去了该更新研究并评估智能合约安全性发展的如何了。

0. 值得关注的其他问题

尽管有一个安全问题排名很不错,但它往往一些有趣的细节,因为某些细节与排名列表并不完全一致。 在深入挖掘 10 大问题之前,必要阐述一下原始研究中一些值得关注的亮点问题:

  • 在2018年,最主要的两个问题是 外部合约拒绝服务和重入。 但是现在这些问题有所缓解(不过依旧值得关注)。 可以从我们的研究博客中了解更多有关Reentrancy的信息:从安全角度出发审视智能合约
    译者注: 实际上由于 DeFi 应用之间的组合应用(例如闪电贷),又导致了多起严重的重入攻击事件。
  • 现在 Solidity v0.6.x 发布了,它带来了许多重大变化,然而扫描的智能合约中有50%甚至还没有准备好使用Solidity v0.5.0编译器。 另外 30% 智能合约使用了过时的语法(例如:使用 sha3、throw 、constant等),并且 83%的合约在指定编译器版本存在规范问题(pragma)。
  • 尽管可见性问题没有出现在2018年的前10位,也没有出现今年的前 10,但可见性问题增加了48%,值得关注。
    下表比较了2018年和2020年十大常见问题列表之间的变化。 这些问题按严重程度和流行程度排序:

1. 未检查的外部调用

在 2018 年 Solidity十大安全问题榜单上未检查的外部调用是第三个常见问题。由于现在前两个解决了, 因此未检查的外部调用成为了2020年更新列表中最常见的问题。
Solidity 底层调用方法,(例如 address.call()) 不会抛出异常。而是在遇到错误,返回false。
而如果使用合约调用ExternalContract.doSomething()时,如果 doSomething()抛出异常,则异常会继续“冒泡”传播。
应该通过检查返回值来显式处理不成功的情况,以下使用addr.send()进行以太币转账是一个很好的例子,这对于其他外部调用也有效。

if(!addr.send(1)) {
    revert()
}

2. 高成本循环

高成本循环从Solidity安全榜单的第四名上升至第二名。受该问题影响的智能合约数量增长了近30%。
大家都知道,以太坊上的运算是需要付费的。因此,减少完成操作所需的计算,不仅仅是优化问题(效率),还涉及到成本费用。
循环是一个昂贵的操作,这里有一个很好的例子:数组中包含的元素越多,就需要更多迭代才能完成循环。最终,无限循环会耗尽所有可用GAS。

for(uint256 i=0; i< elements.length; i++) {
    // do something
}

如果攻击者能够影响元素数组的长度,则上述代码将导致拒绝服务(执行无法跳出循环)。 而在扫描的智能合约中发现有8%的合约存在数组长度操纵问题。

3. 权力过大的所有者

这是Soldiity十大安全问题新出现的问题,该问题影响了约16%的合约,某些合约与其所有者(Owner)紧密相关,某些函数只能由所有者地址调用, 如下例所示:

只有合约所有者能够调用doSomething()和doSomethingElse()函数:前者使用onlyOwner修饰器, 而后者则显式执行该修饰器。这带来了严重的风险:如果所有者的私钥遭到泄露, 则攻击者可以控制该合约。

4. 算术精度问题

由于使用256位虚拟机(EVM),Solidity的数据类型有些复杂。 Solidity 不提供浮点运算, 并且少于32个字节的数据类型将被打包到同一个32字节的槽位中。考虑到这一点,你应该预见以下程序精度问题:

function calculateBonus(uint amount) returns (uint) {
  return amount/DELIMITER * BONUS;
}

如上例所示,在乘法之前执行的除法,可能会有巨大的舍入误差。

5. 依赖 tx.origin

智能合约不应依赖于tx.origin进行身份验证,因为恶意合约可能会进行中间人攻击,耗尽所有资金。 建议改用msg.sender:

function transferTo(address dest, uint amount) {
    require(tx.origin == owner) {
       dest.transfer(amount);
    }
}

可以在Solidity的文档中找到 Tx Origin攻击的详细说明 。简单的说,tx.origin始终是合约调用链中的最初的发起者帐户,而msg.sender则表示直接调用者。如果链中的最后一个 合约依赖于tx.origin进行身份验证,那么调用链中间环节的合约将能够榨干被调用合约的资金,因为身份验证没有检查究竟是谁(msg.sender)进行了调用。

6. 溢出(Overflow / Underflow)

Solidity的256位虚拟机存在上溢出和下溢出问题(译者注:由于结果超出取值范围称为溢出), 这里有具体的分析。 在for循环条件中使用uint数据类型时,开发人员要格外小心,因为它可能导致无限循环:

for (uint i = border; i >= 0; i--) {
  ans += i;
}

在上面的示例中,当i的值为0时,下一个值为2^256 -1,这使条件始终为true。 开发人员应当尽量使用<、>、!=和==进行比较。

7. 不安全的类型推导

该问题在Solidity十大安全问题排行榜中上升了两位,现在影响到的智能合约比之前多了 17%以上。
Solidity 支持类型推导,但有一些奇怪的表现。例如,字面量0会被推断为byte类型, 而不是通常期望的整型。
在下面的示例中,i的类型被推断为uint8,因为这时能够存储i的值 uint8 就足够。但如果elements数组包含256个以上的元素,则下面的代码就会发生溢出:

for (var i = 0; i < elements.length; i++) {
   // to something 
}

建议明确声明数据类型,以避免意外的行为和/或错误。
译者注: 在 Solidity 0.6 已经移除了var 定义变量( Solidity 0.6之后不再有类型推导了),如果使用新的编译器,将不是问题。

8. 不正确的转账

此问题在Solidity十大安全问题榜单中从第六位下降到第八位,目前影响不到1%的智能合约。
在合约之间进行以太币转账有多种方法。虽然官方推荐使用addr.transfer(x)函数,但我们仍然找到了还在使用send()函数的智能合约:

if(!addr.send(1)) {
    revert()
}

请注意,如果转账不成功,则addr.transfer(x)会自动引发异常,同样减轻第一个未检查外部调用的问题

9. 循环内转帐

当在循环体中进行以太币转账时,如果其中一个转账失败(例如,一个合约不能接收),那么整个交易将被回滚。

for (uint i = 0; i < users.lenghth; i++) {
   users[i].transfer(amount);
}

在这个例子中,攻击者可能利用此行为来进行拒绝服务攻击,从而阻止其他用户接收以太币。

10. 时间戳依赖

在2018年,时间戳依赖问题排名第五,重要的是要记住,智能合约在不同时刻多个节点上运行的。以太坊虚拟机(EVM)不提供时钟时间,并且通常用于获取时间戳的now变量(block.timestamp的别名)实际上是矿工可以操纵的环境变量。

if (timeHasCome == block.timestamp) {
    winner.transfer(amount);
}

由于矿工可以操纵当前的环境变量,因此只能在不等式>、<、>=和<=中使用其值。

如果你的应用需要随机性,可以参考RANDAO合约, 该合约基于任何人都可以参与的去中心化自治组织(DAO),是所有参与者共同生成的随机数。

总结

比较2018年和2020年十大常见问题时,我们可以观察到开发最佳实践的一些进展,尤其是那些影响安全性的实践。 看到2018年排名前2位的问题:外部合约拒绝服务和重入,已经不再榜单了,这是一个积极的信号,但仍然需要采取措施来避免这类常见错误。

请记住,智能合约在设计上是不可变的,这意味着一旦创建,就无法修补源代码。 这对安全性构成了巨大挑战,开发人员应利用可用的安全测试工具来确保在部署之前对源代码进行了充分的测试和审核。

链接:https://securityboulevard.com/2020/05/solidity-top-10-common-issues/
转载:https://learnblockchain.cn/article/1218

Solidity delegatecall 的使用和误区

Solidity delegatecall (委托调用)是一个低级别的函数,其强大但棘手,如果使用得当,可以帮助我们创建 可扩展 的智能合约,帮助我们修复漏洞,并为现有的智能合约增加新的功能

Solidity delegatecall (委托调用)是一个低级别的函数,它允许我们在主合约的上下文的情况下加载和调用另一个合约的代码。这意味着被调用合约的代码被执行,但被调用合约所做的任何状态改变实际上是在主合约的存储中进行的,而不是在被调用合约的存储中。
这对创建库和代理合约模式很有用,我们把调用委托给不同的合约,"给它们"权限来修改调用合约的状态。
这个功能也有一些我们需要注意的隐患,基本上是本文要重点关注的内容。
正如另一篇关于存储中状态变量布局的文章Solidity 文档 中解释的那样,合约中声明的每个状态变量都在存储中占据一个槽,如果它们的类型小于32字节,并且可以一起放入一个槽中,则可能与其他状态变量共享一个公共槽。

所以,当我们访问这些状态变量,给它们赋值或从它们那里读取时,Solidity 用该状态变量的声明位置来知道访问哪个存储槽并从它那里读取或更新它。

例如,给定以下合约:

contract EntryPointContract {
  address public owner = msg.sender;
  uint256 public id = 5;
  uint256 public updatedAt = block.timestamp;
}

我们看到它声明了3个状态变量,owner,id和updatedAt。这些状态变量有赋值,在存储中,它们看起来像这样:

我们看到,在索引0 存储槽处,我们有第一个状态变量的值使用零填充,因为每个槽可以容纳32个字节的数据。
第二个槽,索引为1,保存了 "id"状态变量的值。
第三个槽,索引为2,有第三个状态变量updatedAt的值。所有存储的数据都以十六进制表示,所以转换 0x62fc3adb到十进制是1660697307,用js转换为日期:

const date = new Date(1660697307 * 1000);
console.log(date)

结果:

Tue Aug 16 2022 20:48:27 GMT-0400 (Atlantic Standard Time))

所以,在访问状态变量id时,我们是在访问索引为1的槽。
很好,那么,使用delegatecall的陷阱在哪里?
为了让委托合约对主合约的存储进行修改,它同样需要声明自己的变量,其顺序与主合约的声明顺序完全相同,而且通常有相同数量的状态变量。
例如,上面的 EntryPointContract 的委托合约,需要看起来是这样的:

contract DelegateContract {
  address public owner;
  uint256 public id;
  uint256 public updatedAt;
}

有完全相同的状态变量,完全相同的类型,完全相同的顺序,最好有完全相同数量的状态变量。在此案例中,每个合约有3个状态变量。
让我们展示一下这两个合约:

contract DelegateContract {
  address public owner;
  uint256 public id;
  uint256 public updatedAt;

  function setValues(uint256 _newId) public {
    id = _newId;
  }

}

contract EntryPointContract {
  address public owner = msg.sender;
  uint256 public id = 5;
  uint256 public updatedAt = block.timestamp;
  address delegateContract;

  constructor(address _delegateContract) {
    delegateContract = _delegateContract;
  }

  function delegate(uint256 _newId) public returns(bool) {
    (bool success, ) =
    delegateContract.delegatecall(abi.encodeWithSignature("setValues(uint256)",
      _newId));
    return success;
  }

}

这里我们看到了一个真正简单的代理合约的实现。EntryPointContract有一个构造函数,接收部署的DelegateContract的地址来委托它的调用,以便自己的状态被DelegateContract修改。

该delegate函数收到一个要设置的_newId,所以它使用低级别的delegatecall将该调用委托给DelegateContract 来更新id变量。

在用新的id值调用delegate函数,并检查EntryPointContract和DelegateContract合约的变量id值后,我们看到只有EntryPointContract的状态变量id有值,而DelegateContract的id状态变量没有赋值,仍然被设置为0,因为DelegateContract修改的不是它自己的存储,而是EntryPointContract的存储。

很好!

在第7行,我们看到id = _newId,但是,虽然听起来很奇怪,它并没有修改EntryPointContract的id变量,却实际上修改了EntryPointContract的存储槽, 我们知道EntryPointContract中的id变量被声明在索引为1的槽中,如上图所示。

这可能会引起混淆,因为我们实际上看到代码正在给DelegateContract中的id变量赋值,你可能认为不管这个变量在EntryPointContract或DelegateContract中的位置在哪里,它仍然会修改EntryPointContract中的id状态变量槽。但是不是这样的。

例如,在下面的合约中,我在DelegateContract中声明了id状态变量的第三个位置,这意味着现在它指向索引为2的槽,而不管EntryPointContract中的id 状态变量名。

contract DelegateContract {
  address public owner;
  // 注意:两个变量换了位置
  uint256 public updatedAt;
  uint256 public id;

  function setValues(uint256 _newId) public {
    id = _newId;
  }

}

contract EntryPointContract {
  address public owner = msg.sender;
  uint256 public id = 5;
  uint256 public updatedAt = block.timestamp;
  address delegateContract;

  constructor(address _delegateContract) {
    delegateContract = _delegateContract;
  }

  function delegate(uint256 _newId) public returns(bool) {
    (bool success, ) =
    delegateContract.delegatecall(abi.encodeWithSignature("setValues(uint256)",
      _newId));
    return success;
  }

}

现在 ,如果我用一个新的id值15再次调用delegate,会发生什么?
让我们看看...
DelegateContract被部署在:0x2eD309e2aBC21e6036584dD748d051c0a6E03709
我们可以用Remix来分析它:

EntryPointContract被部署在: 0x172443F1D272BB9f6d03C35Ecf42A96041FabB09
我们可以用Remix检查它的值:

很好!

现在让我们 用参数 15调用delegate,看看会发生什么。
检查一下DelegateContract的状态变量值:

没有变化,正如预期的那样,因为它不应该改变自己的状态,因为它被委托了EntryPointContract的状态。

让我们检查一下EntryPointContract的状态变量值(记住,我们希望id现在是15,其他都保持不变)。

哦哦! EntryPointContract的id仍然是5,实际受到影响的状态变量是updatedAt。为什么?

正如我在上面解释的,DelegateContract实际上不是通过名字来修改状态变量,而是通过它们在存储中的声明位置。

我们知道,id状态变量在EntryPointContract中被声明在第二位,这意味着它将在存储中占据索引为1的槽。updatedAt在EntryPointContract中被声明为第三位,因此占据了索引为2的存储槽。但是我们看到,DelegateContract将id变量声明为第三位,而将updatedAt声明为第二位。所以,当DelegateContract试图修改id时,它实际上是在修改EntryPointContract存储槽的索引2,也就是updatedAt状态变量在EntryPointContract中的位置。这就是为什么我们看到updatedAt是被更新的,而不是id。

让我们来详细说明一下:

EntryPointContract存储显示了声明的状态变量的顺序和它们的值。

EntryPointContract存储“发送到”(委托的)DelegateContract,按照DelegateContract中声明的顺序显示状态变量,但按照EntryPointContract状态变量的声明顺序显示数值:

所以,我们清楚地看到,在DelegateContract中,id变量实际上是指向EntryPointContract存储中的updatedAt值,而DelegateContract的updatedAt值实际上是指向id变量在EntryPointContract存储中有其值的槽。

所以,这就是为什么我们在委托调用另一个合约时需要非常小心的原因,因为拥有相同的变量类型和名称并不能确保调用合约中的这些变量会被使用。它们需要在两个合约中以相同的顺序声明。

另一个有趣的事实是,委托合约可以比主合约有更多的状态变量,有效地将值添加到主存储区,但它不能直接访问,因为主合约没有一个变量指向该存储区。

让我们看看这些合约,以便更清楚理解:

 contract DelegateContract {
   address public owner;
   uint256 public id;
   uint256 public updatedAt;
   address public addressPlaceholder;
   uint256 public unreachableValueByTheMainContract;

   function setValues(uint256 _newId) public {
     id = _newId;
     unreachableValueByTheMainContract = 8;
   }
 }

 contract EntryPointContract {
   address public owner = msg.sender;
   uint256 public id = 5;
   uint256 public updatedAt = block.timestamp;
   address public delegateContract;

   constructor(address _delegateContract) {
     delegateContract = _delegateContract;
   }

   function delegate(uint256 _newId) public returns(bool) {
     (bool success, ) =
     delegateContract.delegatecall(abi.encodeWithSignature("setValues(uint256)",
       _newId));
     return success;
   }

 }

我们看到,EntryPointContract仍然声明了4个状态变量,而DelegateContract声明了5个。我们知道,当EntryPointContract委托调用DelegateContract时,它将把自己的存储发送到DelegateContract.,但是EntryPointContract没有第五个状态变量(unreachableValueByTheMainContract)。那么,当DelegateContract修改它声明的但EntryPointContract没有声明的第五个变量时会发生什么?

嗯,它实际上会修改EntryPointContract存储的槽索引4(第五个位置)。EntryPointContract将不能直接访问它,因为该槽没有对应声明的状态变量,但该值将在那里,我们可以用web3.eth.getStorageAt(entryPointContractAddress, 4)这样的方法来访问它。

EntryPointContract被部署在0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409,我们看到它的值:

现在让我们调用delegate,其值为18:

棒极了! 但是设置为unreachableValueByTheMainContract的值8在哪里呢?让我们看看它是否在 DelegateContract 状态下。

可以看到,它没有值。因为DelegateContract没有修改自己的状态,即使状态变量没有在EntryPointContract中声明。但由于unreachableValueByMainContract状态变量被声明在第五个位置(存储槽索引4),那么它无论如何都会影响EntryPointContract索引4的存储槽。我们可以直接检查它的值:

web3.eth.getStorageAt("0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409", 4)

返回:

0x0000000000000000000000000000000000000000000000000000000000000008

是的! 说明EntryPointContract 确实保存了这个数据。

这是一种有趣的方式,即智能合约可以在部署后被 "扩展",只需在第一时间将其行动委托给另一个合约。这需要精心制作和设计。委托合约的地址需要能够在需要时被动态替换,这样入口点合约就可以在任何时候指向一个新的实现。

有一些方法可以解决这个问题,其中之一就是EIP-1967: Standard Proxy Storage Slots

结论

delegatecall是一个强大但棘手的功能,如果使用得当,我们可以创建 可扩展 的智能合约,帮助我们修复漏洞,并为现有的智能合约增加新的功能,使其动态地将其行动委托给另一个合约并由其修改自己的状态。

我们需要牢记代理合约和执行合约中的状态变量的顺序,以避免对存储数据进行非预期的修改。

转载自:https://learnblockchain.cn/article/5372