12 weeks ago, Patract submitted Kusama Treasury’s #81 proposal regarding Ask! v0.2, including the implementation of the goal, principle and process. In that proposal, we will complete the following functions in the v0.2:
V0.2 Goal: Improve and enhance the function of Ask!. You can write practical contracts
- Improve the sub-options of
@storage
,@message
annotations, and add@event
annotations.- Add composite data types
StorableMap
,StorableArray
.- Implement contract inheritance.
- Implement the cross-contract call function through the
@dynamic
annotation.- Provide example contracts such as
ERC20
,ERC721
, etc.
Now the source code we have implemented is in the Ask! project repo, and the example contract is in examples directory, Please review at v0.2-review branch, then we will merge into master and release with a tag. The preview doc is at docs.patract.io
Ask! v0.2 follows the annotation parsing and compilation method used in v0.1, adding new functions.
The @storage
annotation works on the class, and provides the @packed
and @ignore
sub-options.
@packed
annotation is used for data type about Map and Array . The data marked as @packed
will be stored and accessed as a whole. Its implementation theory will be described in detail in the following chapters.@ignore
annotations are only saved in memory and will not be saved on the chain. After the execution environment exits, they are destroyed.The @message
annotation works on the methods of the class, and provides the mutates
, payable
and selector
options. A complete @message
annotation is like: @message(payable, mutates = false, selector = "0xabcdef12")
payable
option indicates that the method can accept value, but it is not accepted by default. It is implemented by inserting a piece of logic before executing the method to determine whether the value is sent when the method is called. If the value is not 0, and there is no annotation as payable, When the method is executed, it will exit through the assert method.mutates
option indicates whether the method can change the value of the state variable. The default value of mutates is true, and can be omitted. Its implementation is that if mutates = false
is specified, it will be executed an assert method in the seal_set_storage
, which is not allowed to write data to the chain in such a method.selector
option is used to indicate that this method uses a fixed value as the selector, and does not need to be calculated and generated based on the real method name. It is used to generate the selector
of this method in metadata.json, and to call the contract entry method call
, it is also used as a judgment condition for method dispatch.Added @event
annotation to support the event function.
The @event annotation is applied on the class, and the preprocessor needs to generate logic that meets the requirements for this class.
@topic
sub-annotation acts on a member variable of the class, which means that this variable can be filtered out on the chain. Its implementation is to store the hash of the topic variable in the topic buffer, and store all the variables in the data buffer, The value is then sent to the chain through the seal_deposit_event
method.We support StorableMap
, StorableArray
about composite data types and custom class objects in v0.2 version (need to implement Codec
interface). The composite data type supports two storage modes, @spread
and @packed
. For the @spread
storage mode, each storage unit has its own storage address, and will only be loaded when needed. For the @packed
storage mode, all storage units need to be serialized into a set of data streams and stored in a shared address. All storage units are accessed together. This mode is not suitable for large data access.
StorableMap
: SpreadStorableMap
and PackedStorableMap
are encapsulated classes of Map, and add data persistence function. Two storage modes of @spread
and @packed
are implemented respectively. The storage structure of SpreadStorableMap
is as follows:The number of data stored in this Map and the Hash of the first storage location are saved in MapEntry
. Its storage location is in Hash(prefix)
, and this storage location will be exported to metadata.json for access of external apps.
KVStore
is a specific stored K/V value. In addition to storing Key/Value, each KVStore also stores the hash of the next/prev node. If it is a tail node, then the value of next
is NullHash
, that is (0x0000000000000000000000000000000000
); if it is a head node, then the value of prev
is NullHash
. Through a doubly linked list, external Apps can iteratively access all data. The storage location of each KVStore
is determined by the following rules: Hash(prefix + key)
. The storage structure of PackedStorableMap
is as follows:
The storage model of Packed is different from Spread, all its data is loaded/stored all at once. The usage of MapEntry
is the same as the Spread model. All its data is stored in a fixed location under Hash(prefix + ".value")
through the method of u8[]
.
StorableArray
: SpreadStorableArray
and PackedStorableArray
are the encapsulation of the Array class, and added data persistence function, respectively implementing the two storage modes of @spread
and @packed
.The storage structure of SpreadStorableArray
is as follows:
*
ArrayEntry
saves the number of elements of this Array size
and the number of bytes after serialization rawBytesCount
(this value is 0
in Spread model). Its storage location is in Hash(prefix)
, And this storage location will be exported to metadata.json for external apps to access. The storage location of each element is determined by the method of Hash(prefix + index)
, and the serialized data of the element is stored in this location.
The storage structure of PackedStorableArray
is as follows:
ArrayEntry
stores the number of elements in this Array size
and the number of bytes after serialization rawBytesCount
. In this storage mode, all elements are stored under the same address Hash(prefix + ".values ")
.
Composite object
Composite object
is a serializable class, that is, a class that implements the Codec
interface, which can be stored on the chain. For example, the following class:class EmbedObj implements Codec {
a: i8;
b: string;
c: u128;
constructor(a: i8 = 0, b: string = "", c: u128 = u128.Zero) {
this.a = a;
this.b = b;
this.c = c;
}
toU8a(): u8[] {
let bytes = new Array<u8>();
let aWrap = new Int8(this.a);
let bWrap = new ScaleString(this.b);
let cWrap = new UInt128(this.c);
bytes = bytes.concat(aWrap.toU8a())
.concat(bWrap.toU8a())
.concat(cWrap.toU8a());
return bytes;
}
encodedLength(): i32 {
let aWrap = new Int8(this.a);
let bWrap = new ScaleString(this.b);
let cWrap = new UInt128(this.c);
return aWrap.encodedLength() + bWrap.encodedLength() + cWrap.encodedLength();
}
populateFromBytes(bytes: u8[], index: i32 = 0): void {
let aWrap = new Int8();
aWrap.populateFromBytes(bytes, index);
index += aWrap.encodedLength();
let bWrap = new ScaleString();
bWrap.populateFromBytes(bytes, index);
index += bWrap.encodedLength();
let cWrap = new UInt128();
cWrap.populateFromBytes(bytes, index);
this.a = aWrap.unwrap();
this.b = bWrap.toString();
this.c = cWrap.unwrap();
}
eq(other: EmbedObj): bool {
return this.a == other.a && this.b == other.b && this.c == other.c;
}
notEq(other: EmbedObj): bool {
return !this.eq(other);
}
}
EmbedObj
can be used in the storage class annotated by @storage
to save a set of related information.
The inheritance function makes contract reuse possible. The contract inheritance of v0.2 follows the following basic principles:
@constructor
method, use the @constructor
method defined in the subclass contract. If it is not provided in the subclass, then the final generated contract will not provide @constructor
, even if it is already defined in the parent class. The parent class cannot know the member variables in the subclass, and cannot completely initialize the contract correctly.@message
method, use the union of all messages in the parent class and the child class.@storage
class, no additional processing is done, and the developer decides how to use it.The realization theory of inheritance function
clzPrototype.declaration.range.source.sourceKind == SourceKind.USER_ENTRY
&& AstUtil.hasSpecifyDecorator(clzPrototype.declaration, ContractDecoratorKind.CONTRACT);
public resolveContractClass(): void {
this.classPrototype.instanceMembers &&
this.classPrototype.instanceMembers.forEach((instance, _) => {
if (ElementUtil.isCntrFuncPrototype(instance)) {
this.cntrFuncDefs.push(new ConstructorDef(<FunctionPrototype>instance));
}
if (ElementUtil.isMessageFuncPrototype(instance)) {
let msgFunc = new MessageFunctionDef(<FunctionPrototype>instance);
this.msgFuncDefs.push(msgFunc);
}
});
this.resolveBaseClass(this.classPrototype);
}
private resolveBaseClass(sonClassPrototype: ClassPrototype): void {
if (sonClassPrototype.basePrototype) {
let basePrototype = sonClassPrototype.basePrototype;
basePrototype.instanceMembers &&
basePrototype.instanceMembers.forEach((instance, _) => {
if (ElementUtil.isMessageFuncPrototype(instance)) {
let msgFunc = new MessageFunctionDef(<FunctionPrototype>instance);
this.msgFuncDefs.push(msgFunc);
}
});
this.resolveBaseClass(basePrototype);
}
}
The @dynamic annotation is used to describe the message information of a contract, which has been deployed and instantiated. Other contracts can interact with this contract through @dynamic declarations. The @dynamic annotation acts on the class, The pre-compiler will generate cross-contract call logic for the @dynamic class.
the implementation theory of @dynamic
if (ElementUtil.isDynamicClassPrototype(element)) {
let dynamicInterpreter = new DynamicIntercepter(<ClassPrototype>element);
this.dynamics.push(dynamicInterpreter);
}
export const dynamicTpl = `class {{className}} {
addr: AccountId;
constructor(addr: AccountId) {
this.addr = addr;
}
{{#each functions}}
{{#generateFunction .}}{{/generateFunction}}
{{/each}}
}`;
If the original interface method
transfer(recipient: AccountId, amount: u128): bool {
return true;
}
The generated call method
transfer(p0: AccountId,p1: u128): bool {
let data = Abi.encode("transfer", [p0,new UInt128(p1)]);
let rs = this.addr.call(data);
return BytesReader.decodeInto<Bool>(rs).unwrap();
}
The Ask! project is not yet released, so we need to clone the source code locally.git clone https://github.com/patractlabs/ask
After the clone is completed, please perform the following steps:
$ cd ask
$ yarn
In the v0.2 project, we have provided two projects erc20
and erc721
in the examples directory. Below we use the erc20
project to illustrate how to use the new features of v0.2.
In the example erc20 contract, we used the following features in the v0.2 version:
mutates = false
and other annotationsThe ERC20.ts contract provided here is only used to demonstrate the use and capabilities of Ask! and cannot be used as a formal Token contract.
ERC20.ts
is a base class that fit to the ERC20 standard. It encapsulates reusable ERC20 interfaces, such as transfer
, approve
, etc. It defines the storage structure used by the contract, as well as the events Transfer
and Approval
.
@contract
export class ERC20 {
private storage: ERC20Storage;
private msg: Msg;
constructor() {
this.storage = new ERC20Storage();
this.msg = new Msg();
}
@constructor
default(name: string = "", symbol: string = ""): void {
this.storage.name = name;
this.storage.symbol = symbol;
this.storage.decimal = 18;
this.storage.totalSupply = u128.Zero;
}
@message(mutates = false)
name(): string {
return this.storage.name;
}
@message(mutates = false)
symbol(): string {
return this.storage.symbol;
}
@message(mutates = false)
decimal(): u8 {
return this.storage.decimal;
}
@message(mutates = false)
totalSupply(): u128 {
return this.storage.totalSupply;
}
@message(mutates = false)
balanceOf(account: AccountId): u128 {
return this.storage.balances.get(account).unwrap();
}
@message
transfer(recipient: AccountId, amount: u128): bool {
let from = this.msg.sender;
this._transfer(from, recipient, amount);
return true;
}
// .........
}
If we already have an ERC20 contract, it will be very simple for us to issue new Tokens, such as the MyToken
issued in the index.ts
contract (just to demonstrate how to use Ask! to issue ERC20 Tokens, without permission control logic):
import { AccountId, u128 } from "ask-lang";
import {ERC20} from "./ERC20";
@contract
class MyToken extends ERC20 {
constructor() {
super();
}
@constructor
default(name: string = "", symbol: string = ""): void {
super.default(name, symbol);
}
@message
mint(to: AccountId, amount: u128): void {
this._mint(to, amount);
}
@message
burn(from: AccountId, amount: u128): void {
this._burn(from, amount);
}
}
Use the following command to compile our contract:
$ npx ask examples/erc20/index.ts
After the compilation is successful, the target.wasm
and metadata.json
files will be generated in the examples/erc20/target/
directory.
We deploy and test contract functions in the Europa sandbox environment, using polkadot-js on the front end as an interactive interface. The test steps are as follows:
First, we follow the instructions of Europa
and plokadot-js
to start nodes and services.
In the contract interface of polkadot-js
, upload the metadata.json
and target.wasm
files under erc20/target
.
Deploy the uploaded contract and call the default
method to issue tokens.
Call mint
, transfer
, approve
, burn
and other methods to operate ERC20 Token.
So far, we have successfully issued ERC20 tokens through inheritance.
@storage
, @message
annotations, and add @event
annotations.StorableMap
, StorableArray
.@dynamic
annotation.erc20
, erc721
, crosscall
, etc.