paritytech / paritytech/polkadot-cli
Capability-gated Extrinsic V5 General signing (v5 when the chain can authorize it, v4 otherwise)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 10
- Forks
- 2
- Avg merge
- 12h 35m
- Merged PRs (30d)
- 4
Description
Summary
Add Extrinsic V5 "General" signing, chosen only when the connected chain can actually authorize
it. Policy: v5 when the chain advertises version 5 and exposes a VerifyMultiSignature
transaction extension; v4 otherwise.
Step 3 of the Extrinsic V5 work. Depends on the metadata (step 1) and extension-version (step 2)
work. A working prototype exists and has landed transactions on chain — it is reproduced in full
below.
Why the capability gate is the whole point
A v5 General transaction has no signature field. Authorization moves into a transaction
extension — in practice pallet-verify-signature, metadata identifier VerifyMultiSignature. If a
runtime doesn't carry that extension, there is nowhere in a v5 transaction to put a signature.
Every chain advertises extrinsic.version = [4,5]. Almost none can carry a signature. Probed
live:
| Chain | VerifyMultiSignature? |
Must use |
|---|---|---|
| paseo-people, preview-people, nextv2-people | yes | v5 General |
| polkadot, polkadot-asset-hub, polkadot-people, polkadot-bulletin | no | v4 Signed |
| polkadot bridge-hub / coretime / collectives | no | v4 Signed |
| kusama, kusama-asset-hub | no | v4 Signed |
| westend, westend-asset-hub | no | v4 Signed |
| paseo, paseo-asset-hub, paseo-asset-hub-next | no | v4 Signed |
| preview-relay, preview-asset-hub, preview-bulletin | no | v4 Signed |
So on Polkadot and every asset hub, v4 is not a legacy fallback — it is the only way to sign
anything, until those runtimes adopt pallet-verify-signature.
Verified failure mode when the gate is missing — v5 General submitted to Polkadot AssetHub:
state_call TaggedTransactionQueue_validate_transaction -> 0x01000c
^^ Err(Invalid(UnknownOrigin))
Runtime's own wording: "The transaction extension did not authorize any origin."
Do not copy subxt here. subxt does not gate v5 signing on this predicate — create_v5_signable
will happily build and submit a signature-less General tx that lands as UnknownOrigin. Also note
subxt's own default is v4 whenever v4 is advertised
(default_transaction_version, subxt-0.50.3 .../transactions.rs:274-287), so this policy is
more aggressive than the reference client and must be gated accordingly.
The predicate
Build a signed v5 General iff all of:
5 ∈ metadata.extrinsic.version(needs metadata v16 — step 1).- The extension list for the extension version we will encode contains an extension whose
metadata identifier is exactlyVerifyMultiSignature. - We can encode every other extension in that list (each is either known to us, or its explicit
type is empty/Optionand its implicit type is empty). This mirrors frame-decode's
best_v5_general_transaction_extension_version.
On naming. The metadata identifier is VerifyMultiSignature
(polkadot-sdk substrate/frame/verify-signature/src/extension.rs:98 — const IDENTIFIER), while
the Rust type is pallet_verify_signature::VerifySignature<T>. subxt matches on the string
"VerifyMultiSignature" (subxt/src/config/transaction_extensions.rs:58). Match both identifiers
defensively.
There is no machine-readable "is authorization" marker in metadata. frame-decode's
is_authorization_extension is a client-side flag defaulting to false. So name-matching is both
necessary and the only available test.
Other authorization extensions exist but none carries an account signature. The people-chain
pipeline also has AsPerson, AsProofOfInkParticipant, ScoreAsParticipant, GameAsInvited,
PeopleLiteAuth, AsMember, AsCoinage, AsResources, HonourAuth, AuthorizeCall. They
authorize by other means (personhood proofs, call-level authorization) and accept no
(MultiSignature, AccountId32). Their presence neither satisfies nor blocks the predicate.
Exactly one authorization extension may be enabled. VerifySignature::validate returns
BadSigner if the origin is already authorized; AuthorizeCall skips itself if already authorized.
Flag and fill exactly one.
Recommended hardening: after matching the name, verify the type really is an enum with a Signed
variant carrying signature + account, and a Disabled variant.
Construction
Signing payload
payload = extension_version_byte (u8)
++ call_data (pallet u8 ++ call u8 ++ SCALE args)
++ Σ explicit of extensions STRICTLY AFTER the authorization extension, metadata order
++ Σ implicit of those same extensions, metadata order
signed = blake2_256(payload) // ALWAYS hashed — unlike v4's >256-byte rule
Three things that are easy to get wrong, all presenting as BadProof:
- the extension-version byte is part of the payload (the base implication is always signed —
frame-decode once had a bug clearing it, and truapi test-pins the regression); - the cut is at the last authorization extension (
rposition), and everything at or before it
contributes nothing; - disabled extensions after the cut still contribute their bytes.
All explicits come first, then all implicits — not interleaved per-extension.
Cross-checked against three independent sources that agree: frame-decode
encode_v5_signer_payload_with_info (extrinsic_encoder.rs:1023-1073), polkadot-sdk's runtime side
(dispatch_transaction.rs:114 builds TxBaseImplication((extension_version, call));
transaction_extension/mod.rs:577-593 hands extension i the implication of i+1..;
verify-signature/src/extension.rs:149-153 computes
msg = inherited_implication.using_encoded(blake2_256)), and truapi's byte-level oracle test.
Wire layout
compact(len)
0x45 # 0b0100_0000 (General) | 5
extension_version: u8 # currently 0x00
<extension values, metadata order> # incl. the VerifyMultiSignature value
<call data>
VerifyMultiSignature value when enabled: 0x01 (Signed) ++ MultiSignature ++ AccountId32(32);
disabled: 0x00.
Variant indices: Disabled = 0, Signed = 1. Deliberate — the sdk doc comment says Disabled
encoding as 0x00 keeps it "compatible with current signers". Do not trust subxt's Rust
declaration order (it declares Signed first); encode by name, from metadata.
Signed is a struct variant { signature, account } — signature first, account second
(extension.rs:55-60). Confirmed empirically: passing a tuple fails, the struct shape works.
Example sr25519 value: 01 01 <64-byte sig> <32-byte account>
(outer 01 = Signed, inner 01 = MultiSignature::Sr25519).
Two-pass encoding
The signature is not appended — it becomes the value of an extension sitting mid-list. So the
encoder runs twice: once with Disabled to compute the payload, then again with
Signed { signature, account } injected.
Mortality / nonce / tip
Unchanged from v4 — same extensions, same values, same encodings. CheckMortality still takes its
genesis / birth-block-hash implicit. What changes is only assembly: the extension-version byte is
new, the payload is always hashed, and extensions at/before the cut contribute nothing to it.
Non-sr25519
Payload is identical (the 32-byte hash). Differences live inside MultiSignature:
| Variant | Sig bytes | Runtime verification |
|---|---|---|
Ed25519 |
64 | verifies over the hash directly; account = pubkey |
Sr25519 |
64 | schnorrkel under signing context b"substrate"; account = pubkey |
Ecdsa |
65 | recovers pubkey over blake2_256(msg) — a double blake2 — and checks blake2_256(pubkey) == account |
Match variant indices by name from metadata, not hardcoded — polkadot-sdk master already adds a
fourth variant Eth. The CLI is sr25519-only today (src/core/accounts.ts:336); ed25519/ecdsa is
follow-up work, see #284 for the secp256k1 side.
Implementation approach
Stay on polkadot-api 2.x and write our own signer. papi ships no v5 transaction builder in
2.2.2 (what we're on), 3.0.0-rc.5, or the newest canary — every signer hardcodes createV4Tx,
and the pjs signer throws "Only extrinsic v4 is supported". papi's v5 tracking issue
(polkadot-api/polkadot-api#760) is open and unstarted; the maintainer is waiting on RFC-124 to merge.
papi 3.0 doesn't rescue us: its TxCreator has a txExtVersion field but every shipped creator
throws on anything but 0. It broke in four of five RCs and has been stalled since 2026-07-29.
Pinning a published CLI to it buys churn and no v5.
The seam we want is stable and documented: PolkadotSigner.signTx(callData, signedExtensions, metadata, atBlockNumber, hasher) => Promise<Uint8Array> returns the complete extrinsic bytes,
which papi broadcasts untouched (create-tx.js:68-73 -> tx.js:94-99, no downstream validation of
the version byte). papi's own maintainer recommends exactly this approach in #760.
Swap in at one line: src/core/accounts.ts:336, currently
getPolkadotSigner(keypair.publicKey, "Sr25519", keypair.sign).
Design constraint: keep the v5 byte assembly a pure function free of papi types —
inputs are raw metadata bytes, callData, the ordered {extra, additionalSigned} pairs, and a sign
callback. Then the eventual papi 3.0 TxCreator wrapper is ~30 lines and the rest of any 3.0
migration is mechanical renames.
Gotcha: tx.getEstimatedFees builds its own fake v4 signer internally and ignores ours
(polkadot-api/dist/src/tx/tx.js:105-110). Fee estimation on a v5 path needs a manual
TransactionPaymentApi_query_info runtime call.
Rollout: ship opt-in behind --v5 first, then flip the default per-chain once proven. See the
risk section — this is exactly the ordering polkadot-js got wrong.
Proven working
Implemented against the truapi design and submitted for real.
| Test | Chain | Result |
|---|---|---|
v4 signed on a chain that has VerifyMultiSignature |
preview-people | Finalized, block #171737, ExtrinsicSuccess |
| v5 General, signed | preview-people | Validated, submitted, included — nonce 54 -> 55, tx 0xa76ab997aab6c0683a1debb59ee7e765b595656e12e2c5d380e402dba6f4b369 |
| v5 General, signed | nextv2-people | Payment (0x010001), not BadProof — signature verified, account merely unfunded |
| v5 General, no signature extension | polkadot-asset-hub | UnknownOrigin (0x01000c) |
That second row is the load-bearing one: a real v5 General transaction carrying an sr25519 signature
in VerifyMultiSignature::Signed, accepted and included by a live runtime.
Working prototype (TypeScript, standalone) — click to expand
Run with bun. Set POC_RPC to target a chain, POC_SUBMIT=1 to actually submit (otherwise it
only calls validate_transaction). Uses only deps the CLI already has.
import { getWsProvider } from "polkadot-api/ws";
import { createClient } from "@polkadot-api/substrate-client";
import { decAnyMetadata, unifyMetadata, compact, Binary, AccountId } from "@polkadot-api/substrate-bindings";
import { getLookupFn, getDynamicBuilder } from "@polkadot-api/metadata-builders";
import { blake2b } from "@noble/hashes/blake2.js";
import { mnemonicToEntropy, entropyToMiniSecret, DEV_PHRASE } from "@polkadot-labs/hdkd-helpers";
import { sr25519CreateDerive } from "@polkadot-labs/hdkd";
const RPC = process.env.POC_RPC ?? "wss://previewnet.substrate.dev/people";
const SUBMIT = process.env.POC_SUBMIT === "1";
const req = (client: any, method: string, params: any[]) =>
new Promise<any>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`req timeout: ${method}`)), 25000);
client._request(method, params, {
onSuccess: (r: any) => { clearTimeout(t); resolve(r); },
onError: (e: any) => { clearTimeout(t); reject(e); },
});
});
const u32le = (n: number) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; };
const hexToBytes = (h: string) => Uint8Array.from(Buffer.from(h.replace(/^0x/, ""), "hex"));
const toHex = (b: Uint8Array) => "0x" + Buffer.from(b).toString("hex");
const cat = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out;
};
const alice = sr25519CreateDerive(entropyToMiniSecret(mnemonicToEntropy(DEV_PHRASE)))("//Alice");
const client = createClient(getWsProvider(RPC));
const genesisHash: string = await req(client, "chain_getBlockHash", [0]);
const rv: any = await req(client, "state_getRuntimeVersion", []);
const specVersion = rv.specVersion as number;
const transactionVersion = rv.transactionVersion as number;
// --- metadata v16 ---
const metaHex: string = await req(client, "state_call", ["Metadata_metadata_at_version", toHex(u32le(16))]);
const mb = hexToBytes(metaHex);
let off = 1; // strip Option::Some
const first = mb[off]!; const mode = first & 0b11;
off += mode === 0 ? 1 : mode === 1 ? 2 : mode === 2 ? 4 : 1 + (first >> 2) + 1;
const unified: any = unifyMetadata(decAnyMetadata(mb.subarray(off)) as any);
const lookup = getLookupFn(unified);
const builder = getDynamicBuilder(lookup);
// --- extension version: highest key (subxt semantics) ---
const byVersion: Record<number, any[]> = unified.extrinsic.signedExtensions;
const extVersion = Math.max(...Object.keys(byVersion).map(Number));
const extensions = byVersion[extVersion]!;
const nonceHex: string = await req(client, "state_call", ["AccountNonceApi_account_nonce", toHex(alice.publicKey)]);
const nonce = new DataView(hexToBytes(nonceHex).buffer).getUint32(0, true);
// --- type-derived neutral default for extensions we don't know ---
function defaultForType(typeId: number): any {
const e: any = lookup(typeId);
switch (e.type) {
case "void": case "option": return undefined;
case "compact": return e.isBig ? 0n : 0;
case "primitive":
if (e.value === "bool") return false;
if (e.value === "str") return "";
return ["u64","u128","u256","i64","i128","i256"].includes(e.value) ? 0n : 0;
case "enum": {
const [name, v]: any = Object.entries(e.value)[0]!;
return { type: name, value: v?.type === "void" ? undefined : defaultForType(v.value) };
}
case "array": return new Uint8Array(e.len);
case "sequence": return [];
case "struct": {
const o: any = {};
for (const [k, v] of Object.entries<any>(e.value)) o[k] = defaultForType(v.id ?? v);
return o;
}
default: return undefined;
}
}
const enc = (typeId: number, value: any) => builder.buildDefinition(typeId).enc(value);
function known(id: string): { extra: any; implicit: any } | null {
switch (id) {
case "CheckNonce": return { extra: nonce, implicit: undefined };
case "CheckSpecVersion": return { extra: undefined, implicit: specVersion };
case "CheckTxVersion": return { extra: undefined, implicit: transactionVersion };
case "CheckGenesis": return { extra: undefined, implicit: genesisHash };
case "CheckMortality": return { extra: { type: "Immortal", value: undefined }, implicit: genesisHash };
case "ChargeTransactionPayment": return { extra: 0n, implicit: undefined };
case "ChargeAssetTxPayment": return { extra: { tip: 0n, asset_id: undefined }, implicit: undefined };
case "RestrictOrigins": return { extra: false, implicit: undefined };
default: return null;
}
}
const AUTH_EXT = "VerifyMultiSignature";
type Built = { id: string; extra: Uint8Array; implicit: Uint8Array };
function buildAll(sigValue: any): Built[] {
return extensions.map((e: any) => {
if (e.identifier === AUTH_EXT) {
return { id: e.identifier, extra: enc(e.type, sigValue), implicit: enc(e.additionalSigned, defaultForType(e.additionalSigned)) };
}
const k = known(e.identifier);
const ev = k ? k.extra : defaultForType(e.type);
const iv = k ? k.implicit : defaultForType(e.additionalSigned);
const norm = (t: number, v: any) =>
v === undefined && lookup(t).type !== "void" && lookup(t).type !== "option" ? defaultForType(t) : v;
return { id: e.identifier, extra: enc(e.type, norm(e.type, ev)), implicit: enc(e.additionalSigned, norm(e.additionalSigned, iv)) };
});
}
const callCodec = builder.buildCall("System", "remark");
const callData = cat(new Uint8Array(callCodec.location), callCodec.codec.enc({ remark: Binary.fromText("v5-general-poc") }));
// --- pass 1: Disabled, compute the signer payload ---
const disabled = buildAll({ type: "Disabled", value: undefined });
const authIdx = disabled.findIndex((x) => x.id === AUTH_EXT);
if (authIdx < 0) throw new Error(`${AUTH_EXT} not present — chain cannot carry a v5 signature`);
const suffix = disabled.slice(authIdx + 1); // STRICTLY after the cut
const implication = cat(
new Uint8Array([extVersion]),
callData,
...suffix.map((s) => s.extra),
...suffix.map((s) => s.implicit),
);
const signerPayload = blake2b(implication, { dkLen: 32 }); // V5 ALWAYS hashes
// --- sign, then pass 2 with the signature injected ---
const signature = alice.sign(signerPayload);
const built = buildAll({
type: "Signed",
value: { // struct, NOT tuple
signature: { type: "Sr25519", value: toHex(signature) },
account: AccountId().dec(alice.publicKey),
},
});
const body = cat(new Uint8Array([0x45]), new Uint8Array([extVersion]), ...built.map((b) => b.extra), callData);
const wire = cat(compact.enc(body.length), body);
const validation = await req(client, "state_call", [
"TaggedTransactionQueue_validate_transaction",
toHex(cat(new Uint8Array([0x02]), wire, hexToBytes(genesisHash))), // source = External
]);
console.log("validate:", validation, hexToBytes(validation)[0] === 0 ? "VALID" : "INVALID");
if (SUBMIT && hexToBytes(validation)[0] === 0) {
console.log("tx hash:", await req(client, "author_submitExtrinsic", [toHex(wire)]));
}
client.destroy();
process.exit(0);
Risk: this exact change already broke production
In June 2025, chains adopting metadata v16 caused polkadot-js to flip to Extrinsic V5. Signing broke
on Westend Asset Hub and Rococo. The default was reverted to v4 within a day:
- polkadot-js/apps#11602 — "ExtrinsicV5 does not include signing support"
- polkadot-js/api#6164 (the revert) — "we've decided to temporarily disable
ExtrinsicV5and fall
back toExtrinsicV4as the default until full support is in place."
Our difference must be the capability gate plus an opt-in period. Do not flip defaults in the same
change that introduces the code path.
Failure modes to write error messages for
| Mistake | Symptom | Message |
|---|---|---|
| v5 on a chain with no signature extension | UnknownOrigin, RPC 1010 |
"This chain can't accept signed v5 transactions — using v4." |
| Wrong cut / missing version byte / forgot implicits / v4-style conditional hashing | BadProof, RPC 1010 |
Name the three classic construction bugs |
| Two authorization extensions enabled | BadSigner |
"More than one extension tried to authorize this call." |
| Version the runtime can't decode | RPC 1002, WASM trap | Critically, this looks like a node bug. Say plainly it's a version mismatch. |
| Wrong extension-version byte | 1002-style decode failure | "Extension version N is not in this runtime's metadata." |
The 1002 case matters: nodes decode submissions into OpaqueExtrinsic (a length-prefixed blob), so
the runtime's "Invalid transaction version" string never reaches the client — the version check
happens inside the runtime, where Decode failure panics. Budget debugging time accordingly.
Acceptance
- A pure
buildV5Extrinsic()free of papi types, unit-tested against a known-good fixture. - Capability predicate implemented and unit-tested, including the negative case.
- Custom
PolkadotSignerselects v5/v4 per chain;--v5forces v5 and errors clearly if the
chain can't take it. - Signed v5 transaction accepted on a people chain; v4 still works everywhere else
(regression-test both). - Fee estimation works on the v5 path (manual
query_info). - Default remains v4 in this change. Flipping per-chain defaults is a separate PR.
-
bun testgreen.
Related
- #253 — exposing the signing payload /
inherited_implicationfor external proofs. This work
directly unblocks it: the implication formula above is exactly what that issue needs, and the
prototype computes it. Worth coordinating so we build the payload seam once. - #252, #240 — transaction-extension discoverability and display.
- #284 — Ethereum/secp256k1 accounts; relevant to the
Ecdsa/EthMultiSignature variants.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at src/core/accounts.ts:336 and review the PolkadotSigner seam and the standalone TypeScript prototype described in the issue. Trace how create-tx.js and tx.js broadcast signer output, then verify the capability gate, v5 General encoding, and v4 fallback against the listed live-chain cases. Done means gated v5 signing works where VerifyMultiSignature is available and v4 remains usable elsewhere.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, typescript
- Domain
- blockchain, cli
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100