Skip to main content

Interact with a WASM smart contract using Astar.js

TL;DR

Here we'll be guiding you on how to interact with a WASM smart contract using Astar.js.


What is Astar.js?

Astar.js library allows application developers the ability to query a node and interact with the Astar/Shiden/Shibuya chains using Javascript/Typescript.

Packages

The @polkadot/api-contract package will be used alongside the @astar-network/astar-api package. With that in mind, we can install from npm: yarn add @astar-network/astar-api@beta @polkadot/api @polkadot/api-contract

API connection

The API provides application developers the ability to send transactions to the Astar node. Here is an example to create an API instance:

import { ApiPromise } from '@polkadot/api';
import { WsProvider } from '@polkadot/rpc-provider';
import { options } from '@astar-network/astar-api';

async function main() {
const provider = new WsProvider('ws://localhost:9944');
const api = new ApiPromise(options({ provider }));
await api.isReady;

// Use the api
console.log((await api.rpc.system.properties()).toHuman());
}

Web3 accounts

We will need a Substrate account to sign messages. We can use dev accounts like Alice, Bob, Charlie, or create new ones.


The WASM Smart Contract

Contract build artifacts

The contract metadata and the wasm code are generated by building the contract with Swanky CLI.

Interact with the smart contract

The CodePromise class allows the developer to manage calls to code deployment. In itself, it is easy to use for code deployment, and it is generally the first step, especially in cases where an existing codeHash is not available.

import { ApiPromise } from '@polkadot/api';
import { CodePromise } from '@polkadot/api-contracts';

...
// Construct the API as per the API sections
// (as in all examples, this connects to a local chain)
const api = await ApiPromise.create();

// Construct our Code helper. The abi is an Abi object, an unparsed JSON string
// or the raw JSON data (after doing a JSON.parse). The wasm is either a hex
// string (0x prefixed), an Uint8Array or a Node.js Buffer object
const code = new CodePromise(api, abi, wasm);

// Deploy the WASM, retrieve a Blueprint
...

It is important to understand that the interfaces provided here are higher-level helpers, so some assumptions are made to make subsequent use easier. In the case of the CodePromise class, this is quite visible. In comparison, a contracts.putCode is independent of any ABIs. For our helpers we always assume that the developer does have access to the ABI right at the start. This means that when code is deployed, a Blueprint can be created with the correct ABI (and subsequent deployments can, once again, create a smart contract with an attached ABI).

The helpers are there to help and make development easier by integrating the parts. Nothing would stop a developer from making putCode or instantiate calls themselves.

Create a Blueprint

After we have the initial structure above, the next step would be to actually deploy the code and retrieve a BlueprintPromise from the result. Building on the above example:

// Deploy the WASM, retrieve a Blueprint
let blueprint;

// createBlueprint is a normal submittable, so use signAndSend
// with an known Alice keypair (as per the API samples)
const unsub = await code
.createBlueprint()
.signAndSend(alicePair, (result) => {
if (result.status.isInBlock || result.status.isFinalized) {
// here we have an additional field in the result, containing the blueprint
blueprint = result.blueprint;
unsub();
}
})

As noted above, the createBlueprint helper on the Code interface is a normal submittable, although it is enhanced to return an actual Blueprint based on the codeHash retrieved. Internally it sends a putCode and will listen for the correct emitted events. Based on the results and events (and the input ABI), it will subsequently create a helper object that can be used to deploy smart contracts.

The Blueprint

A BlueprintPromise wraps an existing codeHash and an ABI and provides the opportunity to create smart contracts on-chain. It is either created via new BlueprintPromise, from an existing codeHash, or as seen in the above example from deploying a WASM code bundle on-chain.

Assuming that we did not deploy any code, we can start by creating one:

import { BlueprintPromise } from '@polkadot/api-contract';

...
// Create a new blueprint from an existing hash. As per the code example
// the abi is an Abi object, an unparsed JSON string or the raw JSON data
// (after doing a JSON.parse). The hash here is either a Hash, Uint8Array
// or hex string
const blueprint = new BlueprintPromise(api, abi, codeHash);

// Deploy a contract using the Blueprint
...

As with the CodePromise sample, we require an ApiPromise, ABI and the actual codeHash, as found on-chain. If a non-existent codeHash is used, it will fail on actual contract creation.


Create a WASM smart contract

We either have a Blueprint from a code deploy or a manual created one. From here we can create an actual smart contract instance. For this example, we are using a normal incrementer smart contract:

