关于智能合约:智能合约编写之-Solidity的设计模式-FISCO-BCOS超话区块链专场篇4

4次阅读

共计 10316 个字符,预计需要花费 26 分钟才能阅读完成。

前言


随着区块链技术倒退,越来越多的企业与集体开始将区块链与本身业务相结合。

区块链所具备的独特劣势,例如,数据公开通明、不可篡改,能够为业务带来便当。但与此同时,也存在一些隐患。数据的公开通明,意味着任何人都能够读取;不可篡改,意味着信息一旦上链就无奈删除,甚至合约代码都无奈被更改。

除此之外,合约的公开性、回调机制,每一个特点都可被利用,作为攻打手法,稍有不慎,轻则合约形同虚设,重则要面临企业秘密泄露的危险。所以,在业务合约上链前,须要事后对合约的安全性、可维护性等方面作充分考虑。

侥幸的是,通过近些年 Solidity 语言的大量实际,开发者们一直提炼和总结,曾经造成了一些 ” 设计模式 ”,来领导应答日常开发常见的问题。

智能合约设计模式概述


2019 年,IEEE 收录了维也纳大学一篇题为《Design Patterns For Smart Contracts In the Ethereum Ecosystem》的论文。这篇论文剖析了那些炽热的 Solidity 开源我的项目,联合以往的研究成果,整顿出了 18 种设计模式。

这些设计模式涵盖了安全性、可维护性、生命周期治理、鉴权等多个方面。

接下来,本文将从这 18 种设计模式中抉择最为通用常见的进行介绍,这些设计模式在理论开发经验中失去了大量测验。

安全性(Security)


智能合约编写,首要思考的就是安全性问题。

在区块链世界中,恶意代码不可胜数。如果你的合约蕴含了跨合约调用,就要特地当心,要确认内部调用是否可信,尤其当其逻辑不为你所掌控的时候。

如果不足防人之心,那些“居心叵测”的内部代码就可能将你的合约毁坏殆尽。比方,内部调用可通过歹意回调,使代码被重复执行,从而毁坏合约状态,这种攻打手法就是驰名的 Reentrance Attack(重放攻打)。

这里,先引入一个重放攻打的小试验,以便让读者理解为什么内部调用可能导致合约被毁坏,同时帮忙更好地了解行将介绍的两种晋升合约安全性的设计模式。

对于重放攻打,这里举个精简的例子。

AddService 合约是一个简略的计数器,每个内部合约能够调用 AddService 合约的 addByOne 来将字段_count 加一,同时通过 require 来强制要求每个内部合约最多只能调用一次该函数。

这样,_count 字段就准确的反馈出 AddService 被多少合约调用过。在 addByOne 函数的开端,AddService 会调用内部合约的回调函数 notify。AddService 的代码如下:

contract AddService{
​
    uint private _count;
    mapping(address=>bool) private _adders;
​
    function addByOne() public {
        // 强制要求每个地址只能调用一次
        require(_adders[msg.sender] == false, "You have added already");
        // 计数
        _count++;
        // 调用账户的回调函数
        AdderInterface adder = AdderInterface(msg.sender);
        adder.notify();
        // 将地址退出已调用汇合
        _adders[msg.sender] = true;   
    }
}
​
contract AdderInterface{function notify() public;  
}

如果 AddService 如此部署,歹意攻击者能够轻易管制 AddService 中的_count 数目,使该计数器齐全生效。

攻击者只须要部署一个合约 BadAdder,就可通过它来调用 AddService,就能够达到攻打成果。BadAdder 合约如下:

contract BadAdder is AdderInterface{
​
    AddService private _addService = //...;
    uint private _calls;
​
    // 回调
    function notify() public{if(_calls > 5){return;}
        _calls++;
        //Attention !!!!!!
        _addService.addByOne();}
​
    function doAdd() public{_addService.addByOne();    
    }
}

