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

无需gas代币和ERC20-Permit还任重而道远

ERC20-Permit(EIP-2612)下,如何避免 使用进行两步交易:授权+ transferFrom!

快进至EIP-2612

从那时起,DAI和Uniswap一直在朝着名为EIP-2612的新标准的方向发展,该标准可以取消 approve + transferFrom,同时还允许无 gas 通证转账。 DAI是第一个为其ERC-20通证添加新的permit功能的公司。它允许用户在链下签署授权的交易,生成任何人都可以使用并提交给区块链的签名。这是解决gas 支付问题的基本的第一步,并且消除了用户不友好的两步过程:发送approve和之后的 transferFrom。

让我们详细研究一下EIP。

原始的错误方法

总体而言,该过程非常简单。用户不在发起授权(approve)交易,而是对approve(spender, amount)签名。签名结果可以被任何人传递到 permit函数,在 permit函数我们只需使用 ecrecover来检索签名者地址,接着用 approve(signer,spender,amount)。

这种方式可用于让其他人为交易支付 gas 费用,也可以删除掉常见的授权(approve)+ transferFrom模式:

之前方法:

  • 用户提交token.approve(myContract.address, amount)交易。
  • 等待交易确认。
  • 用户提交第二个 myContract.doSomething()交易,该交易内部使用 token.transferFrom。

现在:

  • 用户进行授权签名:签名信息signature=(myContract.address,amount)。
  • 用户向 myContract.doSomething(signature)提交签名。
  • myContract使用 token.permit增加配额,并调用 token.transferFrom 获取代币。

之前需要两笔交易,现在只需要一笔!

Permit 细节:防止滥用和重播

我们面临的主要问题是签名可能会多次使用或在原本不打算使用的其他地方使用。为防止这种情况,我们添加了几个参数。在底层,我们使用的是已经存在的,广泛使用的EIP-712标准。

1. EIP-712 域哈希(Domain Hash)

使用EIP-712,我们为ERC-20定义了一个域分隔符

bytes32 eip712DomainHash = keccak256(
    abi.encode(
        keccak256(
            "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
        ),
        keccak256(bytes(name())), // ERC-20 Name
        keccak256(bytes("1")),    // Version
        chainid(),
        address(this)
    )
);

这样可以确保仅在正确的链ID上将签名用于我们给定的通证合约地址。chainID是在以太坊经典分叉之后引入(以太坊经典network id 依旧为 1), 用来精确识别在哪一个网络。 可以在此处查看现有chain ID的列表

2. Permit 哈希结构

现在我们可以创建一个Permit的签名:

bytes32 hashStruct = keccak256(
    abi.encode(
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
        owner,
        spender,
        amount,
        nonces[owner],
        deadline
    )
);

此hashStruct将确保签名只能用于

  • Permit 函数
  • 从owner授权
  • 授权spender
  • 授权给定的value (金额)
  • 仅在给定的deadline之前有效
  • 仅对给定的 nonce有效
    nonce可确保某人无法重播签名,即在同一合约上多次使用该签名。

3. 最终哈希

现在我们可以用兼容 EIP-191的712哈希构建(以0x1901开头)最终签名:

bytes32 hash = keccak256(
    abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct)
);

4. 验证签名

在此哈希上,我们可以使用ecrecover 获得该函数的签名者:

address signer = ecrecover(hash, v, r, s);
require(signer == owner, "ERC20Permit: invalid signature");
require(signer != address(0), "ECDSA: invalid signature");

无效的签名将产生一个空地址,这就是最后一次检查的目的。

5. 增加Nonce 和 授权

现在,最后我们只需要增加所有者的Nonce并调用授权函数即可:

nonces[owner]++;
_approve(owner, spender, amount);

你可以在此处看到完整的实现示例。

已有的ERC20-Permit 实现

DAI ERC20-Permit

DAI是最早引入 permit的通证之一,如此处所述。实现与EIP-2612略有不同:

  1. 没有使用 value,而只使用一个bool allowed,并将allowance 设置为0或MAX_UINT256
  2. deadline参数称为expiry

Uniswap ERC20-Permit

Uniswap实现与当前的EIP-2612保持一致,请参见这里。它允许你调用removeLiquidityWithPermit,从而省去了额外的授权步骤。

如果你想体验一下该过程,请转到https://app.uniswap.org/#/pool 并切换到Kovan网络。不用增加资金的流动性。现在尝试将其删除。单击“Approve”后,你会注意到此MetaMask弹出窗口如下图所示。

这不会提交交易,而只会创建具有给定参数的签名。你可以对其进行签名,并在第二步中使用生成的签名调用removeLiquidityWithPermit。总而言之:只需提交一份交易。

ERC20-Permit 代码库

我已经创建了可以导入的ERC-20-Permit代码库。你可以在https://github.com/soliditylabs/ERC20-Permit 中找到它
其使用:

你可以通过npm安装来简单地使用它:

$ npm install @soliditylabs/erc20-permit --save-dev

像这样将其导入到你的ERC-20合约中:

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

import {ERC20, ERC20Permit} from "@soliditylabs/erc20-permit/contracts/ERC20Permit.sol";

contract ERC20PermitToken is ERC20Permit {
    constructor (uint256 initialSupply) ERC20("ERC20Permit-Token", "EPT") {
        _mint(msg.sender, initialSupply);
    }
}

前端使用

你可以在我的测试中代码在这里看到如何使用eth-permit库创建有效的签名。它会自动获取正确的随机数,并根据当前标准设置参数。它还支持DAI样式的许可证签名创建。完整文档可在https://github.com/dmihal/eth-permit 获得

关于调试的一句话:这可能很痛苦。关闭任何单个参数都将导致revert: Invalid signature。祝你好运找出原因。

在撰写本文时,似乎仍然有一个已知 issue,它可能会或也可能不会影响你,具体取决于你的Web3提供程序。如果确实对你有影响,请使用通过patch-package安装这里的补丁

无需gas代币解决方案

现在回想起我在悉尼的经历,单靠这个标准并不能解决问题,但这是解决该问题的第一个基本模块。现在,你可以为其创建加油站网络,例如Open GSN。部署合约,该网络只需通过permit + transferFrom即可进行通证转账。 GSN内部运行的节点将获取许可签名并提交。