// Deploy a contract using the Blueprint
const endowment = 0;

// NOTE The apps UI specifies these in Mgas
const gasLimit = 100000n * 1000000n;
const initValue = 123;

let contract;

// We pass the constructor (named `new` in the actual Abi),
// the endowment, gasLimit (weight) as well as any constructor params
// (in this case `new (initValue: i32)` is the constructor)
const unsub = await blueprint.tx
.new(endowment, gasLimit, initValue)
.signAndSend(alicePair, (result) => {
if (result.status.isInBlock || result.status.isFinalized) {
// here we have an additional field in the result, containing the contract
contract = result.contract;
unsub();
}
});

As per the Code examples previously, the tx.<constructorName> interface is a normal submittable extrinsic with the result containing an actual ContractPromise instance as created with the address from the events from deployment. Internally it will use the instantiate extrinsic and interpret the events retrieved.

For cases where we want to refer to the message via index (or actual ABI message), we can use the .createContract helper on the Blueprint, in this case the lower-level code would be:

// We pass the constructor (name, index or actual constructor from Abi),
// the endowment, gasLimit (weight) as well as any constructor params
// (in this case `new (initValue: i32)` is the constructor)
const unsub = await blueprint
.createContract('new', endowment, gasLimit, initValue)
.signAndSend(alicePair, (result) => {
...
});

The Smart Contract

The ContractPromise interface allows us to interact with a deployed contract. In the previous Blueprint example this instance was created via createContract. In general use, we can also create an instance via new, i.e. when we are attaching to an existing contract on-chain:

import { ContractPromise } from '@polkadot/api-contract';

// Attach to an existing contract with a known ABI and address. As per the
// code and blueprint examples the abi is an Abi object, an unparsed JSON
// string or the raw JSON data (after doing a JSON.parse). The address is
// the actual on-chain address as ss58 or AccountId object.
const contract = new ContractPromise(api, abi, address);

// Read from the contract
...

Either via a create above or via a call to createContract both instances are the same. The Contract provides a wrapper around the Abi and allows us to call either read or exec on a contract to interact with it.

Reading the smart contract values

In the Blueprint example we have instantiated an incrementer smart contract. In the following examples we will continue using it to read from and execute transactions into, since it is a well-known entity. To read a value from the contract, we can do the following:

// Read from the contract via an RPC call
const value = 0; // only useful on isPayable messages

// NOTE the apps UI specified these in mega units
const gasLimit = 3000n * 1000000n;

// Perform the actual read (no params at the end, for the `get` message)
// (We perform the send from an account, here using Alice's address)
const { gasConsumed, result, outcome } = await contract.query.get(alicePair.address, { value, gasLimit });

// The actual result from RPC as `ContractExecResult`
console.log(result.toHuman());

// gas consumed
console.log(gasConsumed.toHuman());

// check if the call was successful
if (result.isOk) {
// should output 123 as per our initial set (output here is an i32)
console.log('Success', output.toHuman());
} else {
console.error('Error', result.asErr);
}

Underlying the above .query.<messageName> is using the api.rpc.contracts.call API on the smart contracts pallet to retrieve the value. For this interface, the format is always of the form messageName(<account address to use>, <value>, <gasLimit>, <...additional params>). An example of querying a balance of a specific account on an PSP22 contract will therefore be:

// the address we are going to query
const target = '5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY';
const from = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';

// only 1 param needed, the actual address we are querying for (more
// params can follow at the end, separated by , if needed by the message)
const callValue = await contract.query.balanceOf(from, { value: 0, gasLimit: -1 }, target);

In this example we have specified a gasLimit of -1, in a subsequent section we will expand on this. for now, just remember that is indicated to use max available, i.e. we don't explicitly want to specify a value. An alternative for reading would be via the lower-level .read method:

// Perform the actual read (no params at the end, for the `get` message)
// (We perform the send from an account address, it doesn't get executed)
const callValue = await contract
.read('get', { value, gasLimit })
.send(alicePair.address);

// The actual result from RPC as `ContractExecResult`
...

In cases where the ABI messages have conflicting names, instead of the 'get' string the actual message index (or message from the ABI itself) can be passed-through.

Sending a transaction

In addition to using the .query.<messageName> on a smart contract, the .tx.<messageName> method is provided to send an actual encoded transaction to the smart contract, allows it for execution and have this applied in a block. Expanding on our previous examples, we can now execute and then retrieve the subsequent value:

// We will use these values for the execution
const value = 0; // only useful on isPayable messages
const gasLimit = 3000n * 1000000n;
const incValue = 1;


