diff --git a/packages/api/src/promise/createTransaction.spec.ts b/packages/api/src/promise/createTransaction.spec.ts
new file mode 100644
index 00000000000..a154a402a7f
--- /dev/null
+++ b/packages/api/src/promise/createTransaction.spec.ts
@@ -0,0 +1,185 @@
+// Copyright 2017-2025 @polkadot/api authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+///
+
+import type { KeyringPair } from '@polkadot/keyring/types';
+import type { Hash, SignerPayload } from '@polkadot/types/interfaces';
+import type { TxPayloadV1 } from '@polkadot/types/types';
+import type { HexString } from '@polkadot/util/types';
+import type { Signer } from '../types/index.js';
+
+import { createTestPairs } from '@polkadot/keyring';
+import { MockProvider } from '@polkadot/rpc-provider/mock';
+import { GenericExtrinsic, TypeRegistry } from '@polkadot/types';
+
+import { ApiPromise } from './index.js';
+
+// Helper to extract 'extra' from extensions
+const findExtra = (payloadV1: TxPayloadV1, id: string) => {
+ const ext = payloadV1.extensions.find((e) => e.id === id);
+
+ return ext && ext.extra !== '0x' ? ext.extra : null;
+};
+
+// Helper to extract 'additionalSigned' from extensions
+const findAdditional = (payloadV1: TxPayloadV1, id: string) => {
+ const ext = payloadV1.extensions.find((e) => e.id === id);
+
+ return ext && ext.additionalSigned !== '0x' ? ext.additionalSigned : null;
+};
+
+class MockModernSigner implements Signer {
+ private readonly keypair: KeyringPair;
+ private readonly api: ApiPromise;
+
+ constructor (keypair: KeyringPair, api: ApiPromise) {
+ this.keypair = keypair;
+ this.api = api;
+ }
+
+ public async createTransaction (payloadV1: TxPayloadV1): Promise {
+ const bestBlockHeight = payloadV1.context.bestBlockHeight;
+
+ const blockHash = (
+ await this.api.rpc.chain.getBlockHash(bestBlockHeight)
+ ).toHex();
+
+ const payloadToSign = this.api.registry.createType(
+ 'SignerPayload',
+ {
+ address: payloadV1.signer,
+ assetId: findExtra(payloadV1, 'ChargeAssetTxPayment'),
+ blockHash,
+ blockNumber: bestBlockHeight,
+ era: findExtra(payloadV1, 'CheckMortality'),
+ genesisHash: findAdditional(payloadV1, 'CheckGenesis'),
+ method: payloadV1.callData,
+ mode: null,
+ nonce: findExtra(payloadV1, 'CheckNonce'),
+ runtimeVersion: this.api.runtimeVersion,
+ signedExtensions: payloadV1.extensions.map((e) => e.id),
+ tip: findExtra(payloadV1, 'ChargeTransactionPayment'),
+ version: findAdditional(payloadV1, 'CheckTxVersion')
+ }
+ );
+
+ const { signature: signatureHex } = this.api.registry
+ .createType('ExtrinsicPayload', payloadToSign.toPayload(), {
+ version: payloadToSign.version
+ })
+ .sign(this.keypair);
+
+ const extrinsic = new GenericExtrinsic(
+ this.api.registry,
+ payloadToSign.method
+ );
+
+ if (!payloadV1.signer) {
+ throw new Error('Signer is required but not provided');
+ }
+
+ extrinsic.addSignature(
+ payloadV1.signer,
+ signatureHex,
+ payloadToSign.toPayload()
+ );
+
+ return extrinsic.toHex();
+ }
+}
+
+describe('createTransaction', () => {
+ const registry = new TypeRegistry();
+ let api: ApiPromise;
+ let provider: MockProvider;
+ const { alice } = createTestPairs({ type: 'ed25519' }, false);
+
+ beforeEach(async () => {
+ provider = new MockProvider(registry);
+ provider.subscriptions.state_subscribeStorage.lastValue = {
+ changes: [
+ [
+ '0x26aa394eea5630e07c48ae0c9558cef79c2f82b23e5fd031fb54c292794b4cc4d560eb8d00e57357cf76492334e43bb2ecaa9f28df6a8c4426d7b6090f7ad3c9',
+ '0x00'
+ ]
+ ]
+ };
+
+ // Set up the API
+ const rpcData = await provider.send('state_getMetadata', []);
+ const genesisHash = registry.createType('Hash', await provider.send('chain_getBlockHash', [])).toHex();
+ const specVersion = 0;
+
+ api = await ApiPromise.create({
+ metadata: { [`${genesisHash}-${specVersion}`]: rpcData },
+ provider,
+ registry,
+ throwOnConnect: true
+ });
+ });
+
+ afterEach(async () => {
+ await provider.disconnect();
+ });
+
+ it('should create a valid signed transaction', async (): Promise => {
+ const mockSigner = new MockModernSigner(alice, api);
+ let extrinsic = api.tx.balances.transferKeepAlive('16kVKdv56dtV13tPttUZonKgj7qqkJget5Xkf3yMqEgdwhZK', 1);
+ const blockHash = await api.rpc.chain.getBlockHash();
+
+ extrinsic = await extrinsic.signAsync(alice.address, { blockHash, signer: mockSigner });
+
+ expect(extrinsic.isSigned).toEqual(true);
+ expect(extrinsic.signer.toString()).toEqual(alice.address);
+ expect(extrinsic.method.toHex()).toEqual(extrinsic.method.toHex());
+ expect(extrinsic.nonce.toNumber()).toEqual(0);
+ expect(extrinsic.tip.toNumber()).toEqual(0);
+ expect(extrinsic.era.isMortalEra).toEqual(true);
+
+ const payloadToSign = api.registry.createType(
+ 'SignerPayload',
+ {
+ address: alice.address,
+ blockHash,
+ blockNumber: '0x000014fd',
+ era: '0xd607',
+ genesisHash: api.genesisHash,
+ method: extrinsic.method,
+ signedExtensions: api.registry.signedExtensions,
+ specVersion: '0x00000000',
+ transactionVersion: '0x00000000',
+ version: 4
+ }
+ );
+
+ const signatureHex = api.registry
+ .createType('ExtrinsicPayload', payloadToSign.toPayload(), {
+ version: payloadToSign.version
+ })
+ .sign(alice).signature.replace('00', '');
+
+ expect(extrinsic.signature.toString()).toEqual(signatureHex);
+
+ await api.disconnect();
+ });
+
+ it('should create a valid signed transaction, even with invalid blockhash', async (): Promise => {
+ const mockSigner = new MockModernSigner(alice, api);
+ let extrinsic = api.tx.balances.transferKeepAlive('16kVKdv56dtV13tPttUZonKgj7qqkJget5Xkf3yMqEgdwhZK', 1);
+ const blockHash = '0x0000jh930000000000hui3w9200000000000000000'; // some random blockhash
+
+ extrinsic = await extrinsic.signAsync(alice.address, { blockHash, signer: mockSigner });
+ const hash = extrinsic.send();
+
+ expect(extrinsic.isSigned).toEqual(true);
+ expect(extrinsic.signer.toString()).toEqual(alice.address);
+ expect(extrinsic.method.toHex()).toEqual(extrinsic.method.toHex());
+ expect(extrinsic.nonce.toNumber()).toEqual(0);
+ expect(extrinsic.tip.toNumber()).toEqual(0);
+ expect(extrinsic.era.isMortalEra).toEqual(true);
+ expect(hash).toBeDefined();
+
+ await api.disconnect();
+ });
+});
diff --git a/packages/api/src/submittable/createClass.ts b/packages/api/src/submittable/createClass.ts
index 30b6403357a..fc3af40b174 100644
--- a/packages/api/src/submittable/createClass.ts
+++ b/packages/api/src/submittable/createClass.ts
@@ -5,7 +5,7 @@
import type { Observable } from 'rxjs';
import type { Address, ApplyExtrinsicResult, Call, Extrinsic, ExtrinsicEra, ExtrinsicStatus, Hash, Header, Index, RuntimeDispatchInfo, SignerPayload } from '@polkadot/types/interfaces';
-import type { Callback, Codec, CodecClass, ISubmittableResult, SignatureOptions } from '@polkadot/types/types';
+import type { Callback, Codec, CodecClass, ISubmittableResult, SignatureOptions, TxPayloadV1 } from '@polkadot/types/types';
import type { Registry } from '@polkadot/types-codec/types';
import type { HexString } from '@polkadot/util/types';
import type { ApiBase } from '../base/index.js';
@@ -17,6 +17,7 @@ import { catchError, first, map, mergeMap, of, switchMap, tap } from 'rxjs';
import { identity, isBn, isFunction, isNumber, isString, isU8a, objectSpread } from '@polkadot/util';
import { filterEvents, isKeyringPair } from '../util/index.js';
+import { EXTENSION_MATCHER } from './matcher.js';
import { SubmittableResult } from './Result.js';
interface SubmittableOptions {
@@ -54,6 +55,8 @@ function makeEraOptions (api: ApiInterfaceRx, registry: Registry, partialOptions
}
return makeSignOptions(api, partialOptions, {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
blockHash: header.hash,
era: registry.createTypeUnsafe('ExtrinsicEra', [{
current: header.number,
@@ -260,6 +263,57 @@ export function createClass ({ api, apiType, blockHas
return this;
}
+ /**
+ * @private
+ * Transforms the flat, legacy SignerPayload into the structured TxPayloadV1 'extensions' array.
+ * This logic should match the SignedExtension configuration in the chain's runtime.
+ */
+ #mapPayloadToV1Extensions (payload: SignerPayload): TxPayloadV1['extensions'] {
+ const registry = this.registry;
+ const extensions: TxPayloadV1['extensions'] = [];
+
+ const activeExtensions = payload.signedExtensions.toArray();
+
+ for (const id of activeExtensions) {
+ const { additionalSigned = registry.createType('Null'), extra = registry.createType('Null') } =
+ EXTENSION_MATCHER[id.toString()]?.(payload, registry) ?? {};
+
+ extensions.push({
+ additionalSigned: additionalSigned.toHex(),
+ extra: extra.toHex(),
+ id: id.toString()
+ });
+ }
+
+ return extensions;
+ }
+
+ #createTxPayloadV1 = (address: Address | string | Uint8Array, options: SignatureOptions, header: Header | null): TxPayloadV1 => {
+ const payload = this.registry.createTypeUnsafe('SignerPayload', [objectSpread({}, options, {
+ address,
+ blockNumber: header ? header.number : 0,
+ method: this.method
+ })]);
+
+ const extensions: TxPayloadV1['extensions'] = this.#mapPayloadToV1Extensions(payload);
+
+ const txPayload: TxPayloadV1 = {
+ callData: payload.method.toHex(),
+ context: {
+ bestBlockHeight: this.registry.createType('u32', payload.blockNumber),
+ metadata: this.registry.metadata.toHex(),
+ tokenDecimals: this.registry.chainDecimals[0],
+ tokenSymbol: this.registry.chainTokens[0]
+ },
+ extensions,
+ signer: payload.address.toString(),
+ txExtVersion: api.extrinsicType === 4 ? 0 : api.runtimeVersion.transactionVersion.toNumber(),
+ version: 1
+ };
+
+ return txPayload;
+ };
+
#observeSign = (account: AddressOrPair, partialOptions?: Partial): Observable => {
const address = isKeyringPair(account) ? account.address : account.toString();
const options = optionsOrNonce(partialOptions);
@@ -352,6 +406,14 @@ export function createClass ({ api, apiType, blockHas
blockNumber: header ? header.number : 0,
method: this.method
})]);
+
+ if (isFunction(signer.createTransaction)) {
+ const txPayload = this.#createTxPayloadV1(address, options, header);
+ const signedTransaction = await signer.createTransaction(txPayload);
+
+ return { id: Date.now(), signedTransaction };
+ }
+
let result: SignerResult;
if (isFunction(signer.signPayload)) {
diff --git a/packages/api/src/submittable/matcher.ts b/packages/api/src/submittable/matcher.ts
new file mode 100644
index 00000000000..22d2e774d29
--- /dev/null
+++ b/packages/api/src/submittable/matcher.ts
@@ -0,0 +1,46 @@
+// Copyright 2017-2025 @polkadot/api authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { SignerPayload } from '@polkadot/types/interfaces';
+import type { Codec, Registry } from '@polkadot/types-codec/types';
+
+type ExtensionHandler = (payload: SignerPayload, registry: Registry) => { extra?: Codec; additionalSigned?: Codec };
+
+export const EXTENSION_MATCHER: Record = {
+ ChargeAssetTxPayment: (payload, registry) => ({
+ extra: payload.assetId
+ ? registry.createType('Option', payload.assetId)
+ : registry.createType('Null')
+ }),
+ ChargeTransactionPayment: (payload) => ({
+ extra: payload.tip
+ }),
+ CheckGenesis: (payload, registry) => ({
+ additionalSigned: registry.createType('Hash', payload.genesisHash)
+ }),
+ CheckMetadataHash: (_, registry) => ({
+ additionalSigned: registry.metadata.hash
+ }),
+ CheckMortality: (payload, registry) => {
+ const genesisHash = registry.createType('Hash', payload.genesisHash);
+
+ return {
+ additional: payload.era.isMortalEra
+ ? payload.blockHash || genesisHash
+ : genesisHash,
+ extra: payload.era
+ };
+ },
+ CheckNonZeroSender: (payload, registry) => ({
+ extra: registry.createType('Address', payload.address)
+ }),
+ CheckNonce: (payload, registry) => ({
+ extra: registry.createType('Compact', payload.nonce)
+ }),
+ CheckSpecVersion: (payload, registry) => ({
+ additionalSigned: registry.createType('u32', payload.runtimeVersion.specVersion)
+ }),
+ CheckTxVersion: (payload, registry) => ({
+ additionalSigned: registry.createType('u32', payload.version)
+ })
+};
diff --git a/packages/types/src/types/extrinsic.ts b/packages/types/src/types/extrinsic.ts
index 2095c7e10c1..a4678b0afb2 100644
--- a/packages/types/src/types/extrinsic.ts
+++ b/packages/types/src/types/extrinsic.ts
@@ -7,6 +7,7 @@ import type { ExtrinsicStatus } from '../interfaces/author/index.js';
import type { EcdsaSignature, Ed25519Signature, Sr25519Signature } from '../interfaces/extrinsics/index.js';
import type { Address, Call, H256, Hash } from '../interfaces/runtime/index.js';
import type { DispatchError, DispatchInfo, EventRecord } from '../interfaces/system/index.js';
+import type { BlockNumber } from '../interfaces/types.js';
import type { ICompact, IKeyringPair, IMethod, INumber, IRuntimeVersionBase } from './interfaces.js';
import type { Registry } from './registry.js';
@@ -29,6 +30,81 @@ export interface ISubmittableResult {
toHuman (isExtended?: boolean): AnyJson;
}
+export interface TxPayloadV1 {
+ /**
+ * @description Payload version. MUST be 1.
+ */
+ version: 1;
+
+ /**
+ * @description Signer selection hint. Allows the implementer to identify which private-key. (e.g., SS58 address, account-name).
+ * The DApp MUST provide the SS58 address of the intended signer.
+ * This is used by the implementer to identify the correct key/account to use.
+ */
+ signer: string | null;
+
+ /**
+ * @description SCALE-encoded Call (module indicator + function indicator + params)
+ */
+ callData: HexString;
+
+ /**
+ * @description Transaction extensions supplied by the caller (order irrelevant).
+ * The consumer (DApp/API) is responsible for calculating and providing all components.
+ * The implementer (Signer) MAY attempt to infer missing ones if necessary.
+ */
+ extensions: {
+ /** Identifier as defined in metadata (e.g., "CheckSpecVersion", "ChargeAssetTxPayment"). */
+ id: string;
+
+ /**
+ * Explicit "extra" to sign (goes into the extrinsic body).
+ * SCALE-encoded per the extension's "extra" type as defined in the metadata.
+ */
+ extra: HexString;
+
+ /**
+ * "Implicit" data to sign (known by the chain, not included into the extrinsic body).
+ * SCALE-encoded per the extension's "additionalSigned" type as defined in the metadata.
+ */
+ additionalSigned: HexString;
+ }[];
+
+ /**
+ * @description Transaction Extension Version
+ * - MUST be 0 for Extrinsic V4 transactions.
+ * - Set to a runtime-supported version (> 0) for Extrinsic V5 transactions.
+ *
+ * The implementer:
+ * - MUST use this field to determine the required extensions for creating the extrinsic.
+ * - MAY use this field to infer missing extensions that the implementer could know how to handle.
+ */
+ txExtVersion: number;
+
+ /**
+ * @description Context needed for decoding, display, construction and (optionally) inferring certain extensions.
+ */
+ context: {
+ /**
+ * RuntimeMetadataPrefixed blob (SCALE), starting with ASCII "meta" magic (`0x6d657461`).
+ * Must be V14+. For V5+ versioned extensions, MUST provide V16+.
+ */
+ metadata: HexString;
+
+ /**
+ * Native token display info (used by some implementers), also needed to compute
+ * the `CheckMetadataHash` value.
+ */
+ tokenSymbol: string;
+ tokenDecimals: number;
+
+ /**
+ * Highest known block number to aid mortality UX.
+ */
+ bestBlockHeight: BlockNumber;
+ };
+}
+
export interface SignerPayloadJSON {
/**
* @description The ss-58 encoded address
@@ -166,6 +242,11 @@ export interface SignerResult {
}
export interface Signer {
+ /**
+ * @description The new createTransaction function; Returns ready-to-broadcast extrinsic after internal computation by signer
+ */
+ createTransaction?: (payload: TxPayloadV1) => Promise;
+
/**
* @description signs an extrinsic payload from a serialized form
*/