谁支付 gas 费?这将取决于特定的场景。也许Dapp公司支付这些费用作为其客户获取成本(CAC)的一部分。也许用转移的通证支付了GSN节点费用。要弄清所有细节,我们还有很长的路要走。

一如既往的小心使用

请注意,该标准尚未最终确定。当前与Uniswap实现相同,但将来可能会有所变化。如果标准再次更改,我将保持库的更新。我的图书馆代码也未经审核,使用后果自负。

本翻译由 Cell Network 赞助支持。
转载自:https://learnblockchain.cn/article/1790

"Stack Too Deep(堆栈太深)" 解决方案

恐怖的三个词

你只需要在合约中添加一个微小的更改即可。你认为这只需要几秒钟。没错,添加代码只花了不到一分钟的时间。你很高兴你快速解决了这个问题,你输入了compile命令。这么小的更改,你确信代码是正确的。

然而,你确看到以下错误消息:

InternalCompilerError:Stack Too Deep, try removing local variables.(堆栈太深,请尝试删除一些局部变量。)
哎哟。这里发生了什么?如果你之前写过智能合约,这很可能是一个非常熟悉的错误消息,并且在不可预测的时间出现。但是通常在你时间紧迫的时候。

不过请放心,这不是你的错。如果你正在为这个错误而苦苦挣扎,那么你不是唯一的一个。

看看最近的调查,您最讨厌Solidity哪个方面:

为什么会出现此错误?

原因是在EVM堆栈中如何引用变量方面存在限制。尽管其中可以包含16个以上的变量,但是一旦尝试引用16或更高槽位中的变量,将失败。因此,并非总是很清楚为什么某些代码会失败,然后进行一些随机更改似乎可以解决问题。

但是我不想介绍太多让你厌倦的理论。这是一篇实用的博客文章。

如何解决

现在到底有什么通用方法可以解决此问题?让我们看一下处理错误的五种方法:

  1. 使用更少的变量
  2. 利用函数
  3. 代码块作用域范围
  4. 利用结构体
  5. 一些黑技巧

好吧,第一个显而易见。如果可以,请尝试重构代码以使用更少的变量。办法很直接,让我们继续前进看看其他 4 个方法。
对于其他四个,我们来看一个堆栈太深的示例代码以及四种修复它的方法。

Stack Too Deep 的例子

让我们看下面的代码。它将抛出困扰我们的堆栈太深的错误消息。我们可以对它可以做些什么呢?

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

contract StackTooDeepTest1 {
    function addUints(
        uint256 a,uint256 b,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
    ) external pure returns(uint256) {

        return a+b+c+d+e+f+g+h+i;
    }
}

1.使用内部函数

是的,使用内部函数将使错误消失。例如,我们可以将其分为三个函数调用,每个函数调用加起来会包含三个uint。神奇的是,堆栈太深的错误会迫使我们编写更好的代码。

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

contract StackTooDeepTest1 {
   function addUints(
        uint256 a,uint256 b,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
    ) external pure returns(uint256) {

        return _addThreeUints(a,b,c) + _addThreeUints(d,e,f) + _addThreeUints(g,h,i);
    }

    function _addThreeUints(uint256 a, uint256 b, uint256 c) private pure returns(uint256) {
        return a+b+c;
    }
}

2.利用块作用域

Uniswap启发,你也可以使用块作用域。只需将大括号括在部分代码中:

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

contract StackTooDeepTest2 {
    function addUints(
        uint256 a,uint256 b,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
    ) external pure returns(uint256) {

        uint256 result = 0;

        {
            result = a+b+c+d+e;
        }

        {
            result = result+f+g+h+i;
        }

        return result;
    }
}

3. 通过传递结构体

这是只使用较少变量的一种方法。将数据放入结构中。出于可读性原因,也是一个好主意。

// SPDX-License-Identifier: MIT
pragma solidity 0.7.1;
pragma experimental ABIEncoderV2;

contract StackTooDeepTest3 {
    struct UintPair {
        uint256 value1;
        uint256 value2;
    }

    function addUints(
        UintPair memory a, UintPair memory b, UintPair memory c, UintPair memory d, uint256 e
    ) external pure returns(uint256) {

        return a.value1+a.value2+b.value1+b.value2+c.value1+c.value2+d.value1+d.value2+e;
    }
}

4.解析msg.data

这种方法的最初想法来自用户Stackexchange的k06a,这需要点黑技巧,所以我通常不建议这样做。但是如果你尝试了所有其他尝试都没有成功?可以尝试一下:

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

contract StackTooDeepTest4 {
    function addUints(
        uint256 /*a*/,uint256 /*b*/,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
    ) external pure returns(uint256) {
      return _fromUint(msg.data)+c+d+e+f+g+h+i;
    }

    function _fromUint(bytes memory data) internal pure returns(uint256 value) {
        uint256 value1;
        uint256 value2;

        assembly {
            value1 := mload(add(data, 36))
            value2 := mload(add(data, 68))
            value  := add(value1, value2)
        }
    }
}

这是如何工作的,就是通过解析msg.data。所有发送到合约的数据都存储此变量,因此我们可以注释掉变量a和b,但仍接收它们的值。 msg.data的前4个字节是函数选择器数据。之后是我们的前两个uint256,每个32位。

使用 msg.data 的方法仅适用于外部函数。一种变通方法是将其与公共函数一起使用, 方法是通过this.myPublicFunction()调用那些公共函数。

也许现在的堆栈对你来说足够了。:)

本翻译由 Cell Network 赞助支持。
转载:https://learnblockchain.cn/article/1629

hardhat config 中optimizer 优化选项

启用optimizer (优化器)后, 优化器会尝试去优化代码,如简化复杂的表达式等,例如常量表达式可能直接优化出计算,以便减少代码大小和执行成本。

