区块链入门:智能合约(第二部分)

本文深入探讨了智能合约和Solidity语言的关键细节,包括与区块链交互的方法、合约调用模式、合约部署方式以及合约特性。作者通过实例和详细解释,阐述了如何在安全和有效的基础上构建智能合约,并强调了理解编码信息和合约结构的重要性。整篇文章结构清晰、逻辑性强,非常适合想进一步了解智能合约开发者。

这是关于区块链的系列文章之一。如果这是你看到的第一篇文章,我强烈建议你从系列的开头开始阅读。

坦率地说,我并没有计划写这篇特定的文章。我甚至不知道我需要写它。我认为之前的那篇文章已经足够。

理解智能合约如何在低层次上工作似乎对我来说已经足够了 —— 你知道的,能感受到掌握一门编程语言的那种快乐。我心里一切都很好。

而且,我错得很离谱。

我的一位同事在一个频道上发布了一个Solidity 测试(由Rareskills提供,顺便说一下,真是太棒了),我满怀期待地参与其中。结果测试毫不留情地打了我一顿 — 我第一次尝试只得到了可怜的_35%_分数。

有一些我不太理解的操作码,一些我没有经验的模式,通常还有一些我不知道的细节。

然后我意识到:如果我想在智能合约开发上有所成就,学习这些模式和细节是获得对这门语言的_真正_理解的关键。

因此,今天我想关注那些重要的模式和细节 —— 这就比我们对语言的基本理解更进一步。

在我们开始之前

我必须澄清一些事情。智能合约和Solidity并不是同一回事。Solidity是为以太坊虚拟机(EVM)开发应用程序的语言。EVM本身已经变得非常流行,许多区块链已经采纳了其核心理念,并将Solidity作为他们的智能合约语言。

本系列的意图是涵盖一般的区块链概念,但我们现在要深入讨论Solidity。这就是为什么我建议用更全面的视角来看待智能合约中可能的事情,而不仅仅是关注Solidity的特定细微差别。

除非你打算成为一个Solidity开发者,那么这篇文章将会对你相当有帮助!

考虑到这一点,让我们开始吧!

调用模式

让我们开始谈谈我们直到现在一直搁在一旁的事情:如何与区块链互动。正如我们之前提到的,做到这一点的方法是提交交易。这些交易一方面作为一种证明其真实性的手段而被签名,以提供一种验证发送者身份的方式。

但今天,我们想忘记签名部分,而是将重点转向另一个紧迫的问题。那就是:我们需要一个策略来_编码信息_到交易中。

例如,转移某些代币需要交易指定一个数量和一个接收者。

要理解编码哪些信息,我们首先需要检查交易本身的结构。它的结构很简单,由几个字段组成:tofrom是地址,接下来是value,即要发送的Ether的数量(实际上是要发送的Wei)。这对于标准的Ether转账来说已经足够了。

不过其中还有一些额外字段。对我们最感兴趣的是data字段:一个简单的十六进制数据负载。在这里可以发送额外编码的信息。

在编码哪些信息时,从一开始就需要做出一个区分:交易可能是用来部署合约,也可能是调用合约函数。这两类之间有几个关键的不同点:

  • 合约部署交易不指定接收者地址——to字段是空的(或是零地址)。它们的数据字段包含合约的字节码,以及初始化所需的任何参数。
  • 合约调用,另一方面,与_现有合约_进行交互。它们在to字段中指定合约地址,其数据字段则包含有关要调用哪个函数和使用哪些参数的编码信息。

让我们先集中在后者上。

编码合约调用

正如已经提到的,当尝试调用合约函数时,我们需要告诉EVM要调用哪个函数,以及调用的参数是什么。以太坊提供了一种标准方式来做到这一点。

函数本身通过_选择器_进行识别。我们在上一篇文章中讨论过这个问题。data字段的前_4_个字节将对应于函数选择器。

之后是参数。每种参数类型都有特定的编码规则 —— 例如,地址会被填充到32字节,整数则以大端格式编码(即从左到右书写)。

若想了解编码如何工作的完整说明,可以查看Rareskills的这篇文章。有几个规则需要涵盖,而我认为那篇文章涵盖得很好。

好的,太好了。现在,我们还能做什么呢?

静态调用

简单来说,staticcall就像一个 只读 模式。

(bool success, bytes memory data) = address.staticcall(
    abi.encodeWithSignature("justLooking()")
);

