v0.3Report
#
Ask! v0.3 Report12 weeks ago, Patract submitted #101 treasury proposal of Ask! for proposed features including implementation designs, principles and process. In v0.3 we are implementing the following features:
Goals for ask! v0.3: providing cli management tools and better ask! coding experience and execution performance new project management tool:
ask-cli
better execution performance system parameter types in custom env unit test and documentation
The code of implementation is in our git repo Ask!. For contract examples written in Ask!, please refer examples. You can check documentation on docs.patract.io. Please review it on branch v0.3-review as it will be merged into master later.
#
Design and implementationsBased upon Ask! v0.2, we introduced ask-cli
as the the command line tool to manage contract development. We optimized the Ask! execution performance. Additionally, we also provided related documentations.
ask-cli
#
New Project Management Command-line Tool ask-cli
is the Ask! command-line tool for managing the contract compilation lifecycling.
It provides init
and compile
functions:
ask-cli init
:init
is used for initializing Ask! contract projects.init
read from Dependencies for latest project and updates corresponding NPM packages and the creates local directory structured like:
.βββ buildβββ contractsβββ node_modulesβββ package.json
Inside the dir, contracts
contains the source code of contract. build
is generated once compilation is done and contains .wasm
and metadata.json
. If the compilation is done in debug
mode, build
may contain other files as well.
ask-cli compile [--release|--debug] contracts/Hello.ts
:compile
is used to compile targeting file of contract source code and generate.wasm
andmetadata.json
inbuild
directory.compile
contains two compiling modes:release
anddebug
. While--release
is the defaul option to compile under highest level of optimization and compression.--debug
is the debug mode which will generate other files created in compilation.
For detailed usages, please refer related chapters in QuickStart.
#
Performance optimization#
2.1 Merging the functions of @storage into @contract, simplifying the process of state variable definition.Before v0.3, state variables are defined seperately in @storage, which does't support contract inheritence well. Therefore in v0.3, we put stated varaible definitions directly in @contract and removed @storage.
In v0.2, before we define @contract
class, we need to define @storage
class, then define storage
property in @contract
. However, storage
is a property in @contract
class. If we want to add storage property in child class during inheritence, we would redefine the @storage
class. in v0.3, for inheritence, if we want to add a variable we can simply add it in subclass.
eg. in v0.2:
For inheritence with extra properties in @stroage
in v0.2:
@storageclass ERC20StoragePausable extends ERC20Storage{ is_pausable: bool;}
@contractexport class ERC20Pausable { private storage: ERC20StoragePausable;}
In v0.3
@contractexport class ERC20Pausable extends ERC20 { @state is_pausable: bool = false;}
At the same time, we introduced @state decorator to mark the specific member variable as state variable while the ones not decorated are class variables. In v0.2, all variables are default as blockchain state variables. Since we moved @storage
into @contract
class for better inheritence, we now have to sperate blockchain state variables and normal class properties by having @state
decorator.
@storageclass ERC20Storage { balances: SpreadStorableMap<AccountId, UInt128>; allowances: SpreadStorableMap<AccountId, SpreadStorableMap<AccountId, UInt128>>;
totalSupply: u128; name: string; symbol: string; decimal: u8;}
@contractexport class ERC20 { private storage: ERC20Storage;}
In v0.3, we now define storage directly inside the @contract
class with @state
:
@contractexport class ERC20 { @state balances: SpreadStorableMap<AccountId, UInt128> = new SpreadStorableMap<AccountId, UInt128>(); @state allowances: SpreadStorableMap<AccountId, SpreadStorableMap<AccountId, UInt128>> = new SpreadStorableMap<AccountId, SpreadStorableMap<AccountId, UInt128>>();
@state totalSupply: u128 = u128.Zero; @state name_: string = ""; @state symbol_: string = "" @state decimal_: u8 = 0;}
hash(string)
#
2.2 Optimize key generation logic for storing state variables. Use sequential hash data instead of hash data generated by dynamic While a contract is inherited, all @state decorated variables are sorted by their definition order and baseclass/subclass relationship. And the order sequence number will serve as the id of state changes in storages.
- First, the compiler will first locate the entry of the contract
this.program.elementsByName.forEach((element, _) => { let contractNum = 0; if (ElementUtil.isTopContractClass(element)) { contractNum++; this.contract = new ContractInterpreter(<ClassPrototype>element); } });
Then iterate through the base classes to push the objects to be stored into stack:
private resolveBaseClass(classPrototype: ClassPrototype): void { if (classPrototype.basePrototype) { let basePrototype = classPrototype.basePrototype; basePrototype.instanceMembers && basePrototype.instanceMembers.forEach((instance, _) => { if (ElementUtil.isField(instance)) { let fieldDef = new FieldDef(<FieldPrototype>instance); if (!fieldDef.decorators.ignore) { this.storeFields.push(fieldDef); } } }); this.resolveBaseClass(basePrototype); }}
When a new @contract
class inherits from parent @contract
as a child class, the new @state
properties defined in child class will also be sequence.
@contractexport class ERC20 { @state balances: SpreadStorableMap<AccountId, UInt128> = new SpreadStorableMap<AccountId, UInt128>(); @state allowances: SpreadStorableMap<AccountId, SpreadStorableMap<AccountId, UInt128>> = new SpreadStorableMap<AccountId, SpreadStorableMap<AccountId, UInt128>>();
@state totalSupply: u128 = u128.Zero; @state name_: string = ""; @state symbol_: string = "" @state decimal_: u8 = 0;}
Adding a new class property with @state
class MyToken extends ERC20 { @state is_paused:bool = false;}
In the compiled metadata.json
, we can the new @state is_paused
is sequenced correctly under inheritence:
{ "name": "symbol_", "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000005", "ty": 1 } }, { "name": "decimal_", "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000006", "ty": 2 } }, { "name": "is_pause", "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000007", "ty": 5}}
seal_set_stroage
calls#
2.3 During a contract message call, when a state variable is mutated multiple times, reduce the number of @state
introduces lazy
option as: @state({"lazy": false})
While lazy
is true
, that means while a state variable gets changed multiple times in a contract call, only the last change will be synced to blockchain. The default value of lazy
is true. While lazy
is false
, then every change made to the state variable will be synced to blockchain.
Basic principle of implmentation:
For every state varible with lazy
set as true
, the setter
function generated by compiler will only updates the value changed in memory; Meanwhile, compiler also creates a __commit__
function. If the state variables within this function ever gets changed before the contract call is done, the updated values will be synced to blockchain.
Using object type bool as the example, when lazy
is set to false
. The setter
method generated follows:
set vbool(newvalue: bool) { this._vbool = new Bool(newvalue); const st = new Storage(new Hash("0x0000000000000000000000000000000000000000000000000000000000000001")); st.store<Bool>(this._vbool!); }
When lazy
is set to true
. The setter
and __commit__
functions generated are:
set vbool(v: bool) { this._vbool = new _lang.Bool(v); } __commit_storage__(): void { if (this._vbool !== null) { const st = new _lang.Storage(new _lang.Hash([0x0000000000000000000000000000000000000000000000000000000000000001])); st.store<_lang.Bool>(this._vbool!); } }
To verify, write a simple contract as follows. Because we do not state @state({"lazy": false})
on
@state flag: bool
. Even we are modifying it multiple times in flip()
. It will call seal_set_storaqe
once. You can monitor it in Europa logs that seal_set_storaqe
only gets called once.
@contractclass Flipper { @state flag: bool;
constructor() { }
@constructor default(initFlag: bool): void { this.flag = initFlag; }
@message flip(): void { const v = this.flag; this.flag = !v; this.flag = !v; this.flag = !v; }
@message({"mutates": false}) get(): bool { return this.flag; }}
It the log printed by Europa,
1: NestedRuntime { ext_result: [success] ExecReturnValue { flags: 0, data: }, caller: d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d (5GrwvaEF...), self_account: 9b1b5687f0e868a1ab3b5536efbe3dfe14aea17570d605c01ef868d5d53e51c0 (5Fa5QB7h...), selector: 0x633aa551, args: None, value: 0, gas_limit: 99827000000, gas_left: 253627, env_trace: [ seal_input(Some(0x633aa551)), seal_get_storage((Some(0x0000000000000000000000000000000000000000000000000000000000000001), Some(0x00))), seal_set_storage((Some(0x0000000000000000000000000000000000000000000000000000000000000001), Some(0x01))), ], sandbox_result_ok: Value( I32( 0, ), ), nests: [],}
The selector is defined in metadata.json
{ "mutates": true, "payable": false, "args": [], "returnType": null, "docs": [ "" ], "name": [ "flip" ], "selector": "0x633aa551" },
#
2.4 Define the export format of Map and Array in metadata.json- The export format of Array have two parts: type definition and store definition. In ask!, by default, the array is mutable length array. The default strucutre definition is
SequenceDef
that defines array as sequence and sepcify the object type in array. It also defines storage modes as pack/spread. In addition, for type array, it can pre-allocate some space by default. The type isArraydef
whith specification of capacity for pre-allocated space.len
is set to 0 by default meaning no fixed length is specified.
export interface Type extends ToMetadata { typeKind(): TypeKind;
toMetadata(): ITypeDef;}
export class SequenceDef implements Type { constructor(public readonly type: number) {}
typeKind(): TypeKind { return TypeKind.Sequence; }
toMetadata(): ISequenceDef { return { def: { sequence: { type: this.type, }, }, }; }}
export class ArrayDef implements Type { constructor(public readonly len: number, public readonly type: number) {}
typeKind(): TypeKind { return TypeKind.Array; }
toMetadata(): IArrayDef { return { def: { array: { len: this.len, type: this.type, }, }, }; }}
SequenceDef
generates the following formatοΌ
{ "def": { "sequence": { "type": 4 } } }
ArrayDef
generates the following formatοΌ
{ "def": { "array": { "len": 32, "type": 2 } } }
Their storage structures looks like
{ "name": "ages", "layout": { "struct": { "fields": [ { "name": "len", "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000002", "ty": 3 } }, { "name": "elems", "layout": { "offset": "0x0000000000000000000000000000000000000000000000000000000000000002", "len": 0, "cellsPerElem": 1, "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000002", "ty": 3 }, "storemode": "spread" } } ] } }}
The differences between SequenceDef without capacity limit and ArrayDef with capacity limit: len
of SequenceDef is 0 while len
of ArrayDef is not 0
{ "name": "elems", "layout": { "offset": "0x0000000000000000000000000000000000000000000000000000000000000002", "len": 0, "cellsPerElem": 1, "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000002", "ty": 3 }, "storemode": "spread"}
To verify, write a simple contract by importing PackedStorableArray
:
import { PackedStorableArray, UInt128} from "ask-lang";
@contractclass Flipper { @state flag: bool;
@state @packed({ "capacity": 128 }) packeArr: PackedStorableArray<UInt128> = new PackedStorableArray<UInt128>();
@state aArr: PackedStorableArray<UInt128> = new PackedStorableArray<UInt128>();
constructor() { }
@constructor default(initFlag: bool): void { this.flag = initFlag; }
@message flip(): void { const v = this.flag; this.flag = !v; }
@message({"mutates": false}) get(): bool { return this.flag; }}
In the compiled metadata.json
, we can see SequenceDef
and ArrayDef
have different len
:
{ "def": { "array": { "len": 128, "type": 2 } } }, { "def": { "sequence": { "type": 2 } } }
If we compile in --debug
mode, in the pre-compiled code generated:
get packeArr(): PackedStorableArray<UInt128> { if (this._packeArr === null) { this._packeArr = new _lang.PackedStorableArray<UInt128>(new _lang.Hash([0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x02]), true, 128); } return this._packeArr!; } get aArr(): PackedStorableArray<UInt128> { if (this._aArr === null) { this._aArr = new _lang.PackedStorableArray<UInt128>(new _lang.Hash([0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03]), true, 0); } return this._aArr!; }
- For map storage type, it requires the key data type and value data type of object to be stored. It also needs to specify the storage modes as pack or spread and entry key value of the object to be stored.
export class CompositeDef implements Type { constructor(public readonly fields: Array<Field>) {}
typeKind(): TypeKind { return TypeKind.Composite; } toMetadata(): ICompositeDef { return { def: { composite: { fields: this.fields.map((f) => f.toMetadata()), }, }, }; }}
The generated format for storage instance looks likeοΌ
{ "def": { "composite": { "fields": [ { "name": "key_index", "type": 2 }, { "name": "value", "type": 3 } ] } } }οΌ { "def": { "primitive": "u8" } }οΌ { "def": { "primitive": "str" } }
The storage structure looks like
{ "name": "allowances", "layout": { "struct": { "fields": [ { "name": "key", "layout": { "offset": "0x0000000000000000000000000000000000000000000000000000000000000002", "strategy": { "hasher": "Blake2x256", "prefix": "0x0000000000000000000000000000000000000000000000000000000000000002", "postfix": "" }, "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000002", "ty": 3 }, "storemode": "spread" } }, { "name": "values", "layout": { "offset": "0x0000000000000000000000000000000000000000000000000000000000000002", "strategy": { "hasher": "Blake2x256", "prefix": "0x0000000000000000000000000000000000000000000000000000000000000002", "postfix": "" }, "layout": { "key": "0x0000000000000000000000000000000000000000000000000000000000000002", "ty": 6 }, "storemode": "spread" } } ] } }}
#
2.5#
2.5 Use JSON format instead of bare() annotations.This change improves code readibility and makes easier to for compiler to interprete. eg. @message(selector = '0x00001111') is now @message({"selector": "0x00001111"})
- First parse the anotated parts as json object. eg. β{"selector": "0x00001111"}β
export class DecoratorNodeDef { jsonObj: any; constructor(public decorator: DecoratorNode) { this.jsonObj = this.parseToJson(decorator); }}
For specific decorator, compiler will parse with specific decorator class and run class specific checks.
export class MessageDecoratorNodeDef extends DecoratorNodeDef { constructor(decorator: DecoratorNode, public payable = false, public mutates = true, public selector = "") { super(decorator); this.payable = this.getIfAbsent("payable", false, "boolean"); this.mutates = this.getIfAbsent('mutates', true, "boolean"); if (this.hasProperty('selector')) { this.selector = this.getProperty('selector'); DecoratorUtil.checkSelector(decorator, this.selector); } if (this.payable && !this.mutates) { throw new Error(`Decorator: ${decorator.name.range.toString()} arguments mutates and payable can only exist one. Trace: ${RangeUtil.location(decorator.range)} `); } }}
Event
syntax#
2.6 Enhance In v0.2, we introduced @event
decorator to emit Event. However, in v0.2, Event can't be inheriteted and Event will emit once it gets instantiated, which isn't very intuitive for programmers. Therefore, we made the following optimization in v0.3:
- To implement
@event
, developer has to either inherit from__lang.Event
or from another Event - To emit an Event, developers has to call
.emit()
The new Event usages looks like:
@eventclass EventA extends __lang.Event {
@topic topicA: u8; name: string;
constructor(t: u8, n: string) { super(); this.topicA = t; this.name = n; } }
@eventclass EventB extends EventA { @topic topicB: u8; gender: string; constructor(t: u8, g: string) { super(t, g); this.topicB = t; this.gender = g; }}
@contractexport class EventEmitter {
count: i8;
constructor() { }
@message triggeEventA(): void { let eventA = new EventA(100, "Elon"); eventA.emit(); }
@message triggeEventB(): void { let eventB = new EventB(<u8>300, "M"); eventB.emit(); }}
Currently, due to the lack of contract standards in pallet-contract
, polkadot.js/app
is not able to parse event
correctly. Therefore, the event emission can not be verified from the polkadot.js/app
frontend or europa logs.
note: currently, Event class does not support inheritence.
#
2.7 Enhance decoroter syntax and parameter checksIn v0.2, compiler will only report wrong decorator. Eg.@massage
, compiler will only report contract doesn't support @massage
decorator. (Spelling error)
@massage({"mutates": false}) get(): bool { return this.flag; }
With the enhanced checks, compiler will utilize string match algorithm to predict that the user intends to input @message
as the decorator and provides hints:
Unsupported contract decorator @massage, do you mean '@message'? Check source text: @massage({"mutates": false}) in path:examples/flipper/flipper.ts lineAt: 24 columnAt: 5 range: (346 374).
It will also check if @message
is marked as public
function with the following error message:
Decorator[@message] should mark on public method(Method: get isn't public method). Check source text: @message({"mutates": false}) @message({"mutates": false}) private get(): bool { return this.flag; } in path:examples/flipper/flipper.ts lineAt: 24 columnAt: 5 range: (346 432)..
The checker will also check unsupported keywords in the decorator:
@message({"mutates": false, "superInherit": true}) get(): bool { return this.flag; }
It will report the error:
FAILURE The parameter: superInherit isn't pre-defined in decorator @message, do you mean selector? Check source text: @message({"mutates": false, "superInherit": true}) in path:examples/flipper/flipper.ts lineAt: 25 columnAt: 5 range: (347 397)..
#
2.8 Optimize the size of the generated wasm file.In v0.3, by default,ask-cli will compile in --release mode so the compiler will use option -o3z
to optimize and compress the wasm file generated. In addtion, in the Framework, we reduces the resources comsumed by string to shrink the codes of Framework
#
2.9 Upgrade the seal_xxx method in pallet-contracThe method seal_xxx
used in contract is now updated to latest seal0 of Europa
#
Provide system parameter types in custom env .- By default,
AccountId
,Hash
,Balance
,BlockNumber
are implemented asArray<u8>(32)
,Array<u8>(32)
,UInt128
,UInt32
. - You can now customize it in assembly/env/CustomTypes.ts as long as the correct Codec is implementd
#
Unit Testing and Documentation.- Provide exmples to test the features in our Ask! Framework in examples/
- Provide tests/ to test the compiler. In
ts-package
, we providets-packages/contract-metadata/src/
andts-packages/transform/src/__tests__/
for tests we used. To run the unit-test:
cd ts-packagesyarn jest
You should see the following log, showing all unit tests are passing:
yarn run v1.22.11$ /home/bonan/repos/ask/ask-compiler/node_modules/.bin/jest PASS ts-packages/contract-metadata/dist/index.spec.js PASS ts-packages/transform/src/__tests__/generator.test.ts PASS ts-packages/contract-metadata/src/index.spec.ts PASS ts-packages/transform/src/__tests__/types.test.ts PASS ts-packages/transform/src/__tests__/decorator.test.ts
For documentations, please refer QuickStart.
#
Start using Ask! v0.3Ask! v0.3 is now released, please refer QuickStart to quick start it.
For detailed usages of components in Ask!, please refer API Usages.
#
Quick startNow, let's use pl-ask-cli
to compose ask! smart contracts.
- init a new directory:
mkdir erc20
cd erc20
- init an npm project:
npm init -y
- install pl-ask-cli:
npm i pl-ask-cli
- init project:
npx pl-ask-cli init
- copy
index.ts
in example/erc20 ,ERC20.ts
toerc20/contracts/
. - compile:
npx pl-ask-cli compile contracts/index.ts
Once compiled successfully, we can deploy and call the contract.
#
Use ERC20 base ContractERC20.ts
is the base class that implments ERC20 standard with reusable ERC20 interfaces such as transfer
, approve
etc. It defines the storages for contract as well as Event of Transfer
and Approval
.
In Ask! v0.3, we have reimplemented ERC20 with new coding conventions. So the new contract can still be written like:
import { Account, u128 } from "ask-lang";import {ERC20} from "./ERC20";
@contract@doc({"desc": "MyToken conract that extended erc20 contract"})class MyToken extends ERC20 {
constructor() { super(); }
@constructor default(name: string = "", symbol: string = ""): void { super.default(name, symbol); }
@message @doc({"desc": "Mint a token"}) mint(to: Account, amount: u128): void { this._mint(to, amount); }
@message @doc({"desc": "burn the token"}) burn(from: Account, amount: u128): void { this._burn(from, amount); }}
#
CompileTo compile the contract:
$ npx ask-cli compile contracts/index.ts
After successfuly compilation, wasm
and metadata.json
will be generated under examples/erc20/build/
.
#
Deployment and contract callsWe use Europa(v3.0.0 branch
) sandbox to deploy and test contracts with polkadot-js(master
branch, commit-id 11276477a0523348c7b143db566622aa32833296
) as the frontend
Test:
Follow the instructions of
Europa
andplokadot-js
to start node and services.In
polkadot-js
contract tab, uploadbuild/metadata.json
andbuild/target.wasm
.Instantiate the uploaded contract and call
default
to issue tokens.call
mint
,transfer
,approve
,burn
to operate this ERC20 contract.
Now, with ask-cli
and new ask! contract features, we succesfully issued ERC20 tokens.
#
Implemented features of Ask! v0.3:- release of new Ask v0.3 and npm of ask-cli .
- Implementing contracts in
/examples
with new base contracts . - Complete contract development tutorial
- Complete API documentations.