介绍在使用 Solidity 以太坊升级智能合约的挑战

在开发软件的时候,我们经常需要发布新的版本来增加新的功能或者修复bug。当涉及到智能合约开发时,也没有什么区别。虽然将智能合约更新到新版本通常不像更新其他类型的相同复杂性的软件那么简单。

大多数区块链,尤其是像 Ethereum 这样的公链,都实现了不可变的特性。理论上不允许任何人改变区块链的 "过去"。不可变更性适用于区块链中的所有交易,包括用于部署智能合约和相关代码的交易。换句话说,一旦智能合约的代码被部署到区块链上,它将永远 "原样 "地 "活着"--没有人可以改变它。如果发现了bug或者需要添加新的功能,我们无法直接修改部署合约的代码。

如果一个智能合约是不可更改的,那么你如何能够将它升级到新的版本?答案就在于将新的智能合约部署到区块链上。但这种方法会带来一些需要解决的挑战。最基本也是最常见的是,所有使用智能合约的用户都需要参考新合约版本的地址,第一个合同的版本应该被禁用,强制每个用户使用新版本。

通常,你需要确保旧版本的数据(状态)被迁移或以某种方式提供给新版本。在最简单的情况下,这意味着你需要将旧版本中的状态复制/迁移到新合同的版本中。

下面的章节将更详细地描述这些挑战。为了更好的说明,我们用下面两个版本的 MySmartContract 作为参考。

// Version 1
contract MySmartContract {
    uint32 public counter;
    constructor() public {
        counter = 0;
    }
    function incrementCounter() public {
        counter += 2; // This "bug" is intentional.
    }
}

// Version 2
contract MySmartContract {
    uint32 public counter;
    constructor(uint32 _counter) public {
        counter = _counter;
    }
    function incrementCounter() public {
        counter++;
    }
}

用户可参考新合同的地址。

当部署到区块链时,智能合约的每个实例都被分配到一个唯一的地址。该地址用于引用智能合约的实例,以便调用其方法并从/向合约的存储(状态)读取/写入数据。当你将合同的更新版本部署到区块链时,合同的新实例将部署在一个新的地址。这个新地址与第一个合约的地址不同。这意味着,所有与智能合约交互的用户、其他智能合约和/或 dApp(去中心化应用)都需要更新,以便它们使用更新版本的地址。剧透:有一些选项可以避免这个问题,你会在本节最后看到。

那么,让我们考虑以下情况。你用上面 Version 1 的代码创建 MySmartContract。它被部署到区块链的地址 A1(这不是一个真实的 Ethereum 地址--仅用于说明目的)。所有想要与 Version 1交互的用户都需要使用地址 A1 来引用它。现在,经过一段时间后,我们注意到了方法 incrementCounter 中的 bug:它是以2来递增计数器,而不是以1来递增它,所以我们实现了一个修复,产生了 MySmartContract 的 Version 2 版本。这个新合约的版本被部署到地址 D5 的区块链上。此时,如果用户想要与 Version 2 进行交互,需要使用地址 D5,而不是 A1。这就是为什么所有与 MySmartContract 交互的用户都需要更新,以便他们参考新的地址 D5 的原因。

你可能同意强迫用户更新不是最好的方法,考虑到更新智能合约的版本应该对使用它的用户尽可能的透明。有不同的策略可以用来解决这个问题。可以使用一些设计模式,如 Registry、不同类型的 Proxies 来使升级更容易,并为用户提供透明度。另一个很好的选择是使用 Ethereum Name Service,并注册一个用户朋友的名字,解析到你的合约地址。有了这个选择,合约的用户不需要知道合约的地址,只需要知道它的用友名。因此,升级到新的地址对你的合约用户来说将是透明的。具体采取何种策略,取决于智能合约的使用场景。

禁用旧版合约

我们在上一节中了解到,所有用户都需要更新才能使用 Version 2 的地址(D5),或者我们的合同应该实现某种机制,使这个过程对用户透明。尽管如此,如果你是合同的拥有者,你可能要执行所有用户只使用最新的 D5 版本。如果用户无意中或没有使用 A1,你要保证 Version 1 已经被废弃,无法使用。

在这种情况下,你可以实现一种技术来停止 MySmartContract 的 Version 1。这个技术是由一个名为 Circuit Breaker 的设计模式实现的。它通常也被称为可暂停合同或紧急停止。

一般来说,Circuit Breaker 可以停止智能合约的功能。此外,它还可以启用特定的功能,这些功能只有在合约被停止时才能使用。这种模式通常实现了某种访问限制,因此只有被允许的行为者(如管理员或所有者)才有必要的权限来触发断路器并停止合约。

这种模式可以使用的一些场景有。

当发现一个bug时,停止合同的功能。 在达到某个状态后停止某些合约的功能(经常与状态机模式一起使用)。 在升级过程中停止合同的功能,因此外部行为者不能在升级过程中改变合同的状态。 在部署新版本后停止合同的废弃版本。

现在让我们来看看如何实现一个断路器,以停止 MySmartContract 的 incrementCounter 功能,所以计数器不会改变在迁移过程中。这个修改需要在 Version 1 中,也就是第一次部署的时候进行。

