| name | product-sdk-transactions |
| description | Submit transactions, connect wallets, manage signers, and handle keys in product-sdk. Use when: submitting transactions, integrating Host API signing (Polkadot Desktop/Mobile), managing multi-provider wallet accounts, deriving keys, creating dev signers for testnet, or wiring QR/mobile sign-in for CLIs. Covers @parity/product-sdk-tx (submit/watch), @parity/product-sdk-signer (wallet connection, account management, multi-provider signing), @parity/product-sdk-keys (key derivation, session keys), and @parity/product-sdk-auth (QR/mobile CLI sign-in + product-account session signers).
|
Product SDK Transactions, Signing, and Key Management
This skill covers three packages that work together for submitting on-chain transactions:
| Package | Import | Purpose |
|---|
| tx | @parity/product-sdk-tx | Submit, watch, retry transactions |
| signer | @parity/product-sdk-signer | Manage signing accounts across providers |
| keys | @parity/product-sdk-keys | Derive keys, accounts, and session keys |
| auth | @parity/product-sdk-auth | QR/mobile CLI sign-in + product-account session signers |
Quick Start: Submit a Transaction in 10 Lines
import { createDevSigner, submitAndWatch } from "@parity/product-sdk-tx";
import type { TxStatus, TxResult } from "@parity/product-sdk-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);
if (!result.ok) {
console.log("Failed", result.error.message);
} else {
console.log("Success", result.value.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 | @parity/product-sdk-signer | Account wrapper with address, publicKey, source, and getSigner() that returns a PolkadotSigner. |
SignerManager | @parity/product-sdk-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 "@parity/product-sdk-tx";
const dryRun = await contract.query("mint", { origin, data: { name, price } });
const extracted = extractTransaction(dryRun);
if (!extracted.ok) throw extracted.error;
const tx = extracted.value;
2. Sign and Submit
import { submitAndWatch } from "@parity/product-sdk-tx";
const result = await submitAndWatch(tx, signer, {
waitFor: "best-block",
timeoutMs: 300_000,
mortalityPeriod: 256,
onStatus: (status: TxStatus) => updateUI(status),
});
if (result.ok) {
console.log(result.value.txHash, result.value.block.hash);
} else {
console.log(result.error.message);
}
3. Batch Multiple Transactions
Submit multiple transactions as a single atomic batch — one signing prompt, one fee.
import { batchSubmitAndWatch } from "@parity/product-sdk-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),
});
if (!result.ok) {
handleBatchFailure(result.error);
return;
}
console.log("Batch finalized", result.value.block.hash);
Three batch modes:
| 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. |
4. Retry Transient Failures
import { withRetry, submitAndWatch } from "@parity/product-sdk-tx";
const result = await withRetry(
() => submitAndWatch(tx, signer),
{ maxAttempts: 3, baseDelayMs: 1_000, maxDelayMs: 15_000 },
);
Error Handling
All tx errors extend TxError. submitAndWatch / batchSubmitAndWatch do NOT
throw on tx failure — they return err(TxError). Branch on result.ok and
narrow the typed result.error:
import {
TxError, TxTimeoutError, TxDispatchError,
TxSigningRejectedError, TxDryRunError, TxBatchError,
} from "@parity/product-sdk-tx";
const result = await submitAndWatch(tx, signer);
if (!result.ok) {
const e = result.error;
if (e instanceof TxSigningRejectedError) {
} 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 TxError) {
}
} else {
}
Dev Signers for Testnet
import { createDevSigner, getDevPublicKey } from "@parity/product-sdk-tx";
const alice = createDevSigner("Alice");
const result = await submitAndWatch(tx, alice);
SignerManager: Multi-Provider Account Management
import { SignerManager } from "@parity/product-sdk-signer";
const manager = new SignerManager({
ss58Prefix: 42,
dappName: "my-app",
});
const unsub = manager.subscribe((state) => {
console.log(state.status, state.accounts, state.selectedAccount);
});
const result = await manager.connect();
if (result.ok) {
const productRes = await manager.getProductAccount("my-app.dot", 0);
if (productRes.ok) {
const productAccount = productRes.value;
const txResult = await submitAndWatch(tx, productAccount.getSigner());
}
}
manager.destroy();
See examples/tx-demo/src/main.ts for the
full end-to-end pattern (imports, state, init flow).
CLI Sign-In and Session Signers (@parity/product-sdk-auth)
For command-line products, @parity/product-sdk-auth is the shared sign-in
layer: QR/mobile pairing, persisted sessions, a sign-out flow, and a
product-account signer — all bound to a product via injected config (no per-CLI
config.ts). It sits on top of @parity/product-sdk-terminal and derives the
product account with the same derivation scheme the mobile wallet uses (via
terminal's deriveProductPublicKey, the CLI counterpart of the keys package's
deriveProductAccountPublicKey below).
The signer it returns signs as the product account (/product/{productId}/{index}),
NOT the wallet's selected account — so its address matches the funded /
allowance-granted account. This is the CLI analogue of SignerManager.getProductAccount().
Getting a signer
resolveSigner is the one call to reach for: it returns a dev signer when a
--suri is supplied, otherwise the persisted QR session's product-account
signer.
import { createAuthClient, resolveSigner } from "@parity/product-sdk-auth";
import { submitAndWatch } from "@parity/product-sdk-tx";
const authClient = createAuthClient({
dappId: "playground",
productId: "playground.dot",
derivationIndex: 0,
peopleEndpoints: ["wss://<people-rpc>"],
});
const resolved = await resolveSigner(authClient, { suri: process.env.SURI });
try {
const result = await submitAndWatch(tx, resolved.signer);
} finally {
resolved.destroy();
}
First-time login (QR flow)
connect() + waitForLogin() only authenticate and persist the session —
they do not return a signer. After a successful login, call resolveSigner()
(or authClient.getSessionSigner()) to actually sign.
const conn = await authClient.connect();
if (conn.kind === "existing") {
console.log("Already signed in as", conn.address);
} else {
console.log(conn.qrCode);
const address = await authClient.waitForLogin(conn.login, (s) => console.log(s.step));
}
import { renderLoginStatus, renderLogoutStatus, renderQrCode } from "@parity/product-sdk-auth/ui";
Sign-out (logout)
Mirror image of the login flow: find the paired session, disconnect it (notifies
the mobile app), and clear the local ${dappId}_* files.
const handle = await authClient.findSession();
if (!handle) {
console.log("Not signed in.");
} else {
await authClient.waitForLogout(handle, (s) => console.log(renderLogoutStatus(s)));
}
Resource allocation (RFC-0010)
A fresh session needs allowances before it can sign (statement store / Bulletin /
smart-contract sponsoring). The mobile wallet prompts the user to approve. By
default requestAllocation requests DEFAULT_RESOURCES — BulletInAllowance,
StatementStoreAllowance, and SmartContractAllowance for the default (index 0)
product account.
import { DEFAULT_RESOURCES, summarizeOutcomes } from "@parity/product-sdk-auth";
const outcomes = await authClient.requestAllocation(resolved.userSession!);
const { granted, rejected, unavailable } = summarizeOutcomes(outcomes, DEFAULT_RESOURCES);
When to use which: SignerManager.getProductAccount() is for in-host / web
products (Host API). @parity/product-sdk-auth is for standalone CLIs that pair
with a phone over QR. Both end at a PolkadotSigner you pass to submitAndWatch.
KeyManager: Hierarchical Key Derivation
import { KeyManager } from "@parity/product-sdk-keys";
const km = KeyManager.fromSignature(signatureBytes, signerAddress);
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 "@parity/product-sdk-keys";
import { createLocalKvStore } from "@parity/product-sdk-local-storage";
const store = await createLocalKvStore({ prefix: "session-key" });
const skm = new SessionKeyManager({ store, name: "default" });
const info = await skm.getOrCreate();
deriveProductAccountPublicKey: Canonical sr25519 Product-Account Derivation
import { deriveProductAccountPublicKey } from "@parity/product-sdk-keys";
const derivedPubKey = deriveProductAccountPublicKey(
parentPublicKey,
"playground.dot",
0,
);
Mirrors the algorithm used by polkadot-desktop and polkadot-app-android-v2. sr25519 soft derivation is composable on the parent public key alone, so external clients (CLI, web hosts) can compute the same address without seeing the secret key. See references/keys-api.md for the cross-platform parity constraint on productId.
Common Mistakes
-
Using dev signers in production - createDevSigner uses the well-known dev mnemonic. Use SignerManager for real users.
-
Confusing signer types - submitAndWatch needs a PolkadotSigner, not a SignerAccount. Call account.getSigner().
-
Missing await on submitAndWatch - It returns a Promise.
-
Not handling TxDispatchError - A transaction can be included on-chain but still fail. This comes back as err(TxDispatchError), not a thrown error — always branch on result.ok and inspect result.error.
-
Forgetting account mapping - EVM contract interactions on Asset Hub require calling ensureAccountMapped first.
-
Not calling resolved.destroy() (auth) - session signers own a WebSocket that keeps the Node event loop alive; the CLI won't exit cleanly until it's torn down. Call it in a finally.
-
Signing on a fresh session without allocations (auth) - per the package's own note, RFC-0010 allowances are needed before a fresh session can sign (statement store / Bulletin / smart-contract). Run authClient.requestAllocation(session) once after first login and let the user approve it on the phone.
Reference Files
- tx-api.md - Full
@parity/product-sdk-tx API reference
- signer-api.md - Full
@parity/product-sdk-signer API reference
- keys-api.md - Full
@parity/product-sdk-keys API reference
- auth-api.md - Full
@parity/product-sdk-auth API reference (CLI sign-in, session signers, logout, RFC-0010 allocations)