BadAdder 在回调函数 notify 中,反过来持续调用 AddService,因为 AddService 蹩脚的代码设计,require 条件检测语句被轻松绕过,攻击者能够直击_count 字段,使其被任意地反复增加。

攻打过程的时序图如下:

在这个例子中,AddService 难以获知调用者的回调逻辑,但仍然轻信了这个内部调用,而攻击者利用了 AddService 蹩脚的代码编排,导致喜剧的产生。

本例子中去除了理论的业务意义,攻打结果仅仅是_count 值失真。真正的重放攻打,可对业务造成严重后果。比方在统计投票数目是,投票数会被改得面目全非。

打铁还需本身硬,如果想屏蔽这类攻打,合约须要遵循良好的编码模式,上面将介绍两个可无效解除此类攻打的设计模式。

Checks-Effects-Interaction – 保障状态残缺,再做内部调用

该模式是编码格调束缚,可无效防止重放攻打。通常状况下,一个函数可能蕴含三个局部:

  • Checks:参数验证
  • Effects:批改合约状态
  • Interaction:内部交互

这个模式要求合约依照 Checks-Effects-Interaction 的程序来组织代码。它的益处在于进行内部调用之前,Checks-Effects 已实现合约本身状态所有相干工作,使得状态残缺、逻辑自洽,这样内部调用就无奈利用不残缺的状态进行攻打了。

回顾前文的 AddService 合约,并没有遵循这个规定,在本身状态没有更新完的状况上来调用了内部代码,内部代码天然能够横插一刀,让_adders[msg.sender]=true 永恒不被调用,从而使 require 语句生效。咱们以 checks-effects-interaction 的角度审阅原来的代码:

    //Checks
    require(_adders[msg.sender] == false, "You have added already");
    //Effects    
    _count++;
    //Interaction    
    AdderInterface adder = AdderInterface(msg.sender);
    adder.notify();
    //Effects
    _adders[msg.sender] = true;

只有略微调整程序,满足 Checks-Effects-Interaction 模式,喜剧就得以防止:

    //Checks
    require(_adders[msg.sender] == false, "You have added already");
    //Effects    
    _count++;
    _adders[msg.sender] = true;
    //Interaction    
    AdderInterface adder = AdderInterface(msg.sender);
    adder.notify(); 

因为_adders 映射曾经批改结束,当歹意攻击者想递归地调用 addByOne,require 这道防线就会起到作用,将歹意调用拦挡在外。

尽管该模式并非解决重放攻打的惟一形式,但仍然举荐开发者遵循。

Mutex – 禁止递归

Mutex 模式也是解决重放攻打的无效形式。它通过提供一个简略的修饰符来避免函数被递归调用:

contract Mutex {
    bool locked;
    modifier noReentrancy() {
        // 避免递归
        require(!locked, "Reentrancy detected");
        locked = true;
        _;
        locked = false;
    }
​
    // 调用该函数将会抛出 Reentrancy detected 谬误
    function some() public noReentrancy{some();
    }
}

在这个例子中,调用 some 函数前会先运行 noReentrancy 修饰符,将 locked 变量赋值为 true。如果此时又递归地调用了 some,修饰符的逻辑会再次激活,因为此时的 locked 属性已为 true,修饰符的第一行代码会抛出谬误。

可维护性(Maintaince)


在区块链中,合约一旦部署,就无奈更改。当合约呈现了 bug,通常要面对以下问题:

  1. 合约上已有的业务数据怎么解决?
  2. 怎么尽可能减少降级影响范畴,让其余性能不受影响?
  3. 依赖它的其余合约该怎么办?

回顾面向对象编程,其核心思想是将变动的事物和不变的事物相拆散,以阻隔变动在零碎中的流传。所以,设计良好的代码通常都组织得高度模块化、高内聚低耦合。利用这个经典的思维可解决下面的问题。

Data segregation – 数据与逻辑相拆散

理解该设计模式之前,先看看上面这个合约代码:

