往期回顾:

程序维护中的一个基本问题是——缺陷修复总会以(20-50)%的机率引入新的 bug。所以整个过程是前进两步,后退一步。——布鲁克斯《人月神话》


1. 合约升级的必要性

智能合约本质上是程序,既然是程序,缺陷就不可避免。即便是经过大量测试和审计的智能合约,仍然可能会有漏洞。合约的漏洞如果被攻击者利用,可能造成用户资产的损失,导致严重的后果。漏洞的修复往往需要通过合约升级来实现。除了漏洞修复,新特性的加入也需要合约升级。因此合约的可升级性非常有必要。本期Rust智能合约养成日记,将为大家介绍Rust合约的升级方式。


2. Solidity合约常见升级方式

以太坊中,智能合约具有不可变性,一旦被部署到链上,没有人可以改变它。

那么如果合约存在漏洞或合约需要添加新功能,该如何修改合约的代码?解决方案是将新的合约部署到区块链上。

该方法面临的挑战是,solidity每次部署合约后,合约都会被分配一个唯一的地址。因此所有用到了该合约的DApps都需要修改合约地址来适配新的合约。此外,旧版本合约中的状态需要迁移到新版本合约中,状态较为复杂的合约迁移的工作量很大,容易出错,而且复制数据的Gas费用高。

因此,我们通常采用数据和逻辑分离的架构,将数据保存在一个不处理任何逻辑的合约(状态合约)中,所有的逻辑在另一个合约(逻辑合约)中实现。通常合约升级修改的是逻辑,使用该架构只需要升级逻辑合约,不需要担心状态迁移。

为了解决这个问题,可以使用代理合约(Proxy Contract),具体架构如下图所示。

代理合约用来来存储数据,并且使用delegatecall调用逻辑合约A,这样合约A读写的数据都存储在代理合约中。如果需要升级逻辑合约,部署新的合约B,然后发一条交易给代理合约,让代理合约指向新的逻辑合约B即可。


3. NEAR 合约升级常用方法

接下来我们将以StatusMessage项目为例,给大家介绍NEAR 合约的常用升级方法,如下是StatusMessage的合约代码

#[near_bindgen]#[derive(BorshDeserialize, BorshSerialize)]pub struct StatusMessage {    records: LookupMap<String, String>,}impl Default for StatusMessage {    fn default() -> Self {        Self {            records: LookupMap::new(b'r'.to_vec()),        }    }}#[near_bindgen]impl StatusMessage {    pub fn set_status(&mut self, message: String) {        let account_id = env::signer_account_id();        self.records.insert(&account_id, &message);    }    pub fn get_status(&self, account_id: String) -> Option<String> {        return self.records.get(&account_id);    }}

我们先将编译好的合约部署在测试网上。

$ near deploy \    --accountId statusmessage.blocksec_upgrade.testnet \    --wasmFile target/wasm32-unknown-unknown/release/status_message.wasm

transaction 如下

https://explorer.testnet.near.org/transactions/BgMFFY8Bo2e2bETiEvpfTgJcZwS5oC1Nv4aKwrFQCqgd

接着我们调用set_status 方法,向合约中存储数据

$ near call --accountId blocksec_upgrade.testnet statusmessage.blocksec_upgrade.testnet set_status '{'message':'Hi!BlockSec'}'

transaction 如下

https://explorer.testnet.near.org/transactions/8QAH66QczyMSEizbfvygj8FVhxThQZUvJ6DY9hsZVyh9

接下来我们详细讨论两种不同的合约升级情况

3.1 合约数据结构未被修改

例如我们增加一个函数:

#[near_bindgen]impl StatusMessage {    pub fn set_get_status(&mut self, message: String) -> Option<String> {        let account_id = env::signer_account_id();        self.records.insert(&account_id, &message);        return self.records.get(&account_id);    }}

编译后使用deploy重新部署:

$ near deploy \--accountId statusmessage.blocksec_upgrade.testnet \--wasmFile target/wasm32-unknown-unknown/release/status_message.wasm