runs 很多人有误解,它不是指优化器运行多少次,而是指合约函数未来可能会调用多少次,例如某些锁仓合约只会调用一次,可以将runs值设置为1。 这样编译器会尽可能小的字节码, 减少部署成本(运行时可能会更费 gas)。
如果合约会大量运行,如 token 合约,就可以设为较大的数值,编译器会优化以便在运行时尽可能低 gas 消耗,不过部署时的字节码可能大一些。

深入Solidity数据存储位置

文章较长,内容很详细、很深入。但是不要吓到,坐下来,喝杯咖啡或你最喜欢的饮料,慢慢体会。
我们来探索Solidity的一个新的和必不可少的部分:数据存储位置。具有挑战性的话题。非常底层,因为它与以太坊虚拟机(EVM)的架构有关。
但通过类比,我们总是可以更好地理解复杂的编程概念、架构、智能合约和一般的区块链。
在这篇文章中,我们将通过类比EVM如果是一个 "巨大的工业工厂 "来学习每个数据位置(我希望能帮助你理解)。
然后我们将进入Solidity代码,学习每个数据位置的参考类型的规则和行为。所有这些都有图画、架构图、代码片段以及你可能知道的流行项目的源代码例子来学习。
这个系列还有另外二篇文章分别介绍存储(Storage)及 内存(memory).

为什么要在 Solidity 中理解 Evm 数据位置?

学习每个数据位置是如何工作的,需要学习很多东西,比如 "存储"、"内存 "和 "calldata"的结构和布局,或者 "什么内容可以存储在哪里"。
但最重要的是,它教会了你与它们每一个相关的(Gas)成本,以及我所说的可变性/安全性的权衡。

作为一个Solidity开发者,对EVM中的数据位置以及如何充分使用它们的良好理解将使你能够:

  1. 提高你的智能合约的性能。
  2. 最小化其执行成本(调用其公共或内部函数时使用的Gas差异)。
  3. 强化安全性,防止潜在的错误。

数据位置 → 概述

本文旨在对这些不同的数据位置做一个很好的概述,数据可以被写入和读出。我们将看到,有些位置是只读的,不能写入,而其他位置是可变的,里面存储的值可以被编辑。

EVM有五个主要的数据位置:

  • 存储(Storage)
  • 内存(Memory)
  • 调用数据(Calldata)
  • 堆栈(Stack)
  • 代码(Code)


EVM中可用的数据位置概览,来源:精通以太坊

存储

在以太坊中,每个特定地址的智能合约都有自己的 "存储",由一个键值存储组成,将256位映射到256位。存储中的数据在函数调用和交易之间持续存在。

存储是所有合约状态变量所在的地方。每个合约都有自己的存储。存储中的变量在函数调用之间持续存在。然而,存储空间的使用是相当昂贵的。
由于存储指的是合约存储,它指的是永久存储在区块链上的数据。
你可以从/向合约存储中读取和写入。在低层,用于这样做的EVM操作码是SSTORE和SLOAD。

内存

EVM 内存是用来保存临时值的,在外部函数调用之间被擦除。然而,它的使用成本比较低。

在EVM中,内存是易失性的,是特定合约(环境)的上下文。这意味着,当执行环境从一个合约变为另一个合约时,“白板/写字板”被清除。在每一个新的消息调用中,都会获得一个新的被清除的内存实例。
因此,内存变量是暂时的。它们在对其他合约的外部函数调用之间被擦除。
你可以从/到EVM内存中读取和写入。在低层,用于从/向内存读写的EVM操作码是MLOAD, MSTORE, 和MSTORE8。
某些EVM操作码,如 "CALL"、"DELEGATECALL "或 "STATICCALL" 从EVM内存中消耗其参数。

Calldata

calldata相当于从船上或卡车上取出的一个集装箱。这些集装箱包含送到工厂进行加工的材料。Calldata是只读的。
calldata是交易的数据或外部函数调用的参数所在的位置。它是一个只读的数据位置。你不能写到它。
Calldata的行为主要类似于内存,是一个可由字节编址的空间。你必须为你想读取的字节数指定一个准确的字节偏移。
在低层,可用于从calldata读取的EVM操作码是CALLDATALOAD, CALLDATASIZE和CALLDATACOPY。

堆栈(Stack)

堆栈是用来存放小型局部变量的。它的使用几乎是免费的(用Gas很低),但大小有限,能容纳的项目数量也有限。
堆栈是大多数在函数内部创建的局部变量所在的地方。它是EVM的一个重要部分。

在低层,可以用来对堆栈进行操作的EVM操作码,包括PUSH、POP、SWAP和DUP指令。大多数其他的EVM操作码从堆栈中消耗数据(通过从堆栈中取出),并将结果推回堆栈中。

代码

代码指的是合约的字节码。你只能从合约字节码中读取,而不能写到它。通常是你在Solidity中定义为 constant的变量。大多数的EVM操作码从堆栈中消耗它们的参数。

字节码包含了很多关于合约的信息和逻辑,包括调度器,以及合约元数据。
在低层,从智能合约的代码中读取的EVM操作码是CODESIZE和CODECOPY及操作码EXTERNALCODESIZE和EXTERNALCODECOPY。

数据位置 - 规则

变量的默认位置
Solidity语言定义了一些默认的规则,围绕着一些变量的默认位置,取决于它们被定义的位置。

  • 定义为 constant的变量 = 合约代码(=bytecode)。
    这些变量是不可改变的,一旦合约被部署就不能改变。它们是只读的,可以被内联使用。

  • 状态变量(在函数之外声明) = 默认情况下在存储中。
    这些被称为状态变量,因为它们是合约状态的一部分,反过来也是区块链全局状态的一部分(=以太坊中所有智能合约的状态)。这些变量被永久地写入区块链。

  • 本地变量(在函数体内声明)= 在堆栈中。
    值类型的变量(例如,uint256, bytes8 , address)驻留在堆栈中。

大多数时候,你不需要使用数据位置关键字(storage,memory,或calldata),因为Solidity通过上面解释的默认规则处理数据的位置。

然而,有时你确实需要使用这些关键字并指定数据位置,即在处理复杂类型的变量时,如函数内的结构体和数组。

参考类型

对于数组(固定或动态大小的数组, 如uint256[]), bytes, string, 结构和映射, 你必须明确提供存储值的数据区域. 这可以是storage,memory或calldata。