// Version 1 implementing a Circuit Breaker with access restriction to owner
contract MySmartContract {
    uint32 public counter;
    bool private stopped = false;
    address private owner;
    /**
    @dev Checks if the contract is not stopped; reverts if it is.
    */
    modifier isNotStopped {
        require(!stopped, 'Contract is stopped.');
        _;
    }
    /**
    @dev Enforces the caller to be the contract's owner.
    */
    modifier isOwner {
        require(msg.sender == owner, 'Sender is not owner.');
        _;
    }
    constructor() public {
        counter = 0;
        // Sets the contract's owner as the address that deployed the contract.
        owner = msg.sender;
    }
    /**
    @notice Increments the contract's counter if contract is active.
    @dev It will revert if the contract is stopped. See modifier "isNotStopped"
    */
    function incrementCounter() isNotStopped public {
        counter += 2; // This is an intentional bug.
    }
    /**
    @dev Stops / Unstops the contract.
    */
    function toggleContractStopped() isOwner public {
        stopped = !stopped;
    }
}

在上面的代码中,你可以看到 MySmartContract 的 Version 1 现在实现了一个修改器 isNotStopped。如果合同被停止,这个修饰符将恢复交易。函数 incrementCounter 被修改为使用修饰符 isNotStopped,所以它将只在合约未停止时执行。通过这个实现,就在迁移开始之前,合约的所有者可以调用函数 toggleContractStopped 并停止合约。请注意,这个函数使用修饰符 isOwner 来限制合同所有者的访问。

要了解更多关于 Circuit Breakers 的信息,请务必查看 Consensys 关于 Circuit Breakers 的帖子和 OpenZeppelin 对 Pausable 合约的参考实现。

合同的数据(状态)迁移

大多数智能合约需要在其内部存储中保持某种状态。根据不同的用例,每个合约所需要的状态变量的数量有很大的不同。在我们的例子中,原来 MySmartContract 的 Version 1 有一个单一的状态变量计数器。现在考虑 MySmartContract 的 Version 1 已经使用了一段时间。当你发现incrementCounter 函数的 bug 时,counter 的值已经在 100 了。这种情况下会产生一些问题:

你将如何处理 MySmartContract Version 2 的状态? 你可以在 Version 2 中把计数器重置为 0(零),还是应该从 Version 1 中迁移状态,以确保计数器在 Version 2 中初始化为 100?

这些问题的答案将取决于用例。在本文的例子中,这是一个非常简单的场景,而且 counter 没有重要的用法,如果将 counter 重置为 0,你不会有任何问题。但是,这不是大多数情况下所希望的方法。假设你不能将值重置为 0,需要在第2版中将 counter 设置为 100。在 MySmartContract 这样一个简单的合约中,这并不困难。你可以改变Version 2的构造函数来接收计数器的初始值作为参数。在部署时,你会把值 100 传递给构造函数,这就解决了你的问题。实现这个方法后,MySmartContract Version 2的构造函数会是这样的。

constructor(uint32 _counter) public {
    counter = _counter;
}

如果你的用例像上面介绍的那样简单(或类似),从数据迁移的角度来看,这可能是最合适的方式。实现其他方法的复杂性就不值得了。但是,请记住,大多数生产就绪的智能合约并不像 MySmartContract 那样简单,而且经常有更复杂的状态。

现在考虑一个使用多个结构、映射和数组的合约。如果你需要在具有如此复杂存储的合约版本之间复制数据,你可能会面临以下一个或多个挑战:

一堆交易要在区块链上处理,这可能需要相当长的时间,这取决于数据集。 用于处理从"版本1"读取数据并写入"版本2"的附加代码(除非手动完成)。 花真金白银来支付 GAS。记住,你需要支付 GAS 来处理区块链中的交易。根据 Ethereum 黄皮书--附录G. 费用表,用于向 Ethereum 写入数据的上位代码 SSTORE 操作,"当存储值从零设置为非零时 "需要花费 20000 个天然气单位,"当存储值的零度不变时 "需要花费 5000 个天然气单位。 通过使用某种机制(如断路器)冻结 Version 1 的状态,以确保在迁移过程中没有更多的数据附加到 Version 1 上。 实现访问限制机制,以避免外部各方(与迁移无关)在迁移期间调用版本2的功能。为了确保版本一的数据可以复制/迁移到版本二,而不会在版本二中受到损害和/或破坏,需要这样做。

在状态较为复杂的合约中,执行升级所需的工作相当重要,而且在区块链上复制数据会产生相当大的气成本。使用库和代理可以帮助你开发更容易升级的智能合约。采用这种方法,数据将被保存在一个存储状态但不承担任何逻辑的合约中(状态合约)。第二个合约或库实现了逻辑,但不承担状态(逻辑合约)。所以当发现逻辑中的bug时,只需要升级逻辑合约,而不用担心迁移状态合约中存储的状态(见下文注)。