会提示该账户已经部署了一个合约,是否继续。输入y确认即可。

This account already has a deployed contract [ EVuouKAEq4JWsj3i96qhEf6QK3AMR1K3a1wy9g8qmM3J ]. Do you want to proceed? (y/n) yStarting deployment. Account id: statusmessage.blocksec_upgrade.testnet, node: https://rpc.testnet.near.org, helper: https://helper.testnet.near.org, file: target/wasm32-unknown-unknown/release/status_message.wasmTransaction Id 7aeqQV9PWRzPhyaGSizTP6HtPSfqJzoczwg15NzDodSVTo see the transaction in the transaction explorer, please open this url in your browserhttps://explorer.testnet.near.org/transactions/7aeqQV9PWRzPhyaGSizTP6HtPSfqJzoczwg15NzDodSVDone deploying to statusmessage.blocksec_upgrade.testnet

接着我们调用get_status方法读取之前写入的数据

near call --accountId blocksec_upgrade.testnet statusmessage.blocksec_upgrade.testnet get_status '{'account_id':'blocksec_upgrade.testnet'}'

原来合约中的数据能成功读取:

Scheduling a call: statusmessage.blocksec_upgrade.testnet.get_status({'account_id':'blocksec_upgrade.testnet'})Doing account.functionCall()Transaction Id FwGWCVuAo6dgvPfCJyS9HZAMstiZx4b47vb7kAx3GGMETo see the transaction in the transaction explorer, please open this url in your browserhttps://explorer.testnet.near.org/transactions/FwGWCVuAo6dgvPfCJyS9HZAMstiZx4b47vb7kAx3GGME'Hi!BlockSec'

这是因为NEAR合约可以重复部署,如果一个账户(地址)已经部署过合约,再次调用near deploy命令可以将新的合约代码部署到该账户上。如果我们只修改合约逻辑,不涉及数据结构的修改,可以直接使用near deploy部署新的代码。

3.2合约数据结构被修改

我们将该合约升级,修改了原来的数据结构,去除了records, 新增了taglines和bios

#[near_bindgen]#[derive(BorshDeserialize, BorshSerialize)]pub struct StatusMessage {    taglines: LookupMap<String, String>,    bios: LookupMap<String, String>,}impl Default for StatusMessage {    fn default() -> Self {        Self {            taglines: LookupMap::new(b'r'.to_vec()),            bios: LookupMap::new(b'b'.to_vec()),        }    }}#[near_bindgen]impl StatusMessage {    pub fn set_tagline(&mut self, message: String) {        let account_id = env::signer_account_id();        self.taglines.insert(&account_id, &message);    }    pub fn get_tagline(&self, account_id: String) -> Option<String> {        return self.taglines.get(&account_id);    }    pub fn set_bio(&mut self, message: String) {        let account_id = env::signer_account_id();        self.bios.insert(&account_id, &message);    }    pub fn get_bio(&self, account_id: String) -> Option<String> {        return self.bios.get(&account_id);    }}

我们尝试再次重新部署:

$ near deploy --accountId statusmessage.blocksec_upgrade.testnet --wasmFile target/wasm32-unknown-unknown/release/status_message.wasm

合约还是成功部署了

This account already has a deployed contract [ EzVBv3yrji88y4Lz1o3JZ8LymcDJHywFzjwkL2Cm5aHb ]. Do you want to proceed? (y/n) yStarting deployment. Account id: statusmessage.blocksec_upgrade.testnet, node: https://rpc.testnet.near.org, helper: https://helper.testnet.near.org, file: target/wasm32-unknown-unknown/release/status_message.wasmTransaction Id HYbXxTE1Y6h1hJhhHgekfw9rx7FT77CCW7NDRuKWzqYzTo see the transaction in the transaction explorer, please open this url in your browserhttps://explorer.testnet.near.org/transactions/HYbXxTE1Y6h1hJhhHgekfw9rx7FT77CCW7NDRuKWzqYzDonedeployingtostatusmessage.blocksec_upgrade.testnet