通过使用这些关键字,你可以创建一个 "引用" 类型的变量。这种类型必须比值类型更仔细地处理。

下一个问题自然就出现了:
什么时候使用关键字存储(storage),内存(memory),和calldata?

你只能在函数中的3个地方指定引用一个变量的数据位置。

  • A) 对于参数(=函数定义)
  • B) 对于函数内部的局部变量(=函数主体)
  • C) 返回值总是在内存中(=函数定义)。

在函数参数上的规则

当storage被用作一个函数参数的引用时,它是一个指向合约存储的指针。
对于memory和calldata也是如此。这样的关键字指向EVM内存中的某个位置或从交易中进来的输入数据(=calldata)的指针。

在函数体内的规则

在函数内部,无论函数的可见性如何,都可以指定所有三个数据位置:

然而,引用类型之间的赋值是受特定规则约束的。(这里是变得复杂和 "略微扭曲舌头的地方!")。

  • storage 引用:总是可以直接从合约存储中(=状态变量)或通过另一个 "存储" 引用 给一些变量赋值,但它们不能赋值一个 "内存 "或 "calldata "引用。
  • memory引用:可以被分赋值任何东西(直接的状态变量,或storage、memory或calldata引用)。它总是创建一个副本。
  • calldata引用:总是可以直接从calldata(= tx/message调用的输入),或通过另一个calldata引用赋值,但它们不能从storage或memory引用赋值。

为了更简单地描述它。

  • 对于memory =总是可以复制内存中的任何数据(无论它来自合约的存储还是calldata)。
  • 对于存储和calldata = 我们只能分配来自指定数据位置的值(无论是直接类型还是通过相同类型的引用)。

让我们看看一些真实的、实用的Solidity例子:

// SPDX-License-Identifier: Apache-2  
pragma solidity ^ 0.8 .0;

contract StorageReferences {

  bytes someData;

  function storageReferences() public {
    bytes storage a = someData;
    bytes memory b;
    bytes calldata c;

    // storage variables can reference storage variables as long as the storage
    reference they refer to is initialized.
    bytes storage d = a;

    // if the storage reference it refers to was not initiliazed, it will lead
    to an error
    // "This variable (refering to a) is of storage pointer type and can be
    accessed without prior assignment,
    // which would lead to undefined behaviour."  
    // basically you cannot create a storage reference that points to another
    storage reference that points to nothing
    // f -> e -> (nothing) ???  
    /// bytes storage e;  
    /// bytes storage f = e;  

    // storage pointers cannot point to memory pointers (whether the memory
    pointer was initialized or not
    /// bytes storage x = b;  
    /// bytes memory r = new bytes(3);  
    /// bytes storage s = r;  

    // storage pointer cannot point to a calldata pointer (whether the calldata
    pointer was initialized or not).
  /// bytes storage y = c;  
  /// bytes calldata m = msg.data;  
  /// bytes storage n = m;  
}

}

// SPDX-License-Identifier: Apache-2  
pragma solidity ^ 0.8 .0;

contract DataLocationsReferences {

  bytes someData;

  function memoryReferences() public {
    bytes storage a = someData;
    bytes memory b;
    bytes calldata c;

    // this is valid. It will copy from storage to memory  
    bytes memory d = a;

    // this is invalid since the storage pointer x is not initialized and does
    not point to anywhere.
    /// bytes storage x;  
    /// bytes memory y = x;  

    // this is valid too. `e` now points to same location in memory than `b`;  
    // if the variable `b` is edited, so will be `e`, as they point to the same
    location
    // same the other way around. If the variable `e` is edited, so will be `b`  
    bytes memory e = b;

    // this is invalid, as here c is a calldata pointer but is uninitialized,
    so pointing to nothing.
    /// bytes memory f = c;  

    // a memory reference can point to a calldata reference as long as the
    calldata reference
    // was initialized and is pointing to somewhere in the calldata.  
    // This simply result in copying the offset in the calldata pointed by the
    variable reference
    // inside the memory  
    bytes calldata g = msg.data[10: ];
    bytes memory h = g;

    // this is valid. It can copy the whole calldata (or a slice of the
    calldata) in memory
  bytes memory i = msg.data;
  bytes memory j = msg.data[4: 16];
}

}

// SPDX-License-Identifier: Apache-2  
pragma solidity ^ 0.8 .0;

contract DataLocationsReferences {

  bytes someData;

  function calldataReferences() public {
    bytes storage a = someData;
    bytes memory b;
    bytes calldata c;

    // for calldata, the same rule than for storage applies.  
    // calldata pointers can only reference to the actual calldata or other
    calldata pointers.
  }
}

你可以在这里找到Solidity合约代码

内存←状态变量

contract MemoryCopy {
  bytes someData;
  constructor() {
    someData = bytes("All About Solidity");
  }

  function copyStorageToMemory() public {

    // assigning memory <\-- storage  
    // this load the value from storage and copy in memory  
    bytes memory value = someData;
    // changes are not propagated down in the contract storage  
    value = bytes("abcd");

  }
}

当我们将一个状态变量赋值给一个内存引用的变量时,基本上是将数据从存储空间→复制到内存。

= 我们正在向内存写入 =新的内存被分配。
这意味着对变量的任何修改都不会影响到合约存储(=合约状态)。

= 合约存储将不会被重写。
在上面的例子中,运行函数后,状态变量someData没有被改变。

状态变量 ←内存

这是前一个例子 "内存←状态变量 "的反例。同样会进行整体拷贝,这个例子可以在Solidity文档部分找到。

存储指针

contract StoragePointer {
  uint256[] someData;
  uint256[] moreData;

  function createStoragePointer() public {

    // pointer to storage  
    uint256[] storage value = someData;
    // pointer to somewhere else in storage  
    value = moreData;
  }
}

当一个storage变量在一个函数中被创建时,这基本上是作为一个存储指针。存储指针简单地引用已经分配到存储空间的数据。
你可以重新分配存储指针,将其指向存储中的其他地方。
= 引用存储中的一些现有值 = 不创建新的存储
然而,我们可以通过直接给查找变量分配一个新的值来覆盖合约存储。看一下这个例子。