contract Computer{
​
    uint private _data;
​
    function setData(uint data) public {_data = data;}
​
    function compute() public view returns(uint){return _data * 10;}
}

此合约蕴含两个能力,一个是存储数据(setData 函数),另一个是使用数据进行计算(compute 函数)。如果合约部署一段时间后,发现 compute 写错了,比方不应是乘以 10,而要乘以 20,就会引出前文如何降级合约的问题。

这时,能够部署一个新合约,并尝试将已有数据迁徙到新的合约上,但这是一个很重的操作,一方面要编写迁徙工具的代码,另一方面原先的数据齐全作废,空占着贵重的节点存储资源。

所以,事后在编程时进行模块化十分必要。如果咱们将 ” 数据 ” 看成不变的事物,将 ” 逻辑 ” 看成可能扭转的事物,就能够完满避开上述问题。Data Segregation(意为数据拆散)模式很好地实现了这一想法。

该模式要求一个业务合约和一个数据合约:数据合约只管数据存取,这部分是稳固的;而业务合约则通过数据合约来实现逻辑操作。

联合后面的例子,咱们将数据读写操作专门转移到一个合约 DataRepository 中:

contract DataRepository{
​
    uint private _data;
​
    function setData(uint data) public {_data = data;}
​
    function getData() public view returns(uint){return _data;}
}

计算性能被独自放入一个业务合约中:

contract Computer{
    DataRepository private _dataRepository;
    constructor(address addr){_dataRepository =DataRepository(addr);
    }
​
    // 业务代码
    function compute() public view returns(uint){return _dataRepository.getData() * 10;
    }    
}

这样,只有数据合约是稳固的,业务合约的降级就很轻量化了。比方,当我要把 Computer 换成 ComputerV2 时,原先的数据仍然能够被复用。

Satellite – 合成合约性能

一个简单的合约通常由许多性能形成,如果这些性能全副耦合在一个合约中,当某一个性能须要更新时,就不得不去部署整个合约,失常的性能都会受到波及。

Satellite 模式使用繁多职责准则解决上述问题,提倡将合约子性能放到子合约里,每个子合约(也称为卫星合约)只对应一个性能。当某个子性能须要批改,只有创立新的子合约,并将其地址更新到主合约里即可,其余性能不受影响。

举个简略的例子,上面这个合约的 setVariable 性能是将输出数据进行计算(compute 函数),并将计算结果存入合约状态_variable:

contract Base {
    uint public _variable;
​
    function setVariable(uint data) public {_variable = compute(data);
    }
​
    // 计算
    function compute(uint a) internal returns(uint){return a * 10;}
}

如果部署后,发现 compute 函数写错,心愿乘以的系数是 20,就要重新部署整个合约。但如果一开始依照 Satellite 模式操作,则只需部署相应的子合约。

首先,咱们先将 compute 函数剥离到一个独自的卫星合约中去:

contract Satellite {function compute(uint a) public returns(uint){return a * 10;}
}

而后,主合约依赖该子合约实现 setVariable:

contract Base {
    uint public _variable;
​
    function setVariable(uint data) public {_variable = _satellite.compute(data);
    }
​
     Satellite _satellite;
    // 更新子合约(卫星合约)function updateSatellite(address addr) public {_satellite = Satellite(addr);
    }
}

这样,当咱们须要批改 compute 函数时,只需部署这样一个新合约,并将它的地址传入到 Base.updateSatellite 即可:

contract Satellite2{function compute(uint a) public returns(uint){return a * 20;}    
}

Contract Registry – 跟踪最新合约

在 Satellite 模式中,如果一个主合约依赖子合约,在子合约降级时,主合约须要更新对子合约的地址援用,这通过 updateXXX 来实现,例如前文的 updateSatellite 函数。

这类接口属于维护性接口,与理论业务无关,过多裸露此类接口会影响主合约好看,让调用者的体验大打折扣。Contract Registry 设计模式优雅地解决了这个问题。

