Patract's treasury report for Ask! v0.2 (ink! in AssemblyScript)

3yrs ago
0 Comments

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

Design and Implementation

Ask! v0.2 follows the annotation parsing and compilation method used in v0.1, adding new functions.

Improve Annotation function

  • The @storage annotation works on the class, and provides the @packed and @ignore sub-options.

    • The @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.
    • Members of classe marked with @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")

    • The 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.
    • The 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.
    • The 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.
      In their implementation, conditional checks can only be checked at runtime, and cannot be checked at compile time for the time being.
  • 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.

    • The @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.

Composite data type store and read

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:
    SpreadStorableMap

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:

PackedStorableMap

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:
SpreadStorableArray

*

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:
PackedStorableArray

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.

Contract inheritance function

The inheritance function makes contract reuse possible. The contract inheritance of v0.2 follows the following basic principles:

  • For the @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.
  • For the @message method, use the union of all messages in the parent class and the child class.
  • For the @storage class, no additional processing is done, and the developer decides how to use it.

The realization theory of inheritance function

  • The sub-contract must be located in the compiled entry file. The main contract entry is determined by analyzing the description information of the class marked with @contract annotations. It should mention that the every entry function can only have one contract with @contract.
clzPrototype.declaration.range.source.sourceKind == SourceKind.USER_ENTRY
 && AstUtil.hasSpecifyDecorator(clzPrototype.declaration, ContractDecoratorKind.CONTRACT);
  • After locating the main contract class, analyze the inheritance relationship of the contract class, parse the parent class to obtain @message, and then perform this operation recursively through the contract method message everywhere.
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 generation methods of @message and @storage refer to the single contract.

The role and implementation of @dynamic annotation

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

  • Find the corresponding interface class through the @dynamic annotation
if (ElementUtil.isDynamicClassPrototype(element)) {
  let dynamicInterpreter = new DynamicIntercepter(<ClassPrototype>element);
  this.dynamics.push(dynamicInterpreter);
}
  • Then analyze the interface class, and then generate the implementation call method for each method. The template generated by the implementation call class is as follows. Where addr is the address of the contract being called.
export const dynamicTpl = `class {{className}} {
    addr: AccountId;
    constructor(addr: AccountId) {
        this.addr = addr;
    }
    {{#each functions}}
    {{#generateFunction .}}{{/generateFunction}}
    {{/each}}
}`;
  • The most important one is to implement calling classes for methods. Generated by the generateFunction method. generateFunction analyzes the parameters of the method, and then converts the parameters to the codec type. Then do cross-contract call through Abi.encode.

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();
}
  • Set the contract address for the contract, and then implement the call through Abi.encode.

How to use Ask! v0.2

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.

Write a contract

In the example erc20 contract, we used the following features in the v0.2 version:

  • Contract inheritance
  • Event sent in contract
  • Use composite storage type: Map
  • mutates = false and other annotations

The 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 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);
  }
}

Compile the contract

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.

Deployment and invocation

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:

  1. First, we follow the instructions of Europa and plokadot-js to start nodes and services.

  2. In the contract interface of polkadot-js, upload the metadata.json and target.wasm files under erc20/target.

  3. Deploy the uploaded contract and call the default method to issue tokens.

  4. Call mint, transfer, approve, burn and other methods to operate ERC20 Token.

So far, we have successfully issued ERC20 tokens through inheritance.

What has been implemented in Ask! v0.2

  • 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, crosscall, etc.
Up
Comments
No comments here