contract StoragePointer {
  uint256[] public someData;

  function pushToArrayViaPointer() public {

    uint256[] storage value = someData;
    value.push(1);
  }
}

如果你接着查询someData的第一个索引,你会得到1

内存←存储指针

当我们将一个storage引用的数据分配给一个memory引用的变量时, 我们是在从storage → memory中复制数据。

= 我们是在向内存写数据 = 新的内存被分配。

这等同于我们之前涉及的第一种情况 "内存←状态变量",并通过存储引用增加了 "中间的额外路径"。存储引用将解析到状态变量,然后将其复制到内存。下面是一个建立在前面的代码片段上的基本例子。

contract MemoryCopy {
  uint256[] public someData;

  function copyStorageToMemoryViaRef() public {

    uint256[] storage storageRef = someData;
    uint256[] memory copyFromRef = storageRef;
  }
}

这里copyFromRef是整个数组someData的拷贝。这个数组是通过存储引用storageRef在内存中复制的。

同样在此案例中,由于我们从存储空间复制到内存,我们是在操作数据的副本,而不是在存储空间中的实际数据上。因此,对copyFromRef所做的任何修改都不会影响合约存储,也不会修改合约状态。

为了说明这一点,请在Remix中复制以下合约,然后。

  • 运行函数test()。
  • 在索引1处读取someData数组。
contract MemoryCopy {
  uint256[] public someData;
  constructor() public {
    someData.push(1);
    someData.push(2);
    someData.push(3);
  }

  function doesNotPropagateToStorage() public {
    uint256[] storage storageRef = someData;
    uint256[] memory copyFromRef = storageRef;

    copyFromRef[1] = 12345;
  }
}

你会看到在运行函数后,someData[1]的值仍然是2,而且12345并没有传播回合约存储。

calldata引用

Calldata引用的行为与storage引用相同。它只能作为交易数据的引用,或者作为用calldata关键字提供的复杂类型的函数参数的引用。

简而言之,一个calldata类型的变量总是创建一个引用。
唯一的主要区别是,作为calldata引用的变量不能被修改,因为calldata是只读的。
这与storage引用恰恰相反。当你给storage引用分配一个新的值时,这个变化会传播到合约状态。

数据位置 - 行为

本节借鉴了Forest Fang 的文章
我强烈推荐你阅读它! 我曾用它来分析更多的底层EVM操作代码,以了解在背后发生了什么。

Solidity文档中提到了以下内容:
"数据位置不仅与数据的持久性有关,而且还与赋值的语义有关"。

在指定函数体内部的数据位置时,必须考虑两个主要问题:效果和Gas消耗。

让我们用一个简单的合约作为例子来更好地理解。这个合约在存储中持有一个结构体的映射。为了比较每个数据位置的行为,我们将使用不同的函数,使用不同的数据位置关键字。

  • 使用存储 "storage"的getter。
  • 使用内存 "memory"的getter。
  • 使用存储 "storage"的setter。
  • 使用内存 "memory"的setter。
// SPDX-License-Identifier: Apache-2.0  
pragma solidity ^ 0.8 .0;

contract Garage {
  struct Item {
    uint256 units;
  }
  mapping(uint256 => Item) items;

  // gas (view): 24,025  
  function getItemUnitsStorage(uint _itemIndex) public view returns(uint) {
    Item storage item = items[_itemIndex];
    return item.units;
  }

  // gas (view): 24,055  
  function getItemUnitsMemory(uint _itemIndex) public view returns(uint) {
    Item memory item = items[_itemIndex];
    return item.units;
  }

  // gas: 50,755 (1st storage write)  
  function setItemUnitsStorage(uint256 _id, uint256 _units) public {
    Item storage item = items[_id];
    item.units = _units;
  }

  // gas: 27,871  
  function setItemUnitsMemory(uint256 _id, uint256 _units) public {
    Item memory item = items[_id];
    item.units = _units;
  }
}

使用存储的Getter 与 内存的Getter 对比

我们要问自己的第一个问题是:使用内存的getter还是使用存储的getter更便宜?它们各自是如何工作的?

// gas (view): 24,025  
function getItemUnitsStorage(uint _itemIndex) public view returns(uint) {
  Item storage item = items[_itemIndex];
  return item.units;
}

// gas (view): 24,055  
function getItemUnitsMemory(uint _itemIndex) public view returns(uint) {
  Item memory item = items[_itemIndex];
  return item.units;
}

使用 "storage "的getter比使用 "memory "的getter稍微便宜一些,因为它充当了一个存储指针。
使用memory的getter更贵,花费更多的Gas,因为要创建一个新的变量。在上面的例子中,除了读取存储映射items[_itemIndex]里面的值之外,它还会复制内存中的值。
让我们确保理解这里到底发生了什么。根据关键字 "storage "或 "memory",EVM在幕后做了什么?
我在下面列出了两种获取器类型的操作码序列(为了清晰和简洁,左边没有写程序计数器)。你可以通过在Remix中调试代码来查看它们。
如果我们比较两个getter函数的操作码,我们会发现,与使用内存的getter(45条指令)相比,使用存储的getter包含更少的操作码(30条指令)。
建议:我强烈建议使用evm.codes网站来测试这两个函数,并分析操作码和存储/内存中一个又一个操作码的变化。

; getItemUnitsStorage = 30 instructions
PUSH1 00   ; 1) manipulate + prepare the stack
DUP1
PUSH1 00   
DUP1
DUP5       
DUP2
MSTORE     ; 2.1) prepare the memory for hashing (1)
PUSH1 20   
ADD    
SWAP1
DUP2 
MSTORE     ; 2.2) prepare the memory for hashing (2)
PUSH1 20
ADD
PUSH1 00
SHA3       ; 3) compute the storage number to load via hashing
SWAP1
POP
DUP1
PUSH1 00
ADD
SLOAD      ; 4) load mapping value from storage
SWAP2    
POP
POP
SWAP2
SWAP1
POP
JUMP
JUMPDEST
; getItemUnitsMemory = 47 instructions
PUSH1 00
DUP1
PUSH1 00
DUP1
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
PUSH1 40  ; <------ additional opcodes start here
MLOAD     ; 1) load the free memory pointer
DUP1      ; 2) reserve the free memory pointer by duplicating it
PUSH1 20
ADD       ; 3) compute the new free memory pointer
PUSH1 40
MSTORE    ; 4) store the new free memory pointer
SWAP1
DUP2
PUSH1 00
DUP3
ADD
SLOAD     ; 5) load mapping value from storage
DUP2
MSTORE    ; 6) store mapping value retrieved from storage in memory
POP
POP ; <------------ additional opcodes end here
SWAP1
POP
DUP1
PUSH1 00
ADD
MLOAD
SWAP2
POP
POP
SWAP2
SWAP1
POP