// Query the transaction method to get the error. This is euqivalent to a dry run of the txn
await contract.query
.inc({ value }, incValue);

// Send the transaction, like elsewhere this is a normal extrinsic
// with the same rules as applied in the API (As with the read example,
// additional params, if required can follow - here only one is needed)
await contract.tx
.inc({ value, gasLimit }, incValue)
.signAndSend(alicePair, (result) => {
if (result.status.isInBlock) {
console.log('in a block');
} else if (result.status.isFinalized) {
console.log('finalized');
}
});

If we perform the same query.get read on the value now, it would be 124. For lower-level access, like we have in the Blueprint via .createContract we can also perform the execution via the .exec function, which would have equivalent results:


// Query the transaction method first to check if there is an error. This is euqivalent to a dry run of the transcation.
await contract.query
.inc({ value }, incValue);

// Send the transaction, like elsewhere this is a normal submittable
// extrinsic with the same rules as applied in the API
await contract
.exec('inc', { value, gasLimit }, incValue)
.signAndSend(alicePair, (result) => {
...
});

For the above interface we can specify the message as the string name, the index of the actual message as retrieved via the ABI.

Weight estimation

To estimate the gasLimit (which in the Substrate context refers to the weight used), we can use the .query (read) interface with a sufficiently large value to retrieve the actual gas consumed. The API makes this easy - with a gasLimit or -1 passed to the query it will use the maximum gas limit available to transactions and the return value will have the actual gas used.

To see this in practice:

// We will use these values for the execution
const value = 0;
const incValue = 1;

// Instead of sending we use the `call` interface via `.query` that will return
// the gas consumed (the API aut-fill the max block tx weight when -1 is the gasLimit)
const { gasConsumed, result } = await contract.query.inc(slicePair, { value, gasLimit: -1 }, incValue)

console.log(`outcome: ${result.isOk ? 'Ok' : 'Error'}`);
console.log(`gasConsumed ${gasConsumed.toString()}`);

We can use the gasConsumed input (potentially with a buffer for various execution paths) in any calls to contract.tx.inc(...) with the same input parameters specified on the query where the estimation was done.

Weights V2

The above is the current interface for estimating the gas used for a transaction. However, the Substrate runtime has a new interface for estimating the weight of a transaction. This is available on the api.tx.contracts.call interface. The interface is the same as the above, however the gasLimit is now specified as a refTime and proofSize. refTime is the time it takes to execute a transaction with a proof size of 1. proofSize is the size of the proof in bytes. The gasLimit is calculated as refTime * proofSize. The refTime and proofSize can be retrieved from the api.consts.system.blockWeights interface.


// Estimate the gas required for the transaction
const { gasRequired } = await contract.query.inc(
slicePair,
{
gasLimit: api.registry.createType('WeightV2', {
refTime, // from api.consts.system.blockWeights
proofSize,
}) as WeightV2,
storageDepositLimit,
}
)

const gasLimit = api.registry.createType('WeightV2', gasRequired) as WeightV2

// Send the transaction, like elsewhere this is a normal extrinsic
// with the same rules as applied in the API (As with the read example,
// additional params, if required can follow)

await contract.tx
.inc({
gasLimit: gasLimit,
storageDepositLimit
})
.signAndSend(account, async (res) => {
if (res.status.isInBlock) {
console.log('in a block')
setLoading(false)
} else if (res.status.isFinalized) {
console.log('finalized')
}
})

Events

On the current version of the API, any events raised by the contract will be transparently decoded with the relevant ABI and will be made available on the result (from .signAndSend(alicePair, (result) => {...}) as contractEvents.

When no events are emitted this value would be undefined, however should events be emitted, the array will contain all the decoded values.

Where no events were emitted this value would be undefined, however should events be emitted, the array will contain all the decoded values.

Best Practice

One thing you need to remember is that #[ink(payable)] added to an #[ink(message)] prevents ink_env::debug_println! messages to be logged in console when executing the smart contract call. Debug messages are only emitted during a dry run (query), not during the actual transaction (tx)(Source). When you're calling the contract, first query it, then perform your transaction if there are no error messages.

e.g.

public async transaction(signer: Signer, method: string, args: any[]): Promise<Partial<TransactionResponse>> {
// View any debug in substrate logs and catch any errors here
const queryBeforeTx = await this.contract.query[method](this.account.address, {}, ...args);
// Then run your transaction
const extrinsic = this.contract.tx[method]({}, ...args);
}

References