但是我们调用get_tagline方法读取存储的数据:

$ near call --accountId blocksec_upgrade.testnet statusmessage.blocksec_upgrade.testnet get_tagline '{'account_id':'blocksec_upgrade.testnet'}'

会发现出错了,错误提示如下

Cannot deserialize the contract state.

具体的transaction见

https://explorer.testnet.near.org/transactions/4hQQ1zAwU5bsbfb6tA6DQDqjmFcHsBwaBctdHaPiCKHu

这是因为合约的状态是以序列化数据的形式进行持久化存储的,重新部署合约后,代码中的数据结构变了,状态没有变,新的数据结构匹配不上旧状态,就出错了。

3.3Migrate升级智能合约

NEAR提供了Migrate方法去帮助我们对合约进行升级,针对3.2中所出现的错误,我们在新的合约中加入migrate方法:

#[private]#[init(ignore_state)]pub fn migrate() -> Self {    let old_state: OldStatusMessage = env::state_read().expect('failed');    Self {        taglines: old_state.records,        bios: LookupMap::new(b'b'.to_vec()),    }}

代码中的#[init(ignore_state)]表示在migrate函数执行前不要加载状态。接着,我们重新部署合约,但是在部署的同时调用migrate方法

$ near deploy \    --wasmFile target/wasm32-unknown-unknown/release/status_message.wasm \    --initFunction 'migrate' \    --initArgs '{}' \    --accountId statusmessage.blocksec_upgrade.testnet

如下所示,该合约被成功部署

This account already has a deployed contract [ D7AkSRdmktVAXSFDE7RGT3QSjS531wQsLR8MhY5YiLbM ]. Do you want to proceed? (y/n) yStarting deployment. Account id: statusmessage.blocksec_upgrade.testnet, node: https://rpc.testnet.near.org, helper: https://helper.testnet.near.org, file: target/wasm32-unknown-unknown/release/status_message.wasmTransaction Id DsoJbii6ofCTGdtgyirvqudUQNFgzaasJExXpWMFMG5mTo see the transaction in the transaction explorer, please open this url in your browserhttps://explorer.testnet.near.org/transactions/DsoJbii6ofCTGdtgyirvqudUQNFgzaasJExXpWMFMG5mDone deploying and initializing statusmessage.blocksec_upgrade.testnet

我们尝试调用合约新增的方法get_tagline去获取新增的数据taglines

$ near call --accountId blocksec_upgrade.testnet statusmessage.blocksec_upgrade.testnet get_tagline '{'account_id':'blocksec_upgrade.testnet'}'

可以看到方法被成功调用,旧的合约数据也被迁移到新的合约

Scheduling a call: statusmessage.blocksec_upgrade.testnet.get_tagline({'account_id':'blocksec_upgrade.testnet'})Doing account.functionCall()Transaction Id 9xymz7sL5GhsbWJHBY8KWDXYqSj8eQ2sQy631juQsws7To see the transaction in the transaction explorer, please open this url in your browserhttps://explorer.testnet.near.org/transactions/9xymz7sL5GhsbWJHBY8KWDXYqSj8eQ2sQy631juQsws7'Hi!BlockSec'


4. 合约升级的安全考量

合约安全升级首先要考虑权限控制,一般合约只能由开发者或DAO升级。上一期Rust 智能合约养成日记(8)合约安全之权限控制介绍了特权函数的访问控制,一般合约的升级函数为only owner函数,确保只能由owner调用。

我们推荐尽可能将合约的owner设置为DAO,通过提案和投票来共同管理合约。因为owner设置为个人账户,合约高度中心化,owner可以随意修改合约数据,还存在owner私钥丢失的风险。

除此之外,开发者在做合约迁移时,还可以考虑以下几点建议

  • 在迁移函数前加入#[init(ignore_state)],确保执行迁移函数前不加载状态。

  • 迁移完成后尽量删除迁移函数,确保迁移函数只被调用一次。

  • 新增的数据结构在迁移时完成初始化。