在该设计模式下,会有一个专门的合约 Registry 跟踪子合约的每次降级状况,主合约可通过查问此 Registyr 合约获得最新的子合约地址。卫星合约重新部署后,新地址通过 Registry.update 函数来更新。

contract Registry{
​
    address _current;
    address[] _previous;
​
    // 子合约降级了,就通过 update 函数更新地址
    function update(address newAddress) public{if(newAddress != _current){_previous.push(_current);
            _current = newAddress;
        }
    } 
​
    function getCurrent() public view returns(address){return _current;}
}

主合约依赖于 Registry 获取最新的卫星合约地址。

contract Base {
    uint public _variable;
​
    function setVariable(uint data) public {Satellite satellite = Satellite(_registry.getCurrent());
        _variable = satellite.compute(data);
    }
​
    Registry private _registry = //...;
}

Contract Relay – 代理调用最新合约

该设计模式所解决问题与 Contract Registry 一样,即主合约无需裸露维护性接口就可调用最新子合约。该模式下,存在一个代理合约,和子合约享有雷同接口,负责将主合约的调用申请传递给真正的子合约。卫星合约重新部署后,新地址通过 SatelliteProxy.update 函数来更新。

contract SatelliteProxy{
    address _current;
    function compute(uint a) public returns(uint){Satellite satellite = Satellite(_current);   
        return satellite.compute(a);
    } 
    
    // 子合约降级了,就通过 update 函数更新地址
    function update(address newAddress) public{if(newAddress != _current){_current = newAddress;}
    }   
}
​
​
contract Satellite {function compute(uint a) public returns(uint){return a * 10;}
}

主合约依赖于 SatelliteProxy:

contract Base {
    uint public _variable;
​
    function setVariable(uint data) public {_variable = _proxy.compute(data);
    }
    SatelliteProxy private _proxy = //...;
}

生命周期(Lifecycle)


在默认状况下,一个合约的生命周期近乎有限——除非赖以生存的区块链被毁灭。但很多时候,用户心愿缩短合约的生命周期。这一节将介绍两个简略模式提前终结合约生命。

Mortal – 容许合约自毁

字节码中有一个 selfdestruct 指令,用于销毁合约。所以只须要暴露出自毁接口即可:

contract Mortal{
​
    // 自毁
    function destroy() public{selfdestruct(msg.sender);
    } 
}

Automatic Deprecation – 容许合约主动进行服务

如果你心愿一个合约在指定期限后进行服务,而不须要人工染指,能够应用 Automatic Deprecation 模式。