尽管上面的示例是Solidity代码,但是静态调用可以直接在RPC级别发送。我们使用eth_call,而不是提交“正常”的交易(这会使用eth_sendTransaction RPC方法)。

这在你想确保调用_不会修改状态_时特别有用。

此外,Solidity提供了一组函数修饰符,其中可以找到viewpure

  • 被标记为view的函数不允许修改状态。
  • 被标记为pure的函数甚至不允许_读取_状态。

那么,为什么需要静态调用呢?

区别在于强制执行的方式和时机。viewpure都是Solidity级别的修饰符,帮助开发人员编写更好的代码并执行编译时检查。这些检查在与受信合约和已知实现工作时效果很好。

然而,当与你不完全控制或信任的合约打交道时,可能需要更强的保障。因为,和往常一样,总有一些人想要看到世界着火——而这些人往往会找到巧妙的办法来绕过我们小心设计的安全措施。

你是邪恶父亲能够要求的最优秀邪恶儿子。

考虑这样一个场景:你被告知要与一个声称只读的合约进行交互,但实际上并不是。当然,你可以分析其字节码以验证其行为,因为view修饰符已经包含在其中。但这至少是_不切实际_的,并且在复杂的交互模式—代理调用、代理,或任何种类的动态合约交互中变得更加繁琐。

那么,我们该如何安全处理这些场景呢?当然是使用staticcall!

使用它可以使调用真正只读,没有意外。它在执行时为你提供了不会修改状态的保证,无论发生什么。

委托调用

在正常情况下,每个合约处理其自己的存储 —— 只有通过合约的函数才能更改它。

现在,假设我们可以让一个合约允许_另一个合约_改变它的状态?如果我们在某种程度上信任这个被允许的合约能负责任地处理存储,那就没必要太担心了!

这正是DELEGATECALL操作码的作用。

它是在那些早期EIP中的一个引入的,EIP-7

简单来说,它将调用的执行_委托_给另一个合约,但使用原始合约的存储(和上下文)。

interface ISharedStorage {
    function someValue() external view returns (uint256);
    function lastCaller() external view returns (address);
}

// 这个合约保持状态但委托执行
contract Borrower is ISharedStorage {
    uint256 public someValue;
    address public lastCaller;

    event DelegateCallResult(bool success, uint256 newValue);

    function delegate(address lender, uint256 newValue) external {
        // 编码函数调用及其参数
        (bool success, ) = lender.delegatecall(
            abi.encodeWithSignature("doSomething(uint256)", newValue)
        );

        emit DelegateCallResult(success, someValue);
    }
}

// 这个合约提供逻辑,但在Borrower的存储上操作
contract Lender is ISharedStorage {
    uint256 public someValue;
    address public lastCaller;

    function doSomething(uint256 newValue) external {
        // 这些修改将影响Borrower的存储
        someValue = newValue;
        lastCaller = msg.sender;  // 这将是调用Borrower的EOA
    }
}

如果在最后一个示例中我们使用delegatecall,那么我们仍然可以通过tx.origin引用原始发送者,但msg.sender将会改变。

当然,目标合约需要了解源合约的存储布局。因此,它确实定义了相同的存储,但在它的trie中不保留任何内容——一切都存储在源合约中。

简而言之,这是一种代理模式,首次提出于EIP-1967。这种类型的代理行为允许有用的工具:可升级合约。这个想法是,状态定义在源合约中保持不变,但可以通过指向不同的代理合约,执行规则可以被切换,并请求通过使用delegatecall进行执行。

但这个特性并不是免费的。如果新的代理实现恰好在状态中造成混乱,可能会面临不可逆转的损害。推出新的代理合约实现时必须非常小心。正如本叔所说,“能力越大,责任越大”。

然后他说:“推出代理实现之前不要忘记 review 和 testing”。

合约部署模式

好了!我们已经讨论了几种与现有合约交互的方式。现在是时候谈谈_合约创建_了。

我们已经简要讨论了如何部署合约:我们所需要做的就是向零地址发送交易,数据字段中包含所有必要的信息以创建合约。

实际上,我们已经知道在该数据字段中要放什么——我们在上一篇文章中讨论过。

值得澄清的一点是,字节码的创建代码实际上包含构造函数参数,因此这部分在每次部署时会有所不同。

太棒了!我们的交易准备好发送以进行合约部署了。接下来会发生什么?

在内部,EVM将使用CREATE操作码。在此过程中,它会为合约分配一个确定性计算的地址。它通过计算一个哈希来做到这一点,输入为部署者的地址和交易的随机数

