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 */