Transactions
Preparing, signing, and broadcasting extrinsics is one of the main purposes of polkadot-api
. There are two ways to create transactions in PAPI, and will see both of them in this page.
tx.Pallet.Call
In order to create a transaction directly in PAPI (without a pre-made call data) use the object typedApi.tx
. Every typedApi.tx.Pallet.Call
available in the chain has the following structure:
interface TxEntry<Arg> {
(data: Arg): Transaction
isCompatible: IsCompatible
getCompatibilityLevel: GetCompatibilityLevel
}
We already know how isCompatible
and getCompatibilityLevel
works. In order to get a Transaction
object, we need to pass all arguments required by the extrinsic. Let's see two examples, Balances.transfer_keep_alive
and NominationPools.claim_payout
.
The case of claim_payout
is the simplest one, since it doesn't take any arguments. Simply as
const tx: Transaction = typedApi.tx.NominationPools.claim_payout()
would do the trick. Let's see the other one, that takes arguments:
// MultiAddress is a first class citizen, and there's a special type for it
import { MultiAddress } from "@polkadot-api/descriptors"
const tx: Transaction = typedApi.tx.Balances.transfer_keep_alive({
// these args are strongly typed!
dest: MultiAddress.Id("destAddressInSS58Format"),
value: 10n ** 10n, // 1 DOT
})
txFromCallData
This option will just take a Binary
call data and pack the transaction from it. It will validate the input when creating it, throwing an error otherwise. It will create the transaction asynchronously if you just pass the call data, and synchronously if you pass an already awaited compatibility token!
Its interface is:
interface TxFromBinary {
(callData: Binary): Promise<Transaction>
(callData: Binary, compatibilityToken: CompatibilityToken): Transaction
}
Very simple. Let's see it with an example:
const callData = Binary.fromHex("0x00002c50415049203c3320444f54")
// without compatibility token it's a promise
const tx: Transaction = await api.txFromCallData(callData)
const token = await api.compatibilityToken
// with token is sync!
const txSync: Transaction = api.txFromCallData(callData, token)
Transaction
type
Both methods of creating transactions in PAPI output a Transaction
type, that has the following interface:
type Transaction = {
sign: TxSignFn
signSubmitAndWatch: TxObservable
signAndSubmit: TxPromise
getEncodedData: TxCall
getEstimatedFees: (
from: Uint8Array | SS58String,
txOptions?: TxOptions,
) => Promise<bigint>
decodedCall: Enum
}
We will see item by item its content.
decodedCall
The decodedCall
field holds the papi
way of expressing an extrinsic, decoded in an Enum
type. It could be useful to pass it as call data to a proxy.proxy
call, for example, that takes another call as a parameter:
import { MultiAddress } from "@polkadot-api/descriptors"
const tx: Transaction = typedApi.tx.Balances.transfer_keep_alive({
dest: MultiAddress.Id("destAddressInSS58Format"),
value: 10n ** 10n, // 1 DOT
})
const proxyTx = typedApi.tx.Proxy.proxy({
real: MultiAddress.Id("proxyAddressInSS58Format"),
call: tx.decodedCall,
force_proxy_type: undefined,
})
getEncodedData
getEncodedData
, instead, packs the call data (without signed extensions, of course!) as a SCALE-encoded blob. It also runs the compatibility check, so it needs the runtime and descriptors loaded. As we've seen with getCompatibilityLevel
, if you call it directly it'll be a Promise
-based call, or you can pass in a compatibilityToken
you've previously awaited for and it'll answer synchronously. Let's see an example:
// `getEncodedData` has this interface
interface TxCall {
(): Promise<Binary>
(compatibilityToken: CompatibilityToken): Binary
}
import { MultiAddress } from "@polkadot-api/descriptors"
const tx: Transaction = typedApi.tx.Balances.transfer_keep_alive({
dest: MultiAddress.Id("destAddressInSS58Format"),
value: 10n ** 10n, // 1 DOT
})
// without argument it's async!
const encodedTx = await tx.getEncodedData()
// with compatibilityToken argument it's sync!
const compatibilityToken = await typedApi.compatibilityToken
const encodedTx = tx.getEncodedData(compatibilityToken)
TxOptions
All the methods that will follow sign the transaction (or fake-sign in the case of getEncodedFees
). When signing a transaction, some optional TxOptions
could be passed. Every one of them as a default, so it's not needed to pass them. Let's see and discuss them one by one:
type TxOptions<Asset> = Partial<
void extends Asset
? {
at: HexString | "best" | "finalized"
tip: bigint
mortality: { mortal: false } | { mortal: true; period: number }
nonce: number
}
: {
at: HexString | "best" | "finalized"
tip: bigint
mortality: { mortal: false } | { mortal: true; period: number }
asset: Asset
nonce: number
}
>
at
: gives the option to choose which block to target when creating the transaction. Default:finalized
mortality
: gives the option to choose the mortality for the transaction. Default:{ mortal: true, period: 64 }
nonce
: this is meant for advanced users that submit several transactions in a row, it allows to modify the defaultnonce
. Default: latest nonce fromfinalized
blocktip
: add tip to transaction. Default:0
asset
: there're several chains that allow you to choose which asset to use to pay for the fees and tip. This field will be strongly typed as well and will adapt to every chain used in thedApp
. Default:undefined
. This means to use the native token from the chain.
getEstimatedFees
With getEstimatedFees
we make a call to the runtime and check how much would it cost to run a specific transaction. We need the address of the sender (or public key) and the TxOptions
to construct a fake-signed transaction. We'll check the fees against the latest known finalizedBlock
. Its interface is as follows:
type TxEstimateFees = (
from: Uint8Array | SS58String,
txOptions?: TxOptions<Asset>,
) => Promise<bigint>
sign
As simple as it seems, this method packs the transaction, sends it to the signer, and receives the signature. It requires a PolkadotSigner
, we saw them in another section of the docs. Let's see its interface:
type TxSignFn = (
from: PolkadotSigner,
txOptions?: TxOptions,
) => Promise<HexString>
It'll get back the whole SignedExtrinsic
that needs to be broadcasted. If the signer fails (or the user cancels the signature) it'll throw an error.
signAndSubmit
signAndSubmit
will sign (exactly the same way as sign
). After signing it will validate the transaction against the block specified in txOptions
and broadcast the transaction if it is valid. If it is not it will throw an InvalidTxError
.
- The promise will resolve as soon as the transaction is found in a finalized block.
- The promise will reject if the transaction is invalid at any finalized block after broadcasting. It will throw as well an
InvalidTxError
.
Note that this promise is not abortable. Let's see the interface:
type TxPromise = (
from: PolkadotSigner,
txOptions?: TxOptions,
) => Promise<TxFinalized>
type TxFinalized = {
txHash: HexString
ok: boolean
events: Array<SystemEvent["event"]>
dispatchError?: DispatchError
block: { hash: string; number: number; index: number }
}
You get the txHash
; the bunch of events
that this extrinsic emitted (see this section to see what to do with them); ok
which simply tells if the extrinsic was successful (i.e. event System.ExtrinsicSuccess
is found), with its dispatchError
and the block
information where the tx is found.
signSubmitAndWatch
signSubmitAndWatch
is the Observable-based version of signAndSubmit
. The function returns an Observable and will emit a bunch of events giving information about the status of transaction in the chain, until it'll be eventually finalized or definitely invalid. Let's see its interface:
export type TxObservable = (
from: PolkadotSigner,
txOptions?: TxOptions,
) => Observable<TxEvent>
TxEvent
is divided in 4 different events:
type TxEvent = TxSigned | TxBroadcasted | TxBestBlocksState | TxFinalized
The first two are fairly straight-forward. Let's see them.
First of all, the transaction will be signed (exactly the same way as sign
) and the event TxSigned
will be emitted. As soon as the transaction gets signed, the transaction will be validated aganst the block specified in txOptions
and, if it is valid, the transaction will be broadcasted and TxBroadcasted
will be emitted then. If the transaction is invalid the observable will error with an InvalidTxError
.
This two events can only be emitted once each:
type TxSigned = { type: "signed"; txHash: HexString }
type TxBroadcasted = { type: "broadcasted"; txHash: HexString }
Then, as soon as the block is found in a bestBlock
or if the transaction is not valid in one of the best blocks the following event will be emitted:
type TxBestBlocksState = {
type: "txBestBlocksState"
txHash: HexString
} & (
| {
found: false
isValid: boolean
}
| {
found: true
ok: boolean
events: Array<SystemEvent["event"]>
dispatchError?: DispatchError
block: { hash: string; number: number; index: number }
}
)
We can see that this is a 2-in-1 event. After the broadcast, the library will start verifying the state of the transaction against some best blocks in a smart way. Then, two main situations could happen:
- The transaction is not found in any block in the latest known
bestBlock
branch. If this is the case,polkadot-api
will check if the transaction is still valid in the block. The event received in this case will be
interface TxBestBlockNotFound {
type: "txBestBlocksState"
txHash: HexString
found: false
isValid: boolean
}
- The transaction is found in a
bestBlock
. We already infer that the transaction is valid in this block (otherwise it wouldn't get inside it). Therefore, we align the payload to the finalized event, and the event received is as follows. See the finalized event for more info on the fields.
interface TxBestBlockFound {
type: "txBestBlocksState"
txHash: HexString
found: true
ok: boolean
events: Array<SystemEvent["event"]>
dispatchError?: DispatchError
block: { hash: string; number: number; index: number }
}
This event will be emitted any number of times. It might happen that the tx is found in a best block, then this block gets pruned and is not anymore in the new best block branch, comes back, etc. We'll pass all that information to the consumer.
Here two things can happen. The first one is that the tx gets in a block that becomes finalized. In this case we will emit the following event once and will complete the subscription.
type TxFinalized = {
type: "finalized"
txHash: HexString
ok: boolean
events: Array<SystemEvent["event"]>
dispatchError?: DispatchError
block: { hash: string; number: number; index: number }
}
At this stage, the transaction is valid and already in the canonical chain, in a finalized block. We pass, besides the txHash
as in the other events, the following stuff:
ok
: it tells if the extrinsic was successful in its purpose. Under the hood it basically checks that the eventSystem.ExtrinsicFailed
was not emitted.events
: array of all events emitted by the extrinsic. They are ordered as emitted on-chain.dispatchError
: in case the transaction failed, this will have thedispatchError
value ofSystem.ExtrinsicFailed
. Read more about it inDispatchError
block
: information of the block where thetx
is present.hash
of the block,number
of the block,index
of the tx in the block.
On the other hand, if the transaction is invalid in any finalized block after the broadcasting the observable will error with an InvalidTxError
.
InvalidTxError
When a transaction is deemed as invalid (due to, for example, wrong nonce, expired mortality, not enough balance to pay the fees, etc) we provide a strongly typed error. It can be used as follows:
import { InvalidTxError, TransactionValidityError } from "polkadot-api"
import { myChain } from "@polkadot-api/descriptors"
tx.signAndSubmit(signer)
.then(() => "tx went well")
.catch((err) => {
if (err instanceof InvalidTxError) {
const typedErr: TransactionValidityError<typeof myChain> = err.error
console.log(typedErr)
}
})
// it is available, of course, for observable-based broadcasting
tx.signSubmitAndWatch(signer).subscribe({
error: (err) => {
if (err instanceof InvalidTxError) {
const typedErr: TransactionValidityError<typeof myChain> = err.error
console.log(typedErr)
}
},
})
This typedErr
will be, then, strongly typed as any other type coming from PAPI. Its content might differ per chain, but it enables the developer to get the information required and act accordingly.
DispatchError
In Polkadot, a transaction can be valid (and therefore not to throw the InvalidError
) but the inner extrinsic fail. In this case, the event ExtrinsicFailed
gives all the information required to understand why it failed. We also expose a dispatchError
field that helps to guess why it failed. Better a picture than a thousand words:
// `Chain` will change depending on the name you gave the chain
// in the codegen
import { ChainDispatchError } from "@polkadot-api/descriptors"
tx.signSubmitAndWatch(signer).subscribe((ev) => {
if (
ev.type === "finalized" ||
(ev.type === "txBestBlocksState" && ev.found)
) {
// here we are sure that the transaction is in a block (whether finalized or bestBlock)
// with `ok` we know the extrinsic failed
if (!ev.ok) {
const err: ChainDispatchError = ev.dispatchError
// you will have a strongly typed object that you can keep narrowing down
// to find the root of the issue
if (err.type === "Module" && err.value.type === "Balances")
"keep checking..."
}
}
})