12 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.
Based 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
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
and metadata.json
in build
directory.compile
contains two compiling modes: release
and debug
. 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.
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:
@storage
class ERC20StoragePausable extends ERC20Storage{
is_pausable: bool;
}
@contract
export class ERC20Pausable {
private storage: ERC20StoragePausable;}
In v0.3
@contract
export 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.
@storage
class ERC20Storage {
balances: SpreadStorableMap<AccountId, UInt128>;
allowances: SpreadStorableMap<AccountId, SpreadStorableMap<AccountId, UInt128>>;
totalSupply: u128;
name: string;
symbol: string;
decimal: u8;
}
@contract
export class ERC20 {
private storage: ERC20Storage;}
In v0.3, we now define storage directly inside the @contract
class with @state
:
@contract
export 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)
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.
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.
@contract
export 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@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.
@contract
class 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"
},
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 is Arraydef
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";
@contract
class 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!;
}
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"
}
}
]
}
}
}
This change improves code readibility and makes easier to for compiler to interprete.
eg. @message(selector = '0x00001111') is now @message({"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
syntaxIn 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:
@event
, developer has to either inherit from __lang.Event
or from another Event.emit()
The new Event usages looks like:
@event
class EventA extends __lang.Event {
@topic topicA: u8;
name: string;
constructor(t: u8, n: string) {
super();
this.topicA = t;
this.name = n;
}
}
@event
class EventB extends EventA {
@topic topicB: u8;
gender: string;
constructor(t: u8, g: string) {
super(t, g);
this.topicB = t;
this.gender = g;
}
}
@contract
export 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.
In 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)..
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
The method seal_xxx
used in contract is now updated to latest seal0 of Europa
ts-package
, we provide ts-packages/contract-metadata/src/
and ts-packages/transform/src/__tests__/
for tests we used.cd ts-packages
yarn 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.
Ask! v0.3 is now released, please refer QuickStart to quick start it.
For detailed usages of components in Ask!, please refer API Usages.
Now, let's use pl-ask-cli
to compose ask! smart contracts.
mkdir erc20
cd erc20
npm init -y
npm i pl-ask-cli
npx pl-ask-cli init
index.ts
in example/erc20 , ERC20.ts
to erc20/contracts/
.npx pl-ask-cli compile contracts/index.ts
Once compiled successfully, we can deploy and call the contract.
ERC20.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);
}
}
To compile the contract:
$ npx ask-cli compile contracts/index.ts
After successfuly compilation, wasm
and metadata.json
will be generated under examples/erc20/build/
.
We 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
and plokadot-js
to start node and services.
In polkadot-js
contract tab, upload build/metadata.json
and build/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.
/examples
with new base contracts .