Front-end app: smart contract interaction
Learn how to query a contract's state and send signed transactions in your front-end application.
Intro
In this tutorial, you will learn how to make the front-end of your App interact with smart contracts using @polkadot/api-contract
, more specifically:
how to read values stored in your smart contract
how to send signed transactions to your smart contract
Connect to a deployed smart contract
The first step will be to create an API instance to connect to a running node. For that, we need a provider: the default instance of WsProvider
connects to "ws://127.0.0.1:9944"
which usually is your local node's endpoint. For Aleph Zero Testnet use "wss://ws.test.azero.dev"
and for Aleph Zero Mainnet: "wss://ws.azero.dev"
.
import { ApiPromise, WsProvider } from '@polkadot/api';
...
const APP_PROVIDER_URL = "ws://127.0.0.1:9944";
const wsProvider = new WsProvider(APP_PROVIDER_URL);
const api = await ApiPromise.create({ provider: wsProvider });
The @polkadot/api-contract
comes with 4 general helpers (see the docs). Here, we are using ContractPromise
which allows us to interact with already deployed contracts.
To create the contract-api instance, we will need the contract's address and ABI. The contract ABI can be found in the artifacts generated when building the contract (see Creating your first contract).
import { ContractPromise } from '@polkadot/api-contract';
...
import bulletinBoardMetadata from '../metadata/metadata_bulletin_board.json';
import addresses from '../metadata/addresses.json';
const bulletin_board_address = addresses.bulletin_board_address;
const contract = new ContractPromise(
api,
bulletinBoardMetadata,
bulletin_board_address
);
Query a contract state
Under the hood, querying a contract is an extrinsic dry run. It is not submitted on the chain, however, it requires to specify the gasLimit
which refers to the maximum resources used by a contract call. Because we are not submitting an extrinsic to the chain (we're only performing a dry-run), it is safe to use sufficiently high values to ensure that the call won’t be reverted. For contract calls, Substrate uses a 2-dimensional weight (gas) system which consists of:
refTime
: the amount of computational time used for execution, in picoseconds;proofSize
: the amount of storage in bytes that a transaction is allowed to read.
import { BN, BN_ONE } from '@polkadot/util';
import type { WeightV2 } from '@polkadot/types/interfaces';
...
const MAX_CALL_WEIGHT = new BN(5_000_000_000_000).isub(BN_ONE);
const readOnlyGasLimit = api.registry.createType('WeightV2', {
refTime: new BN(1_000_000_000_000),
proofSize: MAX_CALL_WEIGHT,
}) as WeightV2;
In this example, we call the contract method getByAccount
which takes one argument accountId
. For simplicity, we are using the contract address as the caller.
...
const {
gasConsumed,
gasRequired,
storageDeposit,
result,
output,
debugMessage,
} = await contract.query.getByAccount(
contract.address, // caller address
{
gasLimit: readOnlyGasLimit,
},
accountId
);
if (result.isOk && output) {
console.log(output.toHuman());
}
if (result.isErr) {
console.log(result.toHuman());
}
Contract query results in ContractCallOutcome
. If the query is successful, you can extract the return value from the output object. See how it can be done here.
Send signed transaction to a contract
Signing account
To sign a transaction, we need a wallet. To keep this example simple, we will use @polkadot/extension-dapp
to retrieve wallet providers added to the app page and assume that there’s at least one account. See the Aleph Zero Signer Integration tutorial for more information.
import {
web3Enable,
web3Accounts,
} from "@polkadot/extension-dapp";
const APP_NAME = 'Aleph Zero The Bulletin Board';
const injectedExtensions = await web3Enable(APP_NAME);
// Return list of accounts imported from extensions
const accounts: InjectedAccountWithMeta[] = await web3Accounts();
// For simplicity, here we assume that there's at least one account in the connected wallet extensions
const userAccount = accounts[0];
Gas estimation
To sign and send a transaction to a contract, we should estimate values for gasLimit
. Although the unused gas is refunded after the call, it is good practice to specify a reasonable gasLimit
for transactions. As shown here, this (gasRequired
) can be estimated with contract.query.[method]
. Here is an alternative way of how to do it using api.call.contractsApi.call<ContractExecResult>
.
In practice, when estimating gasLimit
using gasRequired
you may want to add some margin to ensure the transaction won't be reverted due to insufficient gasLimit
.
The first step is to get AbiMessage
for the contract method. The function below searches for the method in the contract ABI and returns it if found.
...
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
const toContractAbiMessage = (
contractPromise: ContractPromise,
message: string
): Result<AbiMessage, string> => {
const value = contractPromise.abi.messages.find((m) => m.method === message);
if (!value) {
const messages = contractPromise?.abi.messages
.map((m) => m.method)
.join(", ");
const error = `"${message}" not found in metadata.spec.messages: [${messages}]`;
console.error(error);
return { ok: false, error };
}
return { ok: true, value };
};
The getGasLimit(...)
function below estimates the gasRequired
for this contract call which should be sufficient to cover the gas fees when submitting the actual extrinsic. There are also other contract call options which can be adjusted:
storageDepositLimit
- The maximum amount of balance that can be charged/reserved for the storage consumed.value
- The balance (in native currency,AZERO
in our case) to transfer from the caller to the contract. Non-zero values here apply only to methods marked with the#[ink(message, payable)]
macro.
import type { Weight, ContractExecResult } from "@polkadot/types/interfaces";
import { AbiMessage, ContractOptions } from "@polkadot/api-contract/types";
...
const getGasLimit = async (
api: ApiPromise,
userAddress: string,
message: string,
contract: ContractPromise,
options = {} as ContractOptions,
args = [] as unknown[]
// temporarily type is Weight instead of WeightV2 until polkadot-js type `ContractExecResult` will be changed to WeightV2
): Promise<Result<Weight, string>> => {
const abiMessage = toContractAbiMessage(contract, message);
if (!abiMessage.ok) return abiMessage;
const { value, gasLimit, storageDepositLimit } = options;
const { gasConsumed, gasRequired, storageDeposit, debugMessage, result } =
await api.call.contractsApi.call<ContractExecResult>(
userAddress,
contract.address,
value ?? new BN(0),
gasLimit ?? null,
storageDepositLimit ?? null,
abiMessage.value.toU8a(args)
);
return { ok: true, value: gasRequired };
};
Sign and send the transaction
Function sendPost(...)
puts all the pieces together. It calls the payable method on the contract post(expiresAfter: u32, postText: String)
and handles the result.
...
const sendPost = async (
expiresAfter: number,
postText: string,
totalPrice: number,
api: ApiPromise,
userAccount: InjectedAccountWithMeta
): Promise<void> => {
if (!userAccount.meta.source) return;
const contract = new ContractPromise(
api,
bulletinBoardMetadata,
addresses.bulletin_board_address
);
const injector = await web3FromSource(userAccount.meta.source);
const options: ContractOptions = {
value: totalPrice,
};
const gasLimitResult = await getGasLimit(
api,
userAccount.address,
"post",
contract,
options,
[expiresAfter, postText]
);
if (!gasLimitResult.ok) {
console.log(gasLimitResult.error);
return;
}
const { value: gasLimit } = gasLimitResult;
const tx = contract.tx.post(
{
value: totalPrice, // amount of native token to be transferred
gasLimit,
},
expiresAfter,
postText
);
await tx
.signAndSend(
userAccount.address,
{ signer: injector.signer },
({ events = [], status }) => {
events.forEach(({ event }) => {
const { method } = event;
if (method === "ExtrinsicSuccess" && status.type === "InBlock") {
console.log("Success!");
} else if (method === "ExtrinsicFailed") {
console.log(`An error occured: ${method}.`);
}
});
}
)
.catch((error) => {
console.log(`An error occured: ${error}.`);
});
};
To learn more about how to handle transaction events see Transaction Subscriptions. Also, see how it is done in the Bulletin Board Example.
When running substrate-contract-node, the blocks are never finalized since it does not use a finality mechanism by default. On a live network such as Aleph Zero Testnet, you should expect a ‘Finalized’ transaction status soon after the ‘InBlock’ status.
If you want to run a local chain that will work exactly as the Testnet, you can use the ./scripts/run_nodes.sh
script, which will bootstrap a small chain locally.
Closing remarks
We hope that this tutorial helped you learn more about how to interact with smart contracts in the front-end application. You shouldn't stop here! Check out other resources that may help you extend your dApp:
Nightly-connect: A permissionless, open-source solution that serves as both a wallet adapter and a bridge wallet, enabling connections through QR codes or deep links.
Typechain: Generate Typescript wrappers around your smart contract!
ink!athon: A full-stack dApp boilerplate for ink! smart contracts with an integrated front-end.
Last updated
Was this helpful?