在底层,EVM对一个使用storage的getter执行以下步骤。

  1. 操作 + 准备堆栈
  2. 为 hash准备内存((1)和(2))。
  3. 计算要通过hash和SHA3加载的值的存储槽(=来自映射的值在哪个存储槽。见我的文章 关于映射,以更好地理解如何计算/计算映射的存储槽)。
  4. 通过SLOAD从存储空间加载值。

你可以在这里看到存储版本的getter函数堆栈的所有细节说明 。
然后出现的下一个问题是: 为什么内存的getter函数底层字节码指令中多了17条?
答案就在上面的汇编代码的描述注释中。这17条额外的EVM指令执行以下内容:
它在内存中预留了一些空间用于存储数值,方法是:

  1. 加载空闲内存指针,
  2. 预留,
  3. 计算内存中下一个空闲空间,
  4. 更新新的空闲内存指针。(mload + mstore)
  5. 一旦数值通过SLOAD从合约存储中加载,然后通过MSTORE写入内存。

你可以在这里看到内存版的getter堆栈的所有细节的说明。

使用storage 与memory的 Setter 函数对比

我推荐来吃Rob Hitchens这篇文章"Solidity中的存储指针:'这里有龙'"
以更好地理解使用setter函数的行为:

// gas: 50,755 (1st storage write)  
function setItemUnitsStorage(uint256 _id, uint256 _units) public {
  Item storage item = items[_id];
  item.units = _units;
}

// gas: 27,871  
function setItemUnitsMemory(uint256 _id, uint256 _units) public {
  Item memory item = items[_id];
  item.units = _units;
}

当为一个变量指定storage并设置其值时,它将修改状态变量并覆盖合约存储。这将使用SSTORE操作码。

相反,如果使用关键字memory,它将仅仅覆盖本地变量,而不覆盖合约存储。这将使用MSTORE操作码。

当涉及到EVM指令集时,我们可以从下面的片段中看到,使用storage产生的指令要少很多(storage产生28条,而memory产生48条)。然而,使用storage将花费更多的Gas,因为存储读/写是EVM中最昂贵的操作。

; setItemUnitsStorage = 28 instructions

PUSH1 00
DUP1
PUSH1 00
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
SWAP1
POP
DUP2
DUP2
PUSH1 00
ADD
DUP2
SWAP1
SSTORE
POP
POP
POP
POP

; setItemUnitsMemory = 46 instructions

PUSH1 00
DUP1
PUSH1 00
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
PUSH1 40
MLOAD
DUP1
PUSH1 20
ADD
PUSH1 40
MSTORE
SWAP1
DUP2
PUSH1 00
DUP3
ADD
SLOAD
DUP2
MSTORE
POP
POP
SWAP1
POP
DUP2
DUP2
PUSH1 00
ADD
DUP2
DUP2
MSTORE
POP
POP
POP
POP
POP

映射的(边缘)情况

映射可以作为参数传递给函数或定义在函数体内。然而,它们是一个边缘案例,有两个特定的规则:

  • 它们只能被分配到storage数据位置,作为对已经存在于合约存储中的映射的引用。
  • 它们必须被初始化为一个值。

这是因为映射不能被动态地创建。它们只能被定义在函数体内,作为对已经作为状态变量存在的映射的引用。

如果你在Remix中这样写...

这就是你将得到的错误。

结论

你应该使用storage, memory, 还是calldata取决于你在合约中试图做什么。
对于某些数据类型,如果数据很大,把它们从存储空间复制到内存中可能会很昂贵。
然而,在某些情况下,如果你想在函数中覆盖变量,而不把结果传播到合约存储中,这可能是必要的。
另一方面,calldata提供了比内存更多的好处:

  1. 节省Gas=通过避免将数据复制到内存中(从而在之后不得不从内存中加载)。
  2. 更安全(在某些情况下) = 因为calldata是一个只读的数据位置,它可以确保数据不能被修改。这可能会增加安全性,特别是当传递给函数的参数代表 敏感 数据(例如64字节的签名)时。

calldata在 Solidity 文档中确实被推荐:

然而,在某些情况下,使用memory而不是calldata可以提高可组合性
最后,请注意,在你的函数中不使用适当的数据位置会导致潜在的错误和漏洞。一个现实世界的例子是对Cover协议的无限铸币攻击。
看看这个来自Cover协议的Blacksmith.sol合约的代码片断:

对池变量所做的任何改变都是在内存中进行的,而不是传回合约存储。
关于这个错误的更多细节,请看Mudti Gupta的博文:https://mudit.blog/cover-protocol-hack-analysis-tokens-minted-exploit/

参考文献

Solidity中的数据表示
以太坊 Solidity: 内存与存储以及在本地函数中使用哪一种?
深入以太坊, Part 2
类型 - Solidity 0.8.15 文档
All-About-Solidity/Data-Locations.md
EVMillustrated
以太坊虚拟机
深入EVM
cover 分析

本翻译由 Duet Protocol 赞助支持。
转载自:https://learnblockchain.cn/article/4864

深入理解EVM操作码,让你写出更好的智能合约

你的一些编程“好习惯”反而会让你写出低效的智能合约。对于普通编程语言而言,计算机做运算和改变程序的状态顶多只是费点电或者费点时间,但对于 EVM 兼容类的编程语言(例如 Solidity 和 Vyper),执行这些操作都是费钱的!这些花费的形式是区块链的原生货币(如以太坊的 ETH,Avalanche 的 AVAX 等等...),想象成你是在用原生货币购买计算资源。

