写点什么

智能合约编写之 Solidity 的设计模式

2020 年 4 月 03 日

智能合约编写之 Solidity的设计模式

前 言

随着区块链技术发展,越来越多的企业与个人开始将区块链与自身业务相结合。


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


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


幸运的是,通过近些年 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,通常要面对以下问题:


  • 合约上已有的业务数据怎么处理?

  • 怎么尽可能减少升级影响范围,让其余功能不受影响?

  • 依赖它的其他合约该怎么办?


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


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 核心开发者。


2020 年 4 月 03 日 11:371038

评论

发布
暂无评论
发现更多内容

解析 CloudQuery 审计分析功能

CloudQuery社区

数据库 sql 安全 工具软件

吃透阿里大佬整理的Java面试要点手册,成功五面进阿里(二本学历)

Java架构追梦

Java 学习 架构 面试 核心知识点整理

Flink窗口算子-6-8

小知识点

scala 大数据 flink

Go语言内存管理三部曲(三)图解GC算法和垃圾回收原理

网管

go 内存管理 垃圾回收 GC GC算法

typora增强-mac

老菜鸟

Typora

数据结构与算法系列之链表操作全集(一)(GO)

书旅

go 数据结构 数据结构和算法

mPaaS x Menxlab | 1024程序员节:Talk is cheap,Show me the AppID

蚂蚁集团移动开发平台 mPaaS

程序员 开发者 mPaaS 1024

机器学习是什么?

马同学

学习

千万不要往 Shell 里粘贴命令!

大道至简

命令行

Nginx 在运维领域中的应用,看这一篇就够了

华章IT

nginx Linux 运维工程师

架构师必备的那些分布式事务解决方案!!

架构师修行之路

分布式 微服务 架构设计

【线上排查实战】AOP切面执行顺序你真的了解吗

Zhendong

spring aop

vivo 商城前端架构升级—前后端分离篇

vivo互联网技术

Java 前端 前后端分离

学了那么多 NoSQL 数据库 NoSQL 究竟是啥

哈喽沃德先生

数据库 nosql 非关系型数据库

一个草根的日常杂碎(10月19日)

刘新吾

随笔杂谈 生活记录 社会百态

一个草根的日常杂碎(10月20日)

刘新吾

随笔杂谈 生活记录 社会百态

深入java week1-01 字节码、内存、GC、调试工具

闷骚程序员

一个草根的日常杂碎(10月18日)

刘新吾

随笔杂谈 生活记录 社会百态

央行数字货币离我们还有多远?

CECBC区块链专委会

数字货币

架构必修:领域边界划分方法--职责驱动设计(RDD)

马迪奥

架构 领域 架构师 RDD

架构师训练营 1 期 -- 第五周作业

曾彪彪

极客大学架构师训练营

利用区块链等技术,加强对交通运输信用信息的归集共享和分析应用

CECBC区块链专委会

区块链 交通运输

java week1练习

闷骚程序员

年纪轻轻怎么就卵巢早衰了?试管可帮忙!

Geek_65d32f

试管 三代试管

Microsoft Azure机器学习采用NVIDIA AI为Word编辑器提供语法建议

Geek_459987

分布式下,我想要一致性

架构师修行之路

分布式 微服务

Linux的上手命令

林昱榕

Linux 常用命令

甲方日常 35

句子

工作 随笔杂谈 日常

二十、深入Python迭代器和生成器

刘润森

Python

攻克金融系统开发难点,借助SpreadJS实现在线导入Excel自定义报表

Geek_Willie

SpreadJS 在线导入excel

AI让远程交流“更清晰”:GAN消除视频通话中的抖动

Geek_459987

智能合约编写之 Solidity的设计模式-InfoQ