paritytech / paritytech/polkadot-cli

Capability-gated Extrinsic V5 General signing (v5 when the chain can authorize it, v4 otherwise)

Open
#294 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
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:

  1. 5 ∈ metadata.extrinsic.version (needs metadata v16 — step 1).
  2. The extension list for the extension version we will encode contains an extension whose
    metadata identifier is exactly VerifyMultiSignature.
  3. We can encode every other extension in that list (each is either known to us, or its explicit
    type is empty/Option and 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:98const 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 ExtrinsicV5 and fall
    back to ExtrinsicV4 as 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 PolkadotSigner selects v5/v4 per chain; --v5 forces 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 test green.

Related

  • #253 — exposing the signing payload / inherited_implication for 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/Eth MultiSignature variants.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.