用于购买计算、状态转移还有存储空间的开销被称做 燃料(下文统称 gas )。 gas 的作用是确定交易的优先级, 同时形成一种能抵御【女巫攻击】(Sybil resistance)的机制 ,而且还能防止【停机问题】(halting problem)引起的攻击。

欢迎阅读我的文章 Solidity 基础 去了解 gas 的方方面面

这些非典型的开销导致经典的软件设计模式在合约编程语言中看起来既低效又奇怪。如果想要识别这些模式并理解他们导致效率变高/低的原因,你必须首先对以太坊虚拟机(即 EVM)有一个基本的了解。

什么是EVM?

如果你已经熟悉 EVM,请随时跳到下个部分: 什么是 EVM 操作码?

任何一个区块链都是一个基于交易的 状态机。 区块链递增地执行交易,交易完成后就变成新状态。因此,区块链上的每笔交易都是一次状态转换。

简单的区块链,如比特币,本身只支持简单的交易传输。相比之下,可以运行智能合约的链,如以太坊,实现了两种类型的账户,即外部账户和智能合约账户,所以支持复杂的逻辑。

外部账户由用户通过私钥控制,不包含代码;而只能合约账户仅受其关联的代码控制。EVM 代码以字节码的形式存储在虚拟 ROM 中。

EVM 负责区块链上所有交易的执行和处理。它是一个栈机器,栈上的每个元素长度都是 256 位或 32 字节。EVM 嵌在每个以太坊节点中,负责执行合约的字节码。

EVM 把数据保存在 存储(Storage) 和 内存(Memory) 中。存储(Storage)用于永久存储数据,而内存(Memory)仅在函数调用期间保存数据。还有一个地方保存了函数参数,叫做调用数据(calldata),这种存储方式有点像内存,不同的是不可以修改这类数据。

在 Preethi Kasireddy 的文章中了解有关以太坊和 EVM 的更多信息 Ethereum 是如何工作的?。

智能合约是用高级语言编写的,例如 Solidity、Vyper 或 Yul,随后通过编译器编译成 EVM 字节码。但是,有时直接在代码中使用字节码会更高效(省gas)。

EVM 字节码以十六进制编写。它是一种虚拟机能够解释的语言。这有点像 CPU 只能解释机器代码。

什么是 EVM 操作码?

所有以太坊字节码都可以分解为一系列操作数和操作码。操作码是一些预定义的操作指令,EVM 识别后能够执行这个操作。例如,ADD 操作码在 EVM 字节码中表示为 0x01。它从栈中删除两个元素并把结果压入栈中。