address = keccak256(rlp.encode([deployer_address, nonce]))

这有几个有趣的后果。

首先,如果我们发送完全相同的部署交易(仅更改随机数),我们将获得与之前完全相同的合约,但部署在不同的地址。其次,只要知道随机数,合约的地址是可预测的

最后,我们_无法选择_结果地址。在某些情况下,拥有更多控制权是可取的——例如,如果我们想在两个(EVM兼容)网络上部署同样的地址的合约。

如果你因为一次简单的转账而错过了一个随机数,你的随机数可能会不同步,导致不同的合约地址,你最终可能需要重新部署东西,导致更多的gas费用,并最终得到“死合约”。

走hash路似乎最多是可靠的,而至少是繁琐的。

控制地址

这时候另一个操作码 CREATE2 发挥了作用。它在EIP-1014中引入,其作用非常简单:它不再依赖不断变化的随机数,而是允许我们选择一个

通过这一简单调整,地址完全依赖于我们可以控制的事物!我们可以提前确切知道合约会在哪里部署,而不管随机数是什么。

这带来了很多酷炫的可能性。正如我之前提到的,它允许你轻松地在不同区块链上以相同的地址部署相同的合约。但它也有助于协调复杂的部署,其中合约需要预先知道彼此的地址。

合约特性

我们已经讨论了各种部署和与合约互动的方式。现在,让我们换个话题,谈谈合约可能具有的一些特殊特性。

本节的大部分内容将是关于修饰符。它们本质上就像我们在函数上贴的标签,以改变其行为。我们还可以构建自定义修饰符——但我们现在将重点讨论语言本身提供的那些。

函数可见性

我们要讨论的第一个主题涉及控制函数可见性——也就是说,谁可以调用一个函数

总共有_四个_可见性修饰符:externalpublicinternalprivate。其中,external是最有趣的,所以我们将把它留到最后。

实际上,internal函数的行为像常规面向对象编程(OOP)中的protected函数。于是,我们有public函数,可以被任何调用者看到。接下来是internal函数,这可以由合约本身或其_子合约_或_派生合约_调用。最后,private函数只能由它定义的合约调用。

说完这些,让我们讨论一下external函数。这显然是为了仅从合约外部进行调用。

如果我们已经有public函数,这有什么有趣的地方?答案是这个额外的知识使EVM能够进行优化

我讨厌这个词优化。它们应该被称为改进优化某事指的是寻找极值(最小值或最大值),而这不是我们在这里所做的。只是想说说。

每当调用一个函数时,它的参数会被复制到内存。就像其他任何编程语言一样,内存是一个临时空间,可以存放信息,并且可以被读写。

关于内存还有很多要说的,但这次我不打算涵盖。这是关于EVM内存的非常详细的文章,如果你感兴趣的话!

将信息复制到内存消耗gas,这意味着它使交易的成本稍高一些。

外部调用可以绕过这个复制步骤,因为参数可在一个特殊位置:_调用数据_中获得!因此,添加这个修饰符为我们节省了一些gas。相当不错吧!

虚函数

正如已经暗示的那样,合约通常扩展其他合约,类似于类继承(OOP风格)。

在这种情况下,我们有时只想编写一个合约供其他合约扩展和修改。这就是_虚函数_的作用:它们就像一个标记,表明“嘿,你可以覆盖这个!”

virtual修饰符的对应物是override修饰符,它特别(显然)表明一个函数覆盖目标虚函数:

contract Base {
  function someOperation() public virtual returns (uint) {
    return 42;
  }
}

contract Extended is Base {
  function someOperation() public override returns (uint) {
    return super.someOperation() * 2;
  }
}

虚函数可以不具有实现——但在这种情况下,它们还需要有abstract修饰符,这意味着子合约必须提供一个函数定义。如果我们不使用abstract,但是提供实现是可选的。如果我们不覆盖一个函数,我们只是会使用virtual函数定义中的默认实现。

付款标志

当我们讨论交易由哪些关键组成时,我们提到过可以指定的一个东西是value,它确定转移的Ether(或者实际上是Wei)数量。

通常,这个值是用来在一个EOA之间转移Ether。虽然没有什么可以阻止我们在一个合约函数调用中设置一个值。那会发生什么?

假设我们在一个合约中调用某个方法deposit。默认情况下,合约_不被期望_接收Ether。因此,如果有附加的value,对这个方法的调用将失败。