contract AutoDeprecated{
​
    uint private _deadline;
​
    function setDeadline(uint time) public {_deadline = time;}
​
    modifier notExpired(){require(now <= _deadline);
        _;
    }
​
    function service() public notExpired{//some code} 
}

当用户调用 service,notExpired 修饰符会先进行日期检测,这样,一旦过了特定工夫,调用就会因过期而被拦挡在 notExpired 层。

权限(Authorization)


前文中有许多管理性接口,这些接口如果任何人都可调用,会造成严重后果,例如上文中的自毁函数,假如任何人都能拜访,其严重性显而易见。所以,一套保障只有特定账户可能拜访的权限管制设计模式显得尤为重要。

Ownership

对于权限的管控,能够采纳 Ownership 模式。该模式保障了只有合约的拥有者能力调用某些函数。首先须要有一个 Owned 合约:

contract Owned{
​
    address public _owner;
​
    constructor() {_owner = msg.sender;}    
​
    modifier onlyOwner(){require(_owner == msg.sender);
        _;
    }
}

如果一个业务合约,心愿某个函数只由拥有者调用,该怎么办呢?如下:

contract Biz is Owned{function manage() public onlyOwner{}}

这样,当调用 manage 函数时,onlyOwner 修饰符就会先运行并检测调用者是否与合约拥有者统一,从而将无受权的调用拦挡在外。

行为管制(Action And Control)


这类模式个别针对具体场景应用,这节将次要介绍基于隐衷的编码模式和与链外数据交互的设计模式。

Commit – Reveal – 提早机密泄露

链上数据都是公开通明的,一旦某些隐衷数据上链,任何人都可看到,并且再也无奈撤回。

Commit And Reveal 模式容许用户将要爱护的数据转换为不可辨认数据,比方一串哈希值,直到某个时刻再揭示哈希值的含意,展露真正的原值。

以投票场景举例,假如须要在所有参与者都实现投票后再揭示投票内容,以防这期间参与者受票数影响。咱们能够看看,在这个场景下所用到的具体代码:

contract CommitReveal {
​
    struct Commit {
        string choice; 
        string secret; 
        uint status;
    }
​
    mapping(address => mapping(bytes32 => Commit)) public userCommits;
    event LogCommit(bytes32, address);
    event LogReveal(bytes32, address, string, string);
​
    function commit(bytes32 commit) public {Commit storage userCommit = userCommits[msg.sender][commit];
        require(userCommit.status == 0);
        userCommit.status = 1; // comitted
        emit LogCommit(commit, msg.sender);
    }
​
    function reveal(string choice, string secret, bytes32 commit) public {Commit storage userCommit = userCommits[msg.sender][commit];
        require(userCommit.status == 1);
        require(commit == keccak256(choice, secret));
        userCommit.choice = choice;
        userCommit.secret = secret;
        userCommit.status = 2;
        emit LogReveal(commit, msg.sender, choice, secret);
    }
}

Oracle – 读取链外数据

目前,链上的智能合约生态绝对关闭,无奈获取链外数据,影响了智能合约的利用范畴。

链外数据可极大扩大智能合约的应用范畴,比方在保险业中,如果智能合约可读取到事实产生的意外事件,就可主动执行理赔。

获取内部数据会通过名为 Oracle 的链外数据层来执行。当业务方的合约尝试获取内部数据时,会先将查问申请存入到某个 Oracle 专用合约内;Oracle 会监听该合约,读取到这个查问申请后,执行查问,并调用业务合约响应接口使合约获取后果。

上面定义了一个 Oracle 合约:

contract Oracle {
    address oracleSource = 0x123; // known source
​
    struct Request {
        bytes data;
        function(bytes memory) external callback;
}
​
    Request[] requests;
    event NewRequest(uint);
    modifier onlyByOracle() {require(msg.sender == oracleSource); _;
    }
​
    function query(bytes data, function(bytes memory) external callback) public {requests.push(Request(data, callback));
        emit NewRequest(requests.length - 1);
    }
​
    // 回调函数,由 Oracle 调用
    function reply(uint requestID, bytes response) public onlyByOracle() {requests[requestID].callback(response);
    }
}

业务方合约与 Oracle 合约进行交互:

contract BizContract {
    Oracle _oracle;
​
    constructor(address oracle){_oracle = Oracle(oracle);
    }
​
    modifier onlyByOracle() {require(msg.sender == address(_oracle)); 
        _;
    }
​
    function updateExchangeRate() {_oracle.query("USD", this.oracleResponse);
    }
​
    // 回调函数,用于读取响应
    function oracleResponse(bytes response) onlyByOracle {// use the data}
}

总结


本文的介绍涵盖了安全性、可维护性等多种设计模式,其中,有些偏原则性,如 Security 和 Maintaince 设计模式;有些是偏实际,例如 Authrization,Action And Control。

这些设计模式,尤其实际类,并不能涵盖所有场景。随着对理论业务的深刻摸索,会遇到越来越多的特定场景与问题,开发者可对这些模式提炼、升华,以积淀出针对某类问题的设计模式。

上述设计模式是程序员的无力武器,把握它们可应答许多已知场景,但更应把握提炼设计模式的办法,这样能力从容应对未知领域,这个过程离不开对业务的深刻摸索,对软件工程准则的深刻了解。

下期预报

FISCO BCOS 的代码齐全开源且收费
下载地址:https://github.com/FISCO-BCOS…

正文完
 0