从堆栈中移除和压入堆栈的元素数量取决于操作码。例如,PUSH 操作码有 32 个:PUSH1 到 PUSH32。 PUSH 在栈上 添加一个 字节元素,元素的大小可以从 0 到 32 字节。它不会从栈中删除元素。作为对比, 操作码 ADDMOD 表示 [模加法运算](https://libraryguides.centennialcollege.ca/c.php?g=717548&p=5121840#:~:text=Properties of addition in modular,%2B d ( mod N ) .) ,它从栈中删除3个元素然后压入模加结果。请注意,PUSH 操作码是唯一带有操作数的操作码。

每个操作码都占一个字节,并且操作成本有大有小。操作码的操作成本是固定的或由公式算出来。例如,ADD 操作码固定需要3 gas。而将数据保存在存储中的操作码 SSTORE ,当把值从0设置为非0时消耗 20,000 gas,当把值改为0或保持为0不变时消耗 5000 gas。

SSTORE 的开销实际上会其他变化,具体取决于是否已访问过这个值。可以在这里找到有关 SSTORE 和 SLOAD 开销的完整详细信息:

为什么了解 EVM 操作码很重要?

想要降低 gas 开销,了解 EVM 操作码极其重要,这也会降低你的终端用户的成本。由于不同的 EVM 操作码的成本是不同的,因此虽然实现了相同结果,但不同的编码方式可能会导致更高的开销。了解哪些操作码是比较昂贵的,可以帮助你最大程度地减少甚至避免使用它们。你可以查看 以太坊文档 以获取 EVM 操作码及其相关 gas 开销的列表。

下面是一些考虑了 EVM 操作码开销的反直觉设计模式的具体示例:
用乘法而不是指数: MUL vs EXP
MUL 操作码花费 5 gas 用于执行乘法。例如,10 * 10 背后的算术将花费 5 gas。

EXP 操作码用于求幂,其 gas 消耗由公式决定:如果指数为零,则消耗10 gas。但是,如果指数大于零,则需要 10 gas 加上指数字节数的 50 倍。

一个字节是 8 位,一个字节可以表示 0 到 2⁸-1 之间的值(即0-255),两个字节可以表示 2⁸ 到 2¹⁶-1 之间的值,以此类推。因此,例如求 10¹⁸ 将花费 10 + 50 1 = 60 gas,而求 10³⁰⁰ 将花费 10 + 50 2 = 160 gas,因为来表示 18 需要一个字节,表示 300 需要两个字节。

从上面可以清楚地看出,在某些时候你应该使用乘法而不是求幂。下面一个具体的例子:

contract squareExample {
uint256 x;
constructor (uint256 _x) {
   x = _x;
 }
function inefficcientSquare() external {
   x = x**2;
 }
function efficcientSquare() external {
     x = x * x;
 }
}

inefficientSquare 和 eficcientSquare 两个方法都把状态变量 x 改为 x 的平方。然而,inefficientSquare 的算术开销为 10 + 1 * 50 = 60 gas,而 efficientSquare 的算术开销为 5 gas。

由于上述算术开销之外的原因,inefficientSquare 的 gas 费用平均比 efficientSquare 多 200 左右。

缓存数据:SLOAD & MLOAD

众所周知,缓存数据可以大规模地提升更好的性能。同样,在 EVM 上使用缓存也极端重要,即使只有少量操作,也会明显节省 gas。

SLOAD 和 MLOAD 两个操作码用于从存储和内存中加载数据。MLOAD 成本固定 3 gas,而 SLOAD 的成本由一个公式决定:SLOAD 在交易过程中第一次访问一个值需要花费 2100 gas,之后每次访问需要花费 100 gas。这意味着从内存加载数据比从存储加载数据便宜 97% 以上。

下面是一些节省潜在 gas 的示例代码:

contract storageExample {
uint256 sumOfArray;
function inefficcientSum(uint256 [] memory _array) public {
        for(uint256 i; i < _array.length; i++) {
            sumOfArray += _array[i];
        }
} 
function efficcientSum(uint256 [] memory _array) public {

   uint256 tempVar;
   for(uint256 i; i < _array.length; i++) {
            tempVar += _array[i];
        }
   sumOfArray = tempVar;
} 
}

合约 storageExample 有两个函数: inefficientSum 和 efficientSum

这两个函数都将 _array 作为参数,这是一个无符号整型数组。他们都会把合约的状态变量 sumOfArray 设置为 _array 中所有元素的总和。

inefficcientSum 使用状态变量进行计算。请牢记,状态变量(例如 sumOfArray)保存在 存储 中。

efficcientSum 在内存中创建一个临时变量 tempVar,用于计算 _array 中值的总和。然后将 tempVar 赋值给 sumOfArray。

当传入的数组仅包含 10 个无符号整数时, efficientSum的 gas 效率比 inefficcientSum 高 50% 以上。

它们的效率随着计算次数的增加而增加:当传入 100 个无符号整数的数组时,eficcientSum 比 inefficcientSum 的 gas 效率高 300% 以上。

避免使用面向对象编程模型:CREATE 操作码

CREATE 操作码用于创建包含关联代码的新帐户(即智能合约)。它花费至少32,000 gas,是 EVM 上最昂贵的操作码。

最好尽可能减少使用的智能合约数量。这与典型的面向对象编程不同,在典型的面向对象编程中,为了可复用性和清晰性,鼓励定义多个类。

这是一个具体的例子:

下面是一段使用面向对象方法创建“vault”的代码。每个“vault”都包含一个 uint256 变量,并在构造函数中初始化:

contract Vault {
    uint256 private x; 
    constructor(uint256 _x) { x = _x;}
    function getValue() external view returns (uint256) {return x;}
}
//  Vault 结束
interface IVault {
    function getValue() external view returns (uint256);
} // IVault 结束

contract InefficcientVaults {
    address[] public factory;
    constructor() {}

    function createVault(uint256 _x) external {
        address _vaultAddress = address(new Vault(_x)); 
        factory.push(_vaultAddress);
    }

    function getVaultValue(uint256 vaultId) external view returns (uint256) {
        address _vaultAddress = factory[vaultId];
        IVault _vault = IVault(_vaultAddress);
        return _vault.getValue();
    }
} // InefficcientVaults 结束

每次调用 createVault() 时,都会创建一个新的 Vault 智能合约。存储在 Vault 中的值由传递给 createVault() 的参数决定。然后将新合约的地址存储在数组 factory 中。

这是另一段实现相同功能的代码,但用映射代替了创建:

contract EfficcientVaults {
  // 映射:vaultId => vaultValue
  mapping (uint256 => uint256) public vaultIdToVaultValue;

  // 下一个 vault 的 id
  uint256 nextVaultId;

  function createVault(uint256 _x) external {
      vaultIdToVaultValue[nextVaultId] = _x;
      nextVaultId++;
  }

  function getVaultValue(uint256 vaultId) external view returns (uint256) {
      return vaultIdToVaultValue[vaultId];
  }
} // EfficcientVaults 结束

每次调用 createVault() 时,参数都存储在一个映射中, 映射的 ID 由状态变量 nextVaultId 确定,而 nextVaultId 在每次调用 createVault() 时递增。

这种实现上的差异导致 gas 成本大幅降低。

EfficcientVaults 的 createVault() 与 IneficcientVaults 相比,效率提高了 61%,消耗的 gas 减少了约 76,300。

应该注意的是,在某些情况下在合约中创建新合约是可取的,并且通常是为了不可变性和效率。随着合约的大小增加,与合约的所有交互的交易成本也将增加。 因此,如果你希望在链上存储大量数据,最好通过多个单独的合约分离这些数据。除此之外,应避免创建新合约。

存储数据:SSTORE

SSTORE 是将数据保存到存储的 EVM 操作码。一般而言,当将存储值从零设置为非零时,SSTORE 花费 20,000 gas,当存储值设置为零时,SSTORE 花费 5000 gas。

由于这种成本的存在,在链上存储数据效率低下且成本高昂,应尽可能避免。

这种方法在 NFT 中最为常见。开发人员将 NFT 的元数据(图像、属性等)存储在去中心化存储网络(如 Arweave 或 IPFS)上,而不是将其存储在链上。唯一保存在链上的数据是一条指向元数据的链接。可通过所有 ERC721 合约内置的 tokenURI() 函数获得此链接。

tokenURI() 函数的标准实现。 (来源:OpenZeppelin)

例如无聊猿Bored Ape Yacht Club smart contract。 调用 tokenURI( ) 函数,传入 tokenId: 0, 函数返回以下链接: ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/0


如果点击链接,你将看到 BAYC #0 元数据的 JSON 文件:
这些数据在OpenSea上很容易验证: OpenSea:

还应注意,由于存储成本,某些数据结构在 EVM 中根本不可行。例如,使用邻接矩阵表示图(a graph using an adjacency matrix) 是完全不可行的,因为它的空间复杂度是 O(V²) 。

以上所有代码都可以在我的Github上找到

感谢你阅读,希望你喜欢这篇文章!

如果有机会,我愿意介绍更多的 gas 优化和细微差别。要了解更多信息,我建议使用以下资源:

  • 变量压缩打包大法 和 内存数组优化 作者: Franz Volland
  • Solidity gas 优化技巧 和 Solidity 节省 gas 和字节码大小的魔法 作者: Mudit Gupta
  • EVM: 从 Solidity 到字节码, 内存和存储 作者: Ethereum 工程小组
  • 以太坊黄皮书

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