-
Notifications
You must be signed in to change notification settings - Fork 8
Contracts
Here we'll be guiding you on how to interact with a WASM smart contract using Astar.js.
You can find a working example with flipper contract here https://github.com/AstarNetwork/wasm-flipper
The contract metadata and the wasm code are generated by building the contract with Swanky CLI.
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);
...
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.
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.
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 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.
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.
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.
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.
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')
}
})
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.
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);
}