区块链中文技术社区

Solidity智能合约安全:防止重入攻击的4种方法

使用检查、影响和交互模式(简称CEI:Checks, Effects, and Interactions)、互斥锁、Pull 支付方式以及gas限制都是防止可重入攻击的有效技术。

Solidity智能合约安全:防止重入攻击的4种方法

重入是一种编程技术,在这种技术中,一个函数(“A”)的执行被一个外部函数调用打断,在外部函数调用的逻辑中,能够递归地调用原函数(“A”)。在某些情况下,重复地重新进入一个函数来执行外部逻辑可能是可取的,不一定是错误。然而,这种技术不建议用于智能合约,因为它将控制流执行释放给不受信任的合约,而器可能用于盗取资金 。因此,在执行对外部合约的调用时,应使用反重入模式和防护措施来防止这种类型的攻击发生。

有三种主要技术来防止重入:

  • 使用 检查、影响、交互(CEI)
  • 使用 防重入互斥锁
  • 使用Pull 支付方式

此外,还有一种技术可能是有效的,但不推荐使用,他是使用 Gas Limit 限制。

检查、影响、交互

CEI模式是一种简单而有效的防止重入的方法。检查指的是判断是否符合条件(验证真实性)。影响指的是由交互产生的状态修改。最后,交互指的是函数或合约之间的调用。

下面是一个错误的示范(因为交互在影响之前)

// contract_A: holds user's funds

function withdraw() external {
  uint userBalance = userBalances[msg.sender];

  require(userBalance > 0);

  (bool success,) = msg.sender.call{ value: userBalance }("");
  require(success,);

  userBalances[msg.sender] = 0;
}

这里是攻击者的receive函数:

// contract_B: reentrancy attack

receive() external payable {
  if (address(contract_A).balance >= msg.value) {
    contract_A.withdraw();
  }
}

攻击者的receive函数收到提款后,本应该只返回success,但却检查contract_A是否包含更多的资金。如果是,contract_B会再次调用提款函数,递归直到contract_A所有资金用完。

下面是一个使用CEI模式的提款函数的例子:

function withdraw() external {
  uint userBalance = userBalances[msg.sender];

  require(userBalance > 0);
  userBalances[msg.sender] = 0;

  (bool success,) = msg.sender.call{ value: userBalance }("");
  require(success,);
}

通过在向contract_A转移资金之前将用户在contract_B的账户余额清零,当contract_B发起重入攻击时,提取函数中的条件将为假,执行将被回退。正如这个案例所强调的那样,一行代码的位置可能引起有重大漏洞与重入性安全之间的巨大区别。

重入保护互斥锁

重入防护互斥锁(mutex)可以被构造成一个函数或函数修改器,但其逻辑很简单:一个布尔锁被放置在易受重入影响的函数调用周围。locked的初始状态为假(unlocked),在易受攻击的函数执行开始前,它被立即设置为真(locked),然后在其终止后被设置回假(unlocked)。

下面是一个使用上面的提款函数的例子:

bool internal locked = false;

function withdraw() external {
  require(!locked);
  locked = true;

  uint userBalance = userBalances[msg.sender];
  require(userBalance > 0);
  (bool success,) = msg.sender.call{ value: userBalance }("");
  require(success,);
  userBalances[msg.sender] = 0;

  locked = false;
}

虽然这个提款函数没有遵循CEI模式,但简单的布尔 locked 变量可以防止重入,因此同样可以防止重入攻击。
因为在重入时,第一个require语句条件将为false,会回退交易。

Pull(拉) 方式支付

最后这种技术被Open Zeppelin推荐为最佳实践。然而,它在自动化方面有一个小小的折衷。Pull方式支付是通过中间托管账号发送资金来避免直接和潜在的危险合约交互来实现安全。

在这里,合约资金被发送到一个中间托管账号。

function sendPayment(address user, address escrow) external {
  require(msg.sender == authorized);

  uint userBalance = userBalances[user];

  require(userBalance > 0);

  userBalances[user] = 0;

  (bool success,) = escrow.call{ value: userBalance }("");
  require(success,);
}

而在托管账户的资金,则由接收方来提取:

function pullPayment() external {
  require(msg.sender == receiver);

  uint payment = account(this).balance;

  (bool success,) = msg.sender.call{ value: payment }("");
  require(success,);
}

通过中间托管账号发送资金,合约资金受到保护,不会受到重入攻击。如果托管人持有多个账户的资金,可能会受到重入攻击,所以在适用的情况下,应该实现CEI模式和(或)重入保护互斥锁。

Gas Limit限制

最后,通过Gaslimit 限制也可以防止重入攻击,但这不应该被视为一种安全策略,因为Gas成本取决于以太坊的操作码,而操作码gas是可以改变的。另一方面,智能合约代码是不可改变的。不过, send, transfer, 和 call 这些函数之间的区别是值得了解的。

send和 transfer函数本质上是相同的,但如果交易失败,transfer会回退,而send则不会,而是会返回 false。

// transfer will revert if the transaction fails
address(receiver).transfer(amount);

// send will not revert if the transaction fails
address(receiver).send(amount);

关于重入问题, send和 transfer都有2300个单位的Gas限制。使用这些函数应该可以防止重入性攻击的发生,因为他们没有足够的Gas来递归调用到原函数来利用资金。

与 send和 transfer不同,call没有Gas限制,为了执行复杂的多合约交易(当然,也包括重入攻击),会转发其所有剩余Gas。

结论

一次成功的重入攻击后果可能是毁灭性的,可能会耗尽受害者合约中的所有资金,因此,意识到潜在的漏洞并实现有效的保障措施是很重要的。

无论是否存在漏洞,CEI模式都应该被默认实现,这只是一种良好的做法。额外的安全可以通过使用重入锁和(或)Pull支付方式来完成。最后,Gas限制可以防止重入,但不应该被视为一种安全策略。

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

无需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