Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions packages/api/src/promise/createTransaction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright 2017-2025 @polkadot/api authors & contributors
// SPDX-License-Identifier: Apache-2.0

/// <reference types="@polkadot/dev-test/globals.d.ts" />

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<HexString> {
const bestBlockHeight = payloadV1.context.bestBlockHeight;

const blockHash = (
await this.api.rpc.chain.getBlockHash<Hash>(bestBlockHeight)
).toHex();

const payloadToSign = this.api.registry.createType<SignerPayload>(
'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<HexString>('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<void> => {
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>(
'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<void> => {
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();
});
});
64 changes: 63 additions & 1 deletion packages/api/src/submittable/createClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ApiType extends ApiTypes> {
Expand Down Expand Up @@ -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>('ExtrinsicEra', [{
current: header.number,
Expand Down Expand Up @@ -260,6 +263,57 @@ export function createClass <ApiType extends ApiTypes> ({ 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>('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<SignerOptions>): Observable<UpdateInfo> => {
const address = isKeyringPair(account) ? account.address : account.toString();
const options = optionsOrNonce(partialOptions);
Expand Down Expand Up @@ -352,6 +406,14 @@ export function createClass <ApiType extends ApiTypes> ({ 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)) {
Expand Down
46 changes: 46 additions & 0 deletions packages/api/src/submittable/matcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, ExtensionHandler | undefined> = {
ChargeAssetTxPayment: (payload, registry) => ({
extra: payload.assetId
? registry.createType('Option<TAssetConversion>', 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<Index>', payload.nonce)
}),
CheckSpecVersion: (payload, registry) => ({
additionalSigned: registry.createType('u32', payload.runtimeVersion.specVersion)
}),
CheckTxVersion: (payload, registry) => ({
additionalSigned: registry.createType('u32', payload.version)
})
};
Loading