ASCH智能合约平台致力于成为一个开发者可以快速上手的安全、高效的智能合约开发和运行平台
1.3.1. 隔离的合约执行进程和存储
基于安全性的考虑,合约代码执行运行在与ASCH链相隔离的独立进程中,虽然带来了引擎本身的复杂度,但好处是明显的。智能合约的恶意代码不会造成ASCH链的数据,修改合约状态只能通过指定的交易才可以实现,恶意代码也难以通过接口直接非法篡改合约状态。同时每个合约也运行在独立的Sandbox中,合约代码无法修改其他合约的状态。
1.3.2. 基于Typescript子集的合约语言
智能合约语言的选择首先要做的决策是设计一门全新的智能合约语言(例如:以太坊的Solidity)还是使用一个现有的语言(例如:以太坊的Vyper)。由于智能合约是一种DSL,理论上来说设计一个新语言是最合适的方式。但设计一个新语言不仅周期长、实现风险高,更重要的是对开发人员来说学习和迁移成本更高。而且设计一个全新的语言同时还需要一个全新的运行环境,可能会引入大量的bug。
另一方面从语言本身来说,现在流行的主流语言特性是趋同的。基于上述两方面原因,ASCH智能合约平台选择使用已有的成熟语言,而不是设计一个新的合约语言(正因为上述原因,以太坊平台上的新的智能合约语言Vyper是基于Python的)。
对开发人员来说,选择语言需要同时考虑和语言相关的框架、类库等生态。而在智能合约平台中,提供的类库等工具是一样的;支持多语言只是语法上的差别,对实际开发影响并不大。故ASCH智能合约平台暂不支持多语言。
WebAssembly是目前呼声很高的智能合约中间语言,理论上支持使用多种语言编写然后编译成WebAssembly在引擎中运行。WebAssembly相对原生Javascript来说是一种执行效率更高的方案。通过调研我们发现:目前相对成熟的是Rust和C++语言编译成WebAssembly,其他语言的WebAssembly环境中的配套工具严重匮乏,会导致合约编写困难;而Rust和C++对于开发人员来说门槛高、不友好。所以我们选择了Typescript作为合约编写语言编译成Javascript在Node.js引擎环境中运行。在WebAssembly环境成熟后,可以将合约编译成WebAssembly在Node.js中运行,开发人员不需要对合约进行调整。
TypeScript是一种由微软开发的自由和开源的编程语言,它是JavaScript的一个超集,由实现了静态类型和基于类的面向对象编程。由Anders Hejlsberg设计(他同时还是Delphi和.NET平台的设计师),Typescript借鉴了许多C#、Java等现代语言的优点,相对Javascript来说具有更为安全、更完善的工具支持、代码更易维护的特点。很多的流行的框架(如:AngularJS、ReactJS、Vue等)都基于Typescript开发。ASCH智能合约平台使用Typescript语言作为智能合约编写语言可以实现对合约代码的有效性检查、减少智能合约编写过程中的bug,通过开发工具的智能提示提升开发效率。
1.3.3. 完全自动的状态管理
合约状态管理是智能合约引擎中一个重要的部分,部分平台的做法是提供底层的持久化接口给开发人员自己手动管理状态,但让开发人员需要关注持久化的细节,这样就增加了不必要的复杂性。在ASCH智能合约中状态的持久化是透明的、自动完成的,开发人员只需要对状态变量进行赋值即可,不需要考虑持久化的细节。这样让开发人员把注意力集中在合约的逻辑本身上,降低合约的开发难度,提高合约代码的可理解和可维护性。
ASCH智能合约语言是Typescript语言的子集,下面是一个简单的例子:
2.1.1. 一个简单智能合约样例
const CURRENCY = 'XAS' const EMPTY_ADDRESS = '' const MAX_AMOUNT = BigInt(1000 * (10 ** 8)) // 自定义状态类型 class PayState { // 转账次数 payTimes: number // 转账总额 amount: bigint constructor() { this.payTimes = 0 this.amount = BigInt(0) } } // 数据接口类型 interface MaxAmountInfo { address?: string amount?: bigint payTimes?: number } // 合约类 export class TestContract extends AschContract { // 合约收到的转账, 公开属性 payStateOfAddress: Mapping<PayState> // 最大转账的地址,私有状态,外部不可查询 private maxAmountAddress = EMPTY_ADDRESS // 收到的转账总额 private total = BigInt(0) // 初始化方法 constructor() { super() this.payStateOfAddress = new Mapping<PayState>() this.total = BigInt(0) } // 默认向合约转账自动调用的方法 @payable({ isDefault : true }) onPay(amount: bigint, currency: string) { assert( currency === AVAIBLE_CURRENCY, `Support ${CURRENCY} only` ) assert( amount > 0 && amount < MAX_AMOUNT , `Amount should greater than 0 and less than ${MAX_AMOUNT}`) const address = this.context.senderAddress const newAmount = this.payXAS(amount, address) if (this.getMaxAmount() < newAmount) { this.maxAmountAddress = address } } @constant getMaxInfo(): MaxAmountInfo { const address = this.maxAmountAddress if (address === EMPTY_ADDRESS) return { } const { payTimes, amount } = this.payStateOfAddress[address]! return { address, payTimes, amount } } @constant getTotal(): bigint { return this.total } // 内部方法,外部不可访问(下同) private payXAS(amount: bigint, address: string) : bigint { let payState = this.payStateOfAddress[address] if (!payState) { payState = new PayState() this.payStateOfAddress[address] = payState } payState.payTimes += 1 payState.amount += amount this.total += amount return payState.amount } private getMaxAmount() : bigint { return (this.maxAmountAddress === EMPTY_ADDRESS) ? BigInt(0) : this.getPayInfo(this.maxAmountAddress).amount } private getPayInfo(address: string) : PayState { return this.payStateOfAddress[address] || new PayState() } }
上述合约代码实现了一个简单的智能合约,这个合约的功能是接收转账并记录下转账人转账次数和转账总额,同时记录下最大的转账人地址。熟悉Typescript/Javascript/C#/Java等语言的开发者可以会发现读起来几乎没有障碍,非常容易理解。下面我们来详细了解一下这个合约的结构和约定:
2.1.2. 合约结构
通过上面的代码我们可以看到,一个标准的智能合约包括四大部分:
2.1.2.1. 常量定义
一个合约文件中可以有多个常量声明,使用const
关键字声明,需要注意的是:常量只能使用四种简单类型(string
、number
、bigint
、boolean
),其他类型包括object
,any
等都不支持,例:
const DEFAULT_NAME = 'name' const DEFAULT_INTEVEL = 3 * 1000
2.1.2.2. 数据接口定义
一个合约文件中可以有多个数据接口类型声明,数据接口类型用于公开方法的参数及返回值,使用interface
关键字声明,限制如下:
Array
、数据接口类型,成员可以是可选的(使用?
语法声明)string & number
和string | number
)Array
,必须指定泛型参数。泛型类型可以是简单类型、Array
、数据接口类型,泛型类型参数如果本身不是泛型推荐使用简写形式(如:names: string[]
)inteface Data<T> {...}
)readonly
)示例:
interface AddressInfo { province: string city: string street: string } interface PersonInfo { name: string age?: number sex: boolean address: Address } interface PeopleResultInfo { count: number pepole: PersonInfo[] }
2.1.2.1. 状态类定义
一个合约文件中可以有多个状态类型声明,状态类型用于合约状态,类似于传统的POJO
使用class
关键字声明,限制如下:
Mapping<T>
(类似于Map<stirng,T>
)或Vecotr<T>
(类似于Array<T>
),状态容器中的数据会自动持久化?
语法定义),可以初始化默认值。除可选成员外的所有成员属性必须通过默认值或构造器初始化static
)且可见性为公开(public
可省略),不支持private
, protected
class StateData<T> {...}
)abstract
)implements
语法)和继承(不支持extends
语法)readonly
)getter
和setter
undefined
,随后再初始化各个成员属性)。引擎在调用构造函数时不能产生异常,否则会导致合约加载失败示例:
class PayState { payTimes: number amount: bigint constructor() { this.payTimes = 0 this.amount = BigInt(0) } } class PayStateDefault { payTimes = 0 amount = BigInt(0) } class PayStateOptional { payTimes = 0 amount?: bigint }
2.1.2.2. 合约类定义
一个合约文件中必须有且仅有一个合约类定义,使用class
关键字定义,合约类必须是AschContract
的子类。合约类只允许合约状态和方法两类成员,基本要求如下:
export
关键字修饰AschContract
直接继承,不支持多重继承abstract
)implements
语法)getter
和setter
static
)下面来逐个介绍合约中两类成员的具体规范
合约状态 合约状态是可以自动进行持久化的合约成员属性。开发者只需要给合约的成员属性赋值,引擎会自动把这些状态持久化到区块链中,对于合约状态来说:
undefined
)合约方法
合约类中的方法都必须是成员方法(不支持static
),不支持异步语法(Promise
、async/await
)和生成器语法(generator
)。可分为以下几类
payable
注解)constant
注解)private
或protected
)下面来逐个介绍具体规则:
(a)构造器
一个合约只能有一个构造器,是合约类的初始化方法,名称必须为constructor
,仅在合约注册时执行一次,具体要求如下:
constructor() {...}
,没有参数也没有返回值this.context
this.transfer
,否则会产生异常导致合约无法注册(因为合约注册时,合约账户没有任何资产)(b)可调用方法
一个合约可以有多个可调用方法,是合约类中可见性为公开的,且没有注解修饰的成员方法,具体要求如下:
Array
、数据接口类型之一Array
,必须指定泛型参数。泛型类型可以是简单类型、Array
、数据接口类型,泛型类型参数如果本身不是泛型推荐使用简写形式(如:names: string[]
)...args: string[]
)this.context
和this.transfer
(如合约账户余额不足,则会失败)(c)资产接收方法
一个合约可以多个资产接收方法,资产接收方法是使用payable
注解的公开方法,用于接收调用转入智能合约的资产,要求如下:
bigint
string
payable
有一个可选参数,类型为{ isDefault?: boolean }
,用于表示是否是默认的资产接受方法(使用@payable({ isDefault: true })
注解)。一个合约中最多只能有一个默认资产接受方法this.context
和this.transfer
(如合约账户余额不足,则会失败)(d)查询方法
一个合约可以有多个查询方法,资产接收方法是使用constant
注解的公开方法,用于实现状态查询等只读状态的计算逻辑,具体要求:
Array
、数据接口类型之一this.context
和this.transfer
,否则会失败(e)内部方法
一个合约可以有多个内部方法,可见性为保护(protected
)或私有(private
,推荐),具体要求:
constant
、payable
注解2.1.3. 智能合约其他语法约定
智能合约语言是Typescript语言的子集,除上节描述的结构约定外,其他主要限制如下:
Symbol
null
、any
、never
、object
、unknown
等类型,undefined
可以使用string & number
)和联合类型(如string | null
)作为公开方法的参数或返回类型Promise
、async/await
)<string>name
及name as string
)AschContract
继承而来Function
、Date
都是不可用的)try...catch
语法,也不允许使用throw
语句。任何时候抛出异常(如使用assert
语句)即导致中止合约JSON
传递,故只支持可序化的类型(可参考数据接口类的定义)基于效率考虑,全部参数或返回值序列化后的JSON
字符串长度应控制在32K
以内(length <= 32,767
)Mapping<bigint>
深度称为 1,Vector<Mapping<number>>
深度为2;简单自定义类型本身深度为1,包含一个深度为1的容器类型或自定义状态类型深度为2;以此类推2.2.1 内置类型
2.2.1.1. 简单类型
简单类型为number
、bigint
、string
和boolean
,这四种类型的行为与Javascript/Typescript环境中的行为是一致的。
2.2.1.2. 状态容器类型(Mapping
、Vector
)
Mapping
的行为与以太坊solidity中的mapping
接近,类似Javascript中的Object
,是一个可以通过key
以下标方式来访问的对象容器Vector
的行为与以太坊solidity中的Array
接近,只可以在最后push
或pop
或通过下标(下标必须0或正整数)访问的数组Mapping<bigint>
、Vector<string>
、Mapping<User>
。泛型参数可以是简单类型、状态容器类型或状态类型。2.2.1.3. 其他内置类型
AschContract
AschContract
是智能合约类的基类,包括两个重要的成员:context
属性和transfer
方法
context
属性,是合约调用时环境参数信息。包括三个成员:
transaction
对象,包含合约调用的交易相关信息block
对象,待打包区块信息lastBlock
对象,包括上一区块的区块头信息senderAddress
属性,调用者的地址sender
对象,包括调用者的相关信息请注意,如果在合约中使用了:block
对象 与lastBlock
对象。请一定要了解,调用合约时的这两个对象,与合约被打包到区块中的结果不一定一致。因为在调用时,这两个对象是当前节点根据共识机制推测的结果,不代表最终打包到区块中的结果。
transfer
方法,原型为:
function transfer(toAddress: string, amount: bigint, currency: string): void
该方法可以实现将合约账户的余额转账到指定的账户地址中,该余额记录在Asch链的区块链数据库中,可以通过Asch链接口进行查询。参数信息如下
toAddress
类型为string
,接收人地址amount
类型为bigint
,转账金额currency
类型为string
,资产名称ArrayBuffer
同Node.js中的ArrayBuffer
BufferView
同Node.js中的BufferView
Array
同Node.js中的Array
2.2.2. 工具类/函数
assert
函数,原型为:function assert(condition: boolean, error: string): void
该函数合约方法中使用,用来检查合约执行的前置条件是否满足,如条件不满足(condition === fasle
)会抛出异常,导致合约终止。
log
函数,原型为:function log(...args: any[]): void
该函数用于输出调试日志 (请在节点的配置文件config.json
中的日志级别设置成'debug',否则日志不显示),日志位于logs/contracts/log_yyyyMMdd.log
Crypto
和util
外,基本与原生功能保持一致:
Array
ArrayBuffer
BufferView
String
Number
Object
Math
Bigint
Crypto
Util
工具类及函数的详细说明请参见《Gas计费与内置函数》
编写智能合约代码对可读性和安全性要求比普通的程序要高很多,所以编写一个好的智能合约不仅要求语法正确可以正常编译运行。更多需要考虑可维护性、可验证、安全性等问题,应遵循通用的高维护性、高安全性要求的软件开发规范及模式。下述内容是一些相对特殊的约定:
使用契约式开发的理念来编写合约代码,任何操作之前应检查前置条件是否成立(使用assert
函数)。所有的前置条件都检验通过再执行逻辑、修改状态。
尽管ASCH智能合约平台的每个合约方法的执行是原子的,我们仍然需要遵循先修改状态再调用转账这种顺序来编写代码(代价越高的操作越靠后)。
避免在合约中发行资产,而使用ASCH内置的发行资产功能。这是ASCH智能合约和以太坊智能合约一个重要的区别。在以太坊中一般在合约中发行资产,状态记录在合约中。而ASCH链上,资产作为第一位概念。链上拥有标准的数字资产发行接口,可以通过图形化的操作快速、安全的发行数字资产;这样发行的数字资产可以用标准的转账接口进行转账,也可以通过区块链浏览器查询相应的交易。如需自己发行的资产实现众筹功能,可参考第4节的众筹合约样例。
合约类型可以有一个无参的构造函数(可省略) 该方法仅在合约初始化时调用一次,一般在此函数中完成合约状态的初始化工作。虽然可以通过普通合约方法配合context.senderAddress
实现状态的初始化,但使用构造函数的语义更易于理解。
可接受转账的方法,原型为
@payable({ isDefalut: true }) function payableMethod(amount: bigint, currency: string): void
注:@payable
注解中的参数{ isDefalut: true }
是可选的(默认是false
),上例所示的是默认转账接收函数(向合约转账时不指定接收方法时默认的接收方法)。开发者应在合约中存储转入合约的资产数额,这样可以在合约内部确定合约账户本身的余额,避免在调用transfer
时导致余额不足而失败。
合约对象本身、合约内部状态和内置对象皆是不可扩展的,增加、修改、删除属性会产生异常
建议使用内部方法封装低层次的实现细节,外部可访问合约代码中应是统一的高逻辑层次的合约代码
一个合约方法应当是易于理解和验证,一般一个合约方法的循环复杂度应控制在10以内,且有效内容不宜超过15行
除合约类外,不使用export
语句,尽管语法上不会出错
不使用public
,因为缺省可见性为public
;使用private
而不是protected
,因为private
更语义化
合约类的构造函数必须是没有参数的,构造函数用于合约初始化,仅在合约注册时被引擎自动调用。尽管可以使用缺省的构造函数,但最好显式的声明一个
尽管智能合约引擎支持复杂状态类型的嵌套(嵌套深度不可超过3),请尽量减少这么做,因为可读性会因些而大大降低
智能合约代码应是可读性高、结果确定性高的的代码,避免实现过于灵活的功能。如:在一个众筹合约中,应该在构造函数中初始化众筹的币种、数量、有效期和成功条件等,这些条件不应是动态的
涉及数字资产等可能值比较大的数值时,尽可能使用bigint
,ECMA的标准中采用IEEE-754标准来处理number
(请参见ECMA262),存在最大值限制(Number.MAX_SAFE_INTEGER
= 9,007,199,254,740,991,约 9 X 10^15 )和浮点精度问题(请参见IEEE754 wiki )
对智能合约的计费相关主要包括三个方面,分别是:
合约代码运行所需要的Gas,每一行代码会根据代码的不同进行计费,如:
const variable = 200
上述代码是一个声明变量变赋值的语句,需要消耗3个Gas。
内置函数所需要消耗的Gas,不同的函数需要消耗的Gas数量不完全相同。
存储合约状态(包括合约代码保存)所消耗的存储资源所消耗的Gas。
具体计费规则细节请参见Gas计费与内置函数。
下面的样例智能合约实现了一个通过自己发行的数字资产(XXT)实现众筹的功能。由于ASCH平台的拥有强大的一键发行数字资产的功能,所以资产发行功能通过ASCH链的在线客户端直接完成,具体资产发行过程请参见发行数字资产
const SPONSOR = 'SponsorAddress' //发起人地址 const OFFERING_TOKEN = 'test.XXT' //众筹得到的Token interface FundingInfo { tokenAmount: bigint xasAmount: bigint bchAmount: bigint } class Funding { // 众筹得到的token数量 tokenAmount: bigint // 参与众筹XAS数量 xasAmount: bigint // 参与众筹BCH数量 bchAmount: bigint constructor() { this.tokenAmount = BigInt(0) this.xasAmount = BigInt(0) this.bchAmount = BigInt(0) } } // 众筹合约类 export class SimpleCrowdFundgingContract extends AschContract { // 记录每个地址的众筹信息 fundingOfAddress: Mapping<Funding> // 兑换比例 rateOfCurrency: Mapping<bigint> // 总可众筹token数量 totalFundingToken: bigint // 剩余可众筹数量 avalibleTokenAmount: bigint // 初始化方法,会在合约注册时被调用 constructor() { super() this.rateOfCurrency = new Mapping<bigint>() this.rateOfCurrency['XAS'] = BigInt(100) // 1 XAS = 100 token this.rateOfCurrency['BCH'] = BigInt(30000) // 1 BCH = 30000 token this.totalFundingToken = BigInt(0) this.avalibleTokenAmount = BigInt(0) this.fundingOfAddress = new Mapping<Funding>() } // 发起人初始注入token,只允许注入一次 @payable payInitialToken(amount: bigint, currency: string): void { assert(this.context.senderAddress === SPONSOR, `invalid sponsor address`) assert(currency === OFFERING_TOKEN, `invalid offering currency, should be ${OFFERING_TOKEN}`) assert(this.totalFundingToken === BigInt(0), `initial ${OFFERING_TOKEN} has paied`) this.totalFundingToken = amount this.avalibleTokenAmount = amount } // 众筹逻辑 @payable({ isDefault: true }) crowdFunding(amount: bigint, currency: string) { assert(amount >= 0, 'amount must great than 0') assert(currency === 'XAS' || currency === 'BCH', `invalid currency '${currency}', please pay XAS or BCH`) const rate = this.rateOfCurrency[currency]! const tokenAmount = amount * rate assert(this.avalibleTokenAmount >= tokenAmount, `insuffient ${OFFERING_TOKEN}`) this.avalibleTokenAmount = this.avalibleTokenAmount - tokenAmount const partnerAddress = this.context!.senderAddress this.updateFunding(partnerAddress, amount, currency, tokenAmount) // 调用ASCH链转账 this.transfer(partnerAddress, tokenAmount, OFFERING_TOKEN) } @constant getFunding(address: string): FundingInfo { return this.fundingOfAddress[address] || new Funding() } private updateFunding( address: string, amount: bigint, currency: string, tokenAmount: bigint) : void { const funding = this.getOrCreateFunding(address) funding.tokenAmount += tokenAmount if (currency === 'XAS') { funding.xasAmount += amount } else if (currency === 'BCH') { funding.bchAmount += amount } } private getOrCreateFunding( address: string ) : Funding { if (this.fundingOfAddress[address] === undefined) { this.fundingOfAddress[address] = new Funding() } return this.fundingOfAddress[address]! } }
注:该合约代码是为了演示ASCH平台上开发智能合约的一样例代码,供ASCH平台开发者学习智能合约的开发,请注意不要用于学习以外的用途。
4.3.1. 合约开发环境准备
4.3.2. 部署合约
4.3.3. 智能合约相关交易接口
请参见
4.3.4. 合约状态查询
简单状态查询
智能合约中public
的状态可以通过HTTP GET
接口进行查询,查询地址为:{serverAddress}/api/v2/contracts/{contractName}/states/{statePath}
。注意,本接口仅能查询基本类型的数据,复杂类型请通过状态查询函数来查询。 请参见查询智能合约公开的状态
使用查询方法查询状态 智能合约中通过@constant
注解修饰的方法为状态查询函数,状态查函数的返回值应是基本类型、简单自定义类型、基本类型及简单自定义类型构成的数组。且序列化为JSON
后的字符串长度小于32K 状态查询函数通过HTTP POST
接口访问,访问地址为:{serverAddress}/api/v2/contracts/{contractName}/constant/{method}
请参见使用智能合约查询方法
有没有更方便的调用合约和查询状态的方法 asch-web
是对ASCH链上接口的封装,可自动生成合约方法的代理,使用方便。请参见asch-web使用指南
Gas具体是如何计算的 Gas计费是一个比较复杂的过程,主要有两大部分:一是存储调用合约的交易以及合约结果,根据其存储所需要的空间计费。二是合约运行过程每条指令所消耗的计算资源计费。详细规则请参见《Gas计费与内置函数》
Gas扣费规则 在合约注册时,可以指定是否优先消耗合约开发者的能量,如果设置为优先消耗开发者的能量,则在开发者能量足够的情况下优先使用开发者的能量作为智能合约的消耗;如果未设置或开发者能量不足,则消耗调用者的能量,当调用者的能量不足时,可选择使用XAS
作为智能合约的能量消耗。目前,1 XAS (100,000,000wei) = 10,000 GAS
gasLimit是什么,该传入多少?
gasLimit是一次智能合约访问所最大能消耗的Gas数量,Gas用正整数表示。系统限制一次合约调用所能使用的gasLimit范围为:10000000 >= gasLimit >= 合约调用所消耗的Gas
我如何知道一次合约调用会消耗多少Gas?
由于合约代码是动态的,无法精确估计具体Gas的数量,需要开发人员准备模拟环境测试。一般来说, 一次智能合约调用所消耗的Gas = 交易存储 + 合约代码执行 + 合约状态存储 + 其他消耗,存储一个字符长度消耗的Gas为 2 ,代码执行请参考《Gas计费与内置函数》估算。 如需准备计算Gas,有几点特殊情况需要考虑:
如何知道智能合约调用的结果
合约调用的返回结果中包含合约是否调用成功,Gas消耗情况等内容。请注意,这个结果不一定是最终结果!!!,由于区块链本身的特点决定,交易被打包到区块中时结果才是相对可靠的,可以通过查询接口或asch-web
相应的接口查询到交易执行的结果。当区块经过超过6个区块以上的确认后。才可以认为是确定的。