Ink! SDK
The Ink! SDK is a library for interacting with smart contracts, which supports both pallet contracts (for ink!v5 or below) and pallet revive (for ink!v6 and above and solidity).
Built on top of the Ink! Client, a set of low-level bindings for ink!, this SDK significantly simplifies interacting with contracts. It provides a developer-friendly interface that covers most dApps use cases.
Getting Started
Install the sdk through your package manager:
pnpm i @polkadot-api/sdk-ink
Begin by generating the type definitions for your chain and contract. For example, using the starter flipper
contract on Passet Hub:
pnpm papi add -w wss://testnet-passet-hub.polkadot.io passet
pnpm papi ink add ./flipper.json # Path to the .contract or .json metadata file
This process uses the name defined in the contract metadata to export it as a property of contracts
in @polkadot-api/descriptors
, which will be needed when interacting with a contract. You can now instantiate the Ink! SDK:
import { createInkSdk } from "@polkadot-api/sdk-ink"
import { createClient } from "polkadot-api"
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"
import { getWsProvider } from "polkadot-api/ws-provider"
const client = createClient(
withPolkadotSdkCompat(getWsProvider("wss://testnet-passet-hub.polkadot.io")),
)
const inkSdk = createInkSdk(client)
The SDK provides two main functions for different workflows:
getDeployer(contract: ContractDescriptors, code: Binary)
: Returns an API for deploying contracts.getContract(contract: ContractDescriptors, address: SS58String)
: Returns an API for interacting with deployed contracts.
Contract Deployer
import { contracts } from '@polkadot-api/descriptors'
import { Binary } from "polkadot-api";
const codeBlob = ...; // Uint8Array of the contract WASM (v5-) or PolkaVM (v6+) blob.
const code = Binary.fromBytes(codeBlob);
const flipperDeployer = inkSdk.getDeployer(contracts.flipper, code);
The deployer API supports two methods: one for dry-running deployments and another for actual deployments.
When deploying, the SDK calculates all parameters, requiring only the constructor arguments defined in the contract.
To calculate the gas limit and storage deposit limit required, the SDK also needs the origin account the transaction will be sent from. So it either uses the origin account or the gas limit if you already have it.
The "salt" parameter ensures unique contract deployments. By default, it is empty, but you can provide a custom value for deploying multiple instances of the same contract.
// Deploy psp22
const ALICE = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
const tx = flipperDeployer.deploy("new", {
origin: ALICE,
data: {
initial_value: false,
},
})
// `tx` is a regular transaction that can be sent with `.signSubmitAndWatch`, `.signAndSubmit`, etc.
const result = await tx.signAndSubmit(aliceSigner)
// To get the resulting address the contract was deployed to, we can pass the events back into the SDK:
const data: Array<{
address: string
contractEvents: EventDescriptor[]
}> = psp22Sdk.readDeploymentEvents(result.events)
This gives us all the contracts created by that transaction (in case you batched them), along with the decoded events emitted by each one of them.
Dry-running takes in the same arguments, but returns a Promise with the result directly instead:
const dryRunResult = await psp22Deployer.dryRun("new", {
origin: ALICE,
data: {
initial_value: false,
},
})
if (dryRunResult.success) {
console.log(dryRunResult.value)
/*
dryRunResult.value has:
{
// Resulting address of contract
address: SS58String;
// Events that would be generated when deploying
events: EventDescriptor[];
// Weight required
gasRequired: Gas;
}
*/
// The dry-run result also has a method to get the transaction to perform the actual deployment.
const deploymentResult = await dryRunResult.value
.deploy()
.signAndSubmit(aliceSigner)
}
Contract API
The contract API targets a specific instance of a contract by address, providing multiple interaction functions:
const flipperAddr = "0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc"
const flipperContract = inkSdk.getContract(contracts.flipper, flipperAddr)
// You optionally can make sure the hash hasn't changed by checking compatibility
if (!(await flipperContract.isCompatible())) {
throw new Error("Contract has changed")
}
Query
Sending a query (also known as dry-running a message), can be sent directly through the .query()
method, passing the name of the message, origin and arguments:
console.log("Get balance of ALICE")
const result = await flipperContract.query("get", {
origin: ALICE,
})
if (result.success) {
console.log("flip value", result.value.response)
console.log("events", result.value.events)
// The dry-run result also has a method to get the transaction to send the same message to the contract.
const callResult = await result.value.send().signAndSubmit(aliceSigner)
} else {
console.log("error", result.value)
}
Send
Sending a message requires signing a transaction, which can be created with the .send()
method:
console.log("Flipping")
const flipTxResult = await flipperContract
.send("flip", {
origin: ADDRESS.alice,
})
.signAndSubmit(aliceSigner)
if (flipTxResult.ok) {
console.log("block", flipTxResult.block)
// The events generated by this contract can also be filtered using `filterEvents`:
console.log("events", flipperContract.filterEvents(flipTxResult.events))
} else {
console.log("error", flipTxResult.dispatchError)
}
Redeploy
Contract instances can also be redeployed without the need to have the actual WASM blob. This is similar to using the Contract Deployer, but it's done directly from the contract instance:
const salt = Binary.fromHex("0x00")
const result = await flipperContract.dryRunRedeploy("new", {
data,
origin: ADDRESS.alice,
options: {
salt,
},
})
if (result.success) console.log("redeploy dry run", result)
const txResult = await flipperContract
.redeploy("new", {
data,
origin: ADDRESS.alice,
options: {
salt,
},
})
.signAndSubmit(signer)
const deployment = psp22Sdk.readDeploymentEvents(ADDRESS.alice, txResult.events)
Storage API
The storage of a contract is a tree structure, where you can query the values that are singletons, but for those that can grow into lists, maps, etc. they have to be queried separately.
This SDK has full typescript support for storage. You start by selecting where to begin from the tree, and you'll get back an object with the data within that tree.
const storage = flipperContract.getStorage()
const root = await storage.getRoot()
if (root.success) {
/* result.value is what the flipper contract has defined as the root of its storage */
}
If inside of that subtree there are storage entries that have to be queried separately, the SDK turns those properties into functions that query that. For example, a PSP22 contract has nested storage roots, which mean that in the example above, it would have data.allowances
and data.balances
properties which are async functions that return the value for a given key.
If you don't need the data of the root and just want to query for a specific balance directly, you can get any nested subtree by calling .getNested()
:
// Flipper doesn't have any nested storage, but for a contract with a "data.balances" Map:
const aliceBalance = await storage.getNested("data.balances", ALICE)
if (aliceBalance.success) {
console.log("Alice balance", aliceBalance.value)
}
Solidity Contracts
The ink! SDK basically abstracts over Revive pallet, which means that it also supports Solidity contracts.
In this case, to generate the types of your contract, add your contracts to the project using papi sol add
instead and with a contract name:
# ./ballot.abi in JSON format
pnpm papi ink add ./ballot.abi ballot
And you can use it the same way as shown above:
import { contracts } from "@polkadot-api/descriptors"
const contractAddress = "0x45db12…"
const ballot = inkSdk.getContract(contracts.ballot, contractAddress)
const result = await ballot.query("winningProposal", {
origin: ADDRESS.alice,
})
if (result.success) {
console.log(`Winning proposal: ${result.value.response.winningProposal_}`)
} else {
console.log(`request failed`, result.value)
}
You can find a working example in the github repo
Revive Addresses
Ink! v6+ contracts are deployed to pallet-revive
, which took several design compromises to make it compatible with EVM contracts.
PAPI’s ink-sdk
supports both pallet-contracts
and pallet-revive
, providing a unified interface where all features are supported for both (including queries, dry-run instantiations with event handling, sending and instantiating contracts, and storage access).
The main issue comes from the fact that contracts in Revive use Ethereum-like addresses, represented as 20-byte hex strings. ink-sdk
includes several helpers to work with these addresses:
Account Mapping
Before an account can interact with a contract, it must be mapped using the transaction typedApi.tx.Revive.map_account()
(even for dry-runs). See issue #8619.
The Ink SDK provides a convenient function to check whether a given account is already mapped (as this information is not directly accessible from storage):
const isMapped = await inkSdk.addressIsMapped(ALICE)
if (!isMapped) {
console.log("Alice needs to be mapped first!")
}
Deployment Address
Contract addresses are deterministic: they are derived either from the signer’s accountId
+ contract code + constructor data + salt, or just the signer’s accountId
+ nonce if no salt is used.
The Revive SDK provides methods to estimate the contract address given these parameters:
const erc20Sdk = createReviveSdk(typedApi, contracts.erc20)
const erc20Deployer = erc20Sdk.getDeployer(code)
// Estimate address using nonce:
const estimatedAddressWithNonce = await erc20Deployer.estimateAddress("new", {
origin: ADDRESS.alice,
// Optionally, specify `nonce: {number}`. Otherwise, the SDK will query it for you.
})
// Estimate address using salt:
const estimatedAddressWithSalt = await erc20Deployer.estimateAddress("new", {
origin: ADDRESS.alice,
salt: Binary.fromHex(
"0x0000000000000000000000000000000000000000000000000000000000000000",
),
})
// Since the nonce-based equation does not depend on contract code, ink-sdk exposes
// a lower-level utility function for quick address calculation:
import { getDeploymentAddressWithNonce } from "@polkadot-api/sdk-ink"
const manualAddress = getDeploymentAddressWithNonce(ADDRESS.alice, 123)
Contract's AccountId
Because contract addresses are now in Ethereum-like format (20-byte hex strings), retrieving their AccountId
(SS58) is not straightforward, which could be used for example to check the contract's funds.
ink-sdk
provides an accountId
property to get the SS58 address of the contract:
const contract = inkSdk.getContract(contracts.flipper, flipperAddress)
const account = await typedApi.query.System.Account.getValue(contract.accountId)
console.log("Contract account", account)