メインコンテンツへスキップする

Astar.js for Wasm Smart Contracts

Astar.js is a library for interacting with the with the Astar/Shiden/Shibuya chains using Javascript/Typescript. It is a collection of modules that allow you to interact with the Astar blockchain through a local or remote node. It can be used in the browser or in Node.js.

Installation

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

yarn add @polkadot/api@9.13.6 @polkadot/api-contract@9.13.6 @astar-network/astar-api@0.1.17 @astar-network/astar-sdk-core@0.1.17

Examples

You can find a working examples here:

  • Flipper contract flipper. This is a simple contract that allows users to flip a boolean value.

  • Lottery contract lottery. Here is another dapp example that uses Astar.js to interact with WASM smart contract. This is a simple lottery dapp that allows users to enter and draw the lottery.

Usage

Contract build artifacts

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

Connecting to API

The API provides application developers the ability to send transactions and recieve data from blockchain 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";
import { sendTransaction } from "@astar-network/astar-sdk-core";

async function main() {
const provider = new WsProvider("ws://localhost:9944");
// OR
// const provider = new WsProvider('wss://shiden.api.onfinality.io/public-ws');
const api = new ApiPromise(options({ provider }));
await api.isReady;

// Use the api
// For example:
console.log((await api.rpc.system.properties()).toHuman());

process.exit(0);
}

main();

Initialise ContractPromise Class

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 { Abi, ContractPromise } from "@polkadot/api-contract";

// After compiling the contract a ABI json is created in the artifacts. Import the ABI:
import ABI from "./artifacts/lottery.json";

const abi = new Abi(ABI, api.registry.getChainProperties());

// Initialise the contract class
const contract = new ContractPromise(api, abi, address); // address is the deployed contract address

Query Contract Messages

// Get the gas WeightV2 using api.consts.system.blockWeights['maxBlock']
const gasLimit = api.registry.createType(
"WeightV2",
api.consts.system.blockWeights["maxBlock"]
);

// Query the contract message
const { gasRequired, result, output } = await contract.query.pot(
account.address,
{
gasLimit,
}
);

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>).

Send Contract Transaction the easy way

Sending contract transaction is normally two steps process. The first step is to dry-run the transaction and check for errors. Astar.js has a helper function to do this. This helper function will return the transaction object which you can use to sign and send the transaction.

import { sendTransaction } from "@astar-network/astar-sdk-core";

try {
const result = await sendTransaction(
api, // The api instance of type ApiPromise
contract, // The contract instance of type ContractPromise
"enter", // The message to send or transaction to call
account.address, // The sender address
new BN("1000000000000000000") // 1 TOKEN or it could be value you want to send to the contract in title
// The rest of the arguments are the arguments to the message
);

// Sign and send the transaction
// The result is a promise that resolves to unsubscribe function
const unsub = await result.signAndSend(account.address, (res) => {
if (res.status.isInBlock) {
console.log("in a block");
}
if (res.status.isFinalized) {
console.log("finalized");
console.log("Successfully entered in lottery!");
unsub();
}
});
} catch (error) {
// If there is an error, it will be thrown here
console.log(error);
}

Send Contract Transaction the hard way

If you want to have more control over the transaction, you can use the two steps process. The first step is to dry-run the transaction and check for errors. The second step is to sign and send the transaction.


// Get the initial gas WeightV2 using api.consts.system.blockWeights['maxBlock']
const gasLimit = api.registry.createType(
'WeightV2',
api.consts.system.blockWeights['maxBlock']
)

// Query the contract message
// This will return the gas required and storageDeposit to execute the message
// and the result of the message
const { gasRequired, storageDeposit, result } = await contract.query.enter(
account.address,
{
gasLimit: gasLimit,
storageDepositLimit: null,
value: new BN('1000000000000000000')
}
)

// Check for errors
if (result.isErr) {
let error = ''
if (result.asErr.isModule) {
const dispatchError = api.registry.findMetaError(result.asErr.asModule)
error = dispatchError.docs.length ? dispatchError.docs.concat().toString() : dispatchError.name
} else {
error = result.asErr.toString()
}

console.error(error)
return
}

// Even if the result is Ok, it could be a revert in the contract execution
if (result.isOk) {
const flags = result.asOk.flags.toHuman()
// Check if the result is a revert via flags
if (flags.includes('Revert')) {
const type = contract.abi.messages[5].returnType // here 5 is the index of the message in the ABI
const typeName = type?.lookupName || type?.type || ''
const error = contract.abi.registry.createTypeUnsafe(typeName, [result.asOk.data]).toHuman()

console.error(error ? (error as any).Err : 'Revert')
return
}
}

// Gas require is more than gas returned in the query
// To be safe, we double the gasLimit.
// Note, doubling gasLimit will not cause spending more gas for the Tx
const estimatedGas = api.registry.createType(
'WeightV2',
{
refTime: gasRequired.refTime.toBn().mul(BN_TWO),
proofSize: gasRequired.proofSize.toBn().mul(BN_TWO),
}
) as WeightV2

setLoading(true)

const unsub = await contract.tx
.enter({
gasLimit: estimatedGas,
storageDepositLimit: null,
value: new BN('1000000000000000000') // 1 TOKEN or it could be value you want to send to the contract in title
})
.signAndSend(account.address, (res) => {
// 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)
if (res.status.isInBlock) {
console.log('in a block')
}
if (res.status.isFinalized) {
console.log('Successfully sent the txn')
unsub()
}
})

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.

Events

On the current version of the API, any event 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.

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.

References