注:这种方法一般使用Delegatecall。状态合约使用delegatecall调用逻辑合约中的函数。然后逻辑合约在状态合约的上下文中执行它的逻辑,也就是说 "存储、当前地址和余额仍然参考调用合约,只是代码取自被调用的地址"。(来自上面提到的Solidity文档)。

让 MySmartContract 更容易升级

下面你可以看到,如果我们实现本文中描述的变化,版本1和版本2会是什么样子。需要再次提及的是,考虑到 MySmartContract 的简单性:状态变量和逻辑,其使用的策略是可以接受的。
首先,让我们看看版本1的变化。

// Version 1 — Without Upgradable Mechanisms
contract MySmartContract {
    uint32 public counter;
    constructor() public {
        counter = 0;
    }
    function incrementCounter() public {
        counter += 2; // This "bug" is intentional.
    }
}

在下面的代码中,版本1实现了一个带有访问限制机制的断路器,一旦合同被废弃,所有者可以停止合同。

//Version 1 — With Deprecation Mechanism
contract MySmartContract {
    uint32 public counter;
    bool private stopped = false;
    address private owner;
    /**
    @dev Checks if the contract is not stopped; reverts if it is.
    */
    modifier isNotStopped {
        require(!stopped, 'Contract is stopped.');
        _;
    }
    /**
    @dev Enforces the caller to be the contract's owner.
    */
    modifier isOwner {
        require(msg.sender == owner, 'Sender is not owner.');
        _;
    }
    constructor() public {
        counter = 0;
        // Sets the contract's owner as the address that deployed the contract.
        owner = msg.sender;
    }
    /**
    @notice Increments the contract's counter if contract is active.
    @dev It will revert is the contract is stopped. See modifier "isNotStopped"
    */
    function incrementCounter() isNotStopped public {
        counter += 2; // This is an intentional bug.
    }
    /**
    @dev Stops / Unstops the contract.
    */
    function toggleContractStopped() isOwner public {
        stopped = !stopped;
    }
}

现在让我们看看第二版会是怎样的。第二版--没有可升级的机制:

contract MySmartContract {
    uint32 public counter;
    constructor(uint32 _counter) public {
        counter = _counter;
    }
    function incrementCounter() public {
        counter++;
    }
}

在下面的代码中,第2版实现了与第1版相同的断路器和访问限制机制。此外,它实现了一个构造函数,允许在部署期间设置计数器的初始值。这个机制可以使用,它可以在升级时使用,从旧版本复制数据。
版本2 - 具有简单的升级机制。

//Version 2 — With Simple Upgradable Mechanism
contract MySmartContract {
    uint32 public counter;
    bool private stopped = false;
    address private owner;
    /**
    @dev Checks if the contract is not stopped; reverts if it is.
    */
    modifier isNotStopped {
        require(!stopped, 'Contract is stopped.');
        _;
    }
    /**
    @dev Enforces the caller to be the contract's owner.
    */
    modifier isOwner {
        require(msg.sender == owner, 'Sender is not owner.');
        _;
    }
    constructor(uint32 _counter) public {
        counter = _counter; // Allows setting counter's initial value on deployment.
        // Sets the contract's owner as the address that deployed the contract.
        owner = msg.sender;
    }
    /**
    @notice Increments the contract's counter if contract is active.
    @dev It will revert is the contract is stopped. See modifier "isNotStopped"
    */
    function incrementCounter() isNotStopped public {
        counter++; // Fixes bug introduced in version 1.
    }
    /**
    @dev Stops / Unstops the contract.
    */
    function toggleContractStopped() isOwner public {
        stopped = !stopped;
    }
}

虽然上述变化实现了一些有助于智能合约升级的机制,但本文开头所描述的第一个挑战--用户要参考新合约的地址,并不是这些简单的技术就能解决的。需要更高级的模式,比如 Proxies 和 Registry,或者使用 ENS 给你的合约注册一个用户友好的名字,来避免所有用户升级参考第二版的新地址。

结束语

在 Ethereum 白皮书的 DAO 部分描述了可升级智能合约的原理,内容如下。

"虽然理论上代码是不可变的,但我们可以很容易地绕过这一点,并通过将代码分块放在单独的合约中,并将调用哪些合约的地址存储在可修改的存储空间中,来实现事实上的可变性。*”

虽然这是可以实现的,但智能合约的升级是相当具有挑战性的。区块链的不可变性为智能合约的升级增加了更多的复杂性,因为它迫使你仔细分析智能合约的使用场景,了解可用的机制,然后决定哪些机制适合你的合约,这样潜在的和可能的升级就会很顺利。

智能合约升级性是一个活跃的研究领域。相关的模式、机制和最佳实践仍在不断讨论和发展中。使用库和一些设计模式,如断路器、访问限制、代理和注册表,可以帮助你解决一些挑战。但是,在比较复杂的场景下,仅靠这些机制可能无法解决所有问题,你可能需要考虑更复杂的模式,比如本文没有提到的 Eternal Storage。

你可以在这个github仓库中查看完整的源代码,包括相关的单元测试(为了简单起见,本文没有提到)。

原文:https://levelup.gitconnected.com/introduction-to-ethereum-smart-contract-upgradability-with-solidity-789cc497c56f