这是一个安全特性——我们不希望合约意外接收到它们可能不知道如何处理的Ether。

交易会回退_除非_我们将deposit函数标记为payable

function deposit() external payable {
  // ...
}

payable修饰符只是告诉Solidity,在对标记函数的调用中这个Ether值是合理的。作为开发者,我们可以以msg.value的形式访问该值。例如,这个合约充当一个保险库,保存从账户转移来的Ether,并通过一个映射保持对不同账户余额的跟踪:

contract Vault {
    mapping(address => uint256) balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawAll() external {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;

        // 将Ether发送回调用者
        (bool success,) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

只是一个简短的有趣示例——但在这里,你可以看到这个合约接收Ether并实际执行某些操作是有道理的。

顺便提一下,payable也适用于构造函数。如果你希望合约在部署期间能够接收Ether,只需将构造函数标记为payable!

现在,你可能在想……但如果我调用一个_不存在_的方法呢?或者甚至没有方法就调用一个合约!这与payable有什么关系呢?

捕获未知调用

Solidity(和EVM)提供了两个特殊的函数来处理这些边界情况:_fallback_和_receive_函数。

这真的非常简单:

  • fallback函数在有人调用一个_不存在_的方法,或者调用数据与任何函数不匹配(这意味着提供的输入与预期的不匹配)时被调用。
  • receive函数更加具体:它仅在有人向合约地址发送Ether时被调用,而没有附加任何调用数据。
function fallback() external {
  // 处理未知调用
}

function receive() external payable {
 // 处理普通的Ether转移
}

如你所见,receive被标记为payable,这样交易就不会回退,合约就可以接收Ether。

这些可以算是合约的安全网。它们捕捉到任何意外的交互。尽管这是它们的主要目的,但它们也允许一些非常有趣的模式,比如我们已经讨论过的这个代理合约,它可以使用我们已经覆盖的工具——delegatecall操作码,将所有调用委托给另一个合约!

哦,另外,如果合约对这两个函数都没有实现,它将假定其主体是空的,但仍在需要时执行它们。

看到这些组成部分相互配合,正是美妙的事情,不是吗?

真是完美

销毁合约

接下来,我们继续谈谈合约销毁。没错。

有一个特殊操作码叫做SELFDESTRUCT

function destroy() external {
  require(msg.sender == owner);
  selfdestruct(payable(someAddress));
}

当调用这个操作码时,会发生三件事情:

  1. 合约中所有剩余的Ether将被强制发送到指定地址,即使它没有receive函数。
  2. 合约的代码和存储将从区块链状态中删除。
  3. 未来对合约地址的所有调用将失败。

这个功能——尽管看起来很酷——通常被认为是危险的。特别是与CREATE2结合时。想象一下,如果有人销毁了你正在使用的合约,并用一个恶意合约替换它,并且地址相同。可不酷,兄弟。

我想象有人做这些事情时的模样。

一般建议是使用我们之前提到的代理模式来处理可升级合约,或者如果你的意图是禁用合约,只需使用一个简单的布尔标志。这些模式通常对所有相关方来说更安全。

实际上,以太坊社区已经进行了 关于移除selfdestruct代码的讨论

总结

好吧,这当然是很多内容。

我们已经讨论了各种调用模式、不同的交易部署方法,以及特殊函数和修饰符。这至少应该为你提供一个良好的起点。

我认为在阅读完这篇文章后,你可能会认识到一些来自Rareskill测试的问题 —— 而现在,你真的知道如何回答其中一些问题了_。

当然,并不是所有的问题!

关于Solidity、以太坊整体及其各种标准(ERCs),确实有很多知识要了解。

社区总是在推动网络的发展,审查不仅是接受的标准,还有整个网络的工作方式。因此,跟进最新的发展非常重要。

我坚信学习的最好方法就是实践——你知道的,稍微让手变脏一点。理论永远是好的,但当你经历三小时尝试理解为什么你的智能合约不能正常工作时,你肯定不会忘记。

所以,开始动手,尝试一些想法,你肯定会学到比这篇文章所能提供的更多知识。

在花了两篇文章讲述Solidity的方方面面之后,我们很有必要后退一步看看大局。下次,我们将从以太坊的角度回顾一个熟悉的话题:共识

再见!

  • 原文链接: medium.com/@francomangon...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Frank Mangone
Frank Mangone
Software developer based in Uruguay. Math and Cryptography enthusiast.