| name | polkadot-transactions |
| description | Submit transactions, connect wallets, manage signers, and handle keys in polkadot-apps. Use when: submitting transactions, connecting browser wallet extensions (Talisman, Polkadot.js, SubWallet), integrating Host API signing (Polkadot Desktop/Mobile), managing multi-provider wallet accounts, deriving keys, or creating dev signers for testnet. Covers @polkadot-apps/tx (submit/watch), @polkadot-apps/signer (wallet connection, account management, multi-provider signing), and @polkadot-apps/keys (key derivation, session keys).
|
Polkadot Transactions, Signing, and Key Management
This skill covers three packages that work together for submitting on-chain transactions:
| Package | Import | Purpose |
|---|
| tx | @polkadot-apps/tx | Submit, watch, retry transactions |
| signer | @polkadot-apps/signer | Manage signing accounts across providers |
| keys | @polkadot-apps/keys | Derive keys, accounts, and session keys |
Quick Start: Submit a Transaction in 10 Lines
import { createDevSigner, submitAndWatch } from "@polkadot-apps/tx";
import type { TxStatus, TxResult } from "@polkadot-apps/tx";
const alice = createDevSigner("Alice");
const tx = api.tx.Balances.transfer_keep_alive({
dest: { type: "Id", value: recipientAddress },
value: 1_000_000_000_000n,
});
const result = await submitAndWatch(tx, alice);
console.log(result.ok ? "Success" : "Failed", result.block.hash);
WARNING: Dev signers (createDevSigner) use well-known private keys. They are for local development and testnets ONLY. Never use in production.
Three Distinct Signer Types
WARNING: Three different signer-related types exist in this codebase. Do not confuse them.
| Type | Package | What It Is |
|---|
PolkadotSigner | polkadot-api | Low-level signer passed to submitAndWatch(). Signs extrinsics. |
SignerAccount | @polkadot-apps/signer | Account wrapper with address, publicKey, source, and getSigner() that returns a PolkadotSigner. |
SignerManager | @polkadot-apps/signer | Orchestrator that discovers accounts from multiple providers and manages selection state. |
How they connect:
SignerManager.connect() -> SignerAccount[] -> account.getSigner() -> PolkadotSigner -> submitAndWatch(tx, signer)
Transaction Lifecycle
1. Build the Transaction
From a PAPI typed API:
const tx = api.tx.Balances.transfer_keep_alive({ dest, value });
From an Ink SDK contract (dry-run first):
import { extractTransaction } from "@polkadot-apps/tx";
const dryRun = await contract.query("mint", { origin, data: { name, price } });
const tx = extractTransaction(dryRun);
2. (Optional) Apply Weight Buffer for ReviveApi Calls
import { applyWeightBuffer } from "@polkadot-apps/tx";
const dryRun = await api.apis.ReviveApi.call(origin, dest, value, undefined, undefined, data);
const tx = api.tx.Revive.call({
dest, value, data,
weight_limit: applyWeightBuffer(dryRun.weight_required),
storage_deposit_limit: dryRun.storage_deposit.value,
});
3. Sign and Submit
import { submitAndWatch } from "@polkadot-apps/tx";
const result = await submitAndWatch(tx, signer, {
waitFor: "best-block",
timeoutMs: 300_000,
mortalityPeriod: 256,
onStatus: (status: TxStatus) => updateUI(status),
});
3b. (Optional) Batch Multiple Transactions
Submit multiple transactions as a single atomic batch — one signing prompt, one fee.
import { batchSubmitAndWatch } from "@polkadot-apps/tx";
const tx1 = client.assetHub.tx.Balances.transfer_keep_alive({ dest: addr1, value: 1_000n });
const tx2 = client.assetHub.tx.Balances.transfer_keep_alive({ dest: addr2, value: 2_000n });
const tx3 = client.assetHub.tx.System.remark({ remark: Binary.fromText("hello") });
const result = await batchSubmitAndWatch([tx1, tx2, tx3], client.assetHub, signer, {
onStatus: (status: TxStatus) => updateUI(status),
});
Three batch modes corresponding to Substrate's Utility pallet:
| Mode | Behavior |
|---|
"batch_all" (default) | Atomic. Reverts all calls if any single call fails. |
"batch" | Best-effort. Stops at first failure but earlier successful calls are not reverted. |
"force_batch" | Like batch but continues after failures (never aborts early). |
const result = await batchSubmitAndWatch(calls, api, signer, { mode: "batch" });
Calls can be PAPI transactions, Ink SDK AsyncTransaction wrappers, or raw decoded calls — mixed freely in the same array:
const [dryRun1, dryRun2] = await Promise.all([
contract.query("updateDoc", { origin, data: args1 }),
contract.query("grantAccess", { origin, data: args2 }),
]);
const tx1 = extractTransaction(dryRun1);
const tx2 = extractTransaction(dryRun2);
const result = await batchSubmitAndWatch([tx1, tx2], client.assetHub, signer);
4. (Optional) Retry Transient Failures
import { withRetry, submitAndWatch } from "@polkadot-apps/tx";
const result = await withRetry(
() => submitAndWatch(tx, signer),
{ maxAttempts: 3, baseDelayMs: 1_000, maxDelayMs: 15_000 },
);
withRetry only retries transient errors (network disconnects, RPC failures). It does NOT retry:
TxBatchError (batch construction failure like empty calls)
TxDispatchError (on-chain failure like insufficient balance)
TxSigningRejectedError (user rejected in wallet)
TxTimeoutError (already waited full duration)
Works with batchSubmitAndWatch too:
const result = await withRetry(
() => batchSubmitAndWatch(calls, api, signer),
{ maxAttempts: 3 },
);
5. (Optional) Ensure Account Mapping for EVM Contracts
import { ensureAccountMapped } from "@polkadot-apps/tx";
await ensureAccountMapped(address, signer, inkSdk, api);
Error Handling
All tx errors extend TxError. Catch them hierarchically:
import {
TxError, TxTimeoutError, TxDispatchError,
TxSigningRejectedError, TxDryRunError, TxBatchError,
} from "@polkadot-apps/tx";
try {
const result = await submitAndWatch(tx, signer);
} catch (e) {
if (e instanceof TxSigningRejectedError) {
} else if (e instanceof TxBatchError) {
} else if (e instanceof TxDispatchError) {
console.log(e.formatted);
} else if (e instanceof TxTimeoutError) {
console.log(`Timed out after ${e.timeoutMs}ms`);
} else if (e instanceof TxDryRunError) {
console.log(e.revertReason);
console.log(e.formatted);
} else if (e instanceof TxError) {
}
}
For signer errors, all extend SignerError:
import { SignerError, isHostError, isExtensionError } from "@polkadot-apps/signer";
const result = await manager.connect();
if (!result.ok) {
if (isHostError(result.error)) { }
if (isExtensionError(result.error)) { }
}
Dev Signers for Testnet
import { createDevSigner, getDevPublicKey } from "@polkadot-apps/tx";
const alice = createDevSigner("Alice");
const alicePubKey = getDevPublicKey("Alice");
const result = await submitAndWatch(tx, alice);
These use the well-known Substrate dev mnemonic with Sr25519 derivation at //Name.
SignerManager: Multi-Provider Account Management
import { SignerManager } from "@polkadot-apps/signer";
const manager = new SignerManager({
ss58Prefix: 42,
hostTimeout: 10_000,
extensionTimeout: 1_000,
maxRetries: 3,
dappName: "my-app",
persistence: localStorage,
});
const unsub = manager.subscribe((state) => {
console.log(state.status, state.accounts, state.selectedAccount);
});
const result = await manager.connect();
if (result.ok) {
manager.selectAccount(result.value[0].address);
const signer = manager.getSigner();
if (signer) {
const txResult = await submitAndWatch(tx, signer);
}
}
const sigResult = await manager.signRaw(new Uint8Array([1, 2, 3]));
const productAccount = await manager.getProductAccount("myapp.dot");
const alias = await manager.getProductAccountAlias("myapp.dot");
manager.destroy();
KeyManager: Hierarchical Key Derivation
import { KeyManager } from "@polkadot-apps/keys";
const km = KeyManager.fromSignature(signatureBytes, signerAddress);
const km2 = KeyManager.fromRawKey(rawKeyBytes);
const encKey = km.deriveSymmetricKey("doc:123");
const account = km.deriveAccount("app-account", 42);
const kp = km.deriveKeypairs();
const raw = km.exportKey();
SessionKeyManager: Mnemonic-Based Session Keys
import { SessionKeyManager } from "@polkadot-apps/keys";
import { createKvStore } from "@polkadot-apps/storage";
const store = await createKvStore({ prefix: "session-key" });
const skm = new SessionKeyManager({ store, name: "default" });
const info = await skm.getOrCreate();
const existing = await skm.get();
const fromMnemonic = skm.fromMnemonic("abandon abandon ...");
await skm.clear();
seedToAccount: Low-Level Account Derivation
import { seedToAccount } from "@polkadot-apps/keys";
const account = seedToAccount(
mnemonic,
"//0",
42,
"sr25519",
);
Common Mistakes
-
Using dev signers in production - createDevSigner uses the well-known dev mnemonic. Anyone can recreate these keys. Use SignerManager with extension or host providers for real users.
-
Confusing signer types - submitAndWatch needs a PolkadotSigner (from polkadot-api), not a SignerAccount. Call account.getSigner() to get the PolkadotSigner from a SignerAccount.
-
Missing await on submitAndWatch - It returns a Promise. Forgetting await means you do not see errors or the result.
-
Not handling TxDispatchError - A transaction can be included on-chain but still fail. Always check result.ok or catch TxDispatchError.
-
Retrying non-retryable errors - Do not wrap withRetry around the full flow if you want to handle dispatch errors specially. withRetry already skips non-retryable errors, but your error handling should account for this.
-
Forgetting account mapping - EVM contract interactions on Asset Hub require calling ensureAccountMapped first. It is idempotent so safe to call every time.
-
Assuming batch mode is atomic - The default "batch_all" mode is atomic, but "batch" mode stops at the first failure without reverting earlier calls. With "force_batch", execution continues past failures. In both non-atomic modes, inspect result.events for Utility.ItemFailed or Utility.BatchInterrupted events to detect individual failures.
-
Missing TransactionSubmit permission inside a host container - Since @polkadot-apps/signer@1.0.2, SignerManager.connect() requests this permission automatically on the host path. Before that, tx submissions could hang silently with no error. If you hit hangs after a clean connect, verify your signer package is >= 1.0.2, or opt-in explicitly with new SignerManager({ createProvider: (type) => type === "host" ? new HostProvider({ requestTransactionSubmitPermission: true, ss58Prefix: 42 }) : ... }).
End-to-end testing
Canonical pattern: see examples/tx-demo/ — a minimal Vite + vanilla-TS app that
drives SignerManager + getChainAPI("paseo") + submitAndWatch /
batchSubmitAndWatch with a Playwright suite powered by
@parity/host-api-test-sdk. The test host injects dev accounts (Bob by
default), auto-approves permissions, auto-signs payloads, and proxies chain
RPC — so the demo exercises the real host path end-to-end against live Paseo
Asset Hub. To bootstrap a new <pkg>-demo, copy that directory and swap the
package under test; the same six files (package.json, index.html,
src/main.ts, playwright.config.ts, e2e/fixtures.ts, e2e/<flow>.spec.ts)
apply verbatim.
Reference Files