diff --git a/e2e/nodes/src/tests/Mortality.test.ts b/e2e/nodes/src/tests/Mortality.test.ts new file mode 100644 index 000000000..580b09274 --- /dev/null +++ b/e2e/nodes/src/tests/Mortality.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { devPairs } from '../utils.js'; + +describe('Mortality', () => { + const { alice, bob } = devPairs(); + const TEN_UNIT = BigInt(10 * 1e12); + + describe('LegacyClient (Contracts)', () => { + it('should send immortal transaction', async () => { + const prevBobBalance = (await contractsClient.query.system.account(bob.address)).data.free; + + const result = await contractsClient.tx.balances + .transferKeepAlive(bob.address, TEN_UNIT) + .signAndSend(alice, { mortality: { type: 'Immortal' } }) + .untilFinalized(); + + expect(result.status.type).toBe('Finalized'); + expect(result.txHash).toBeDefined(); + + const newBobBalance = (await contractsClient.query.system.account(bob.address)).data.free; + expect(newBobBalance).toBe(prevBobBalance + TEN_UNIT); + }); + + it('should send transaction with custom mortal period', async () => { + const prevBobBalance = (await contractsClient.query.system.account(bob.address)).data.free; + + const result = await contractsClient.tx.balances + .transferKeepAlive(bob.address, TEN_UNIT) + .signAndSend(alice, { mortality: { type: 'Mortal', period: 128 } }) + .untilFinalized(); + + expect(result.status.type).toBe('Finalized'); + expect(result.txHash).toBeDefined(); + + const newBobBalance = (await contractsClient.query.system.account(bob.address)).data.free; + expect(newBobBalance).toBe(prevBobBalance + TEN_UNIT); + }); + }); + + describe('V2Client (Revive)', () => { + it('should send immortal transaction', async () => { + const prevBobBalance = (await reviveClient.query.system.account(bob.address)).data.free; + + const result = await reviveClient.tx.balances + .transferKeepAlive(bob.address, TEN_UNIT) + .signAndSend(alice, { mortality: { type: 'Immortal' } }) + .untilBestChainBlockIncluded(); + + expect(result.status.type).toBe('BestChainBlockIncluded'); + expect(result.txHash).toBeDefined(); + + const newBobBalance = (await reviveClient.query.system.account(bob.address)).data.free; + expect(newBobBalance).toBe(prevBobBalance + TEN_UNIT); + }); + + it('should send transaction with custom mortal period', async () => { + const prevBobBalance = (await reviveClient.query.system.account(bob.address)).data.free; + + const result = await reviveClient.tx.balances + .transferKeepAlive(bob.address, TEN_UNIT) + .signAndSend(alice, { mortality: { type: 'Mortal', period: 128 } }) + .untilBestChainBlockIncluded(); + + expect(result.status.type).toBe('BestChainBlockIncluded'); + expect(result.txHash).toBeDefined(); + + const newBobBalance = (await reviveClient.query.system.account(bob.address)).data.free; + expect(newBobBalance).toBe(prevBobBalance + TEN_UNIT); + }); + }); +}); diff --git a/packages/api/src/extrinsic/extensions/__tests__/CheckMortality.spec.ts b/packages/api/src/extrinsic/extensions/__tests__/CheckMortality.spec.ts new file mode 100644 index 000000000..2ea8fdc5e --- /dev/null +++ b/packages/api/src/extrinsic/extensions/__tests__/CheckMortality.spec.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + CheckMortality, + MAX_FINALITY_LAG, + FALLBACK_MAX_HASH_COUNT, + MORTAL_PERIOD, + FALLBACK_PERIOD, +} from '../known/CheckMortality.js'; + +const GENESIS_HASH = '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3'; +const BLOCK_HASH = '0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1'; +const FINALIZED_BLOCK_NUMBER = 100; + +function createMockClient(overrides: Record = {}) { + return { + genesisHash: GENESIS_HASH, + rpcVersion: 'v2', + block: { + finalized: async () => ({ + hash: BLOCK_HASH, + number: FINALIZED_BLOCK_NUMBER, + }), + }, + registry: { + findCodec: () => ({ + metadata: [{ name: '$.Era' }], + tryEncode: (value: any) => { + // Simple mock: immortal = [0x00], mortal = [0x01, 0x02] + if (value?.type === 'Immortal') return new Uint8Array([0x00]); + return new Uint8Array([0x01, 0x02]); + }, + tryDecode: (data: any) => { + if (data instanceof Uint8Array && data[0] === 0x00) return { type: 'Immortal' }; + return { type: 'Mortal', value: { period: 64n, phase: 36n } }; + }, + }), + }, + consts: { + system: { blockHashCount: 4096 }, + babe: { expectedBlockTime: 6000n }, + }, + ...overrides, + } as any; +} + +function createExtensionDef() { + return { + ident: 'CheckMortality', + typeId: 1, + additionalSigned: 2, + }; +} + +function createCheckMortality(client: any, payloadOptions: Record = {}) { + return new CheckMortality(client, { + def: createExtensionDef(), + signerAddress: '0x1234', + payloadOptions, + }); +} + +describe('CheckMortality', () => { + let mockClient: any; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + describe('init() with immortal mortality', () => { + it('should set immortal era data and genesis hash as additionalSigned', async () => { + const ext = createCheckMortality(mockClient, { + mortality: { type: 'Immortal' }, + }); + + await ext.init(); + + expect(ext.data).toEqual({ type: 'Immortal' }); + expect(ext.additionalSigned).toBe(GENESIS_HASH); + }); + + it('should return blockNumber 0x00 in toPayload() for immortal', async () => { + const ext = createCheckMortality(mockClient, { + mortality: { type: 'Immortal' }, + }); + + await ext.init(); + + const payload = ext.toPayload(); + expect(payload.blockNumber).toBe('0x00'); + expect(payload.blockHash).toBe(GENESIS_HASH); + }); + }); + + describe('init() with custom mortal period', () => { + it('should set mortal era data with custom period', async () => { + const ext = createCheckMortality(mockClient, { + mortality: { type: 'Mortal', period: 128 }, + }); + + await ext.init(); + + expect(ext.data).toEqual({ period: 128n, current: BigInt(FINALIZED_BLOCK_NUMBER) }); + expect(ext.additionalSigned).toBe(BLOCK_HASH); + }); + + it('should return signing header info in toPayload()', async () => { + const ext = createCheckMortality(mockClient, { + mortality: { type: 'Mortal', period: 128 }, + }); + + await ext.init(); + + const payload = ext.toPayload(); + expect(payload.blockHash).toBe(BLOCK_HASH); + expect(payload.blockNumber).toBe('0x64'); // 100 in hex + }); + }); + + describe('init() with default behavior (no mortality option)', () => { + it('should auto-compute mortal era using calculateMortalLength', async () => { + const ext = createCheckMortality(mockClient); + + await ext.init(); + + // Default calculation: min(blockHashCount, MORTAL_PERIOD / expectedBlockTime + MAX_FINALITY_LAG) + // = min(4096, 720000 / 6000 + 5) = min(4096, 125) = 125 + const expectedPeriod = BigInt(Math.min(4096, Math.floor(MORTAL_PERIOD / 6000) + MAX_FINALITY_LAG)); + expect(ext.data).toEqual({ period: expectedPeriod, current: BigInt(FINALIZED_BLOCK_NUMBER) }); + expect(ext.additionalSigned).toBe(BLOCK_HASH); + }); + + it('should use fallback values when consts are missing', async () => { + const clientNoConsts = createMockClient({ + consts: {}, + }); + + const ext = createCheckMortality(clientNoConsts); + + await ext.init(); + + // Fallback: min(FALLBACK_MAX_HASH_COUNT, MORTAL_PERIOD / FALLBACK_PERIOD + MAX_FINALITY_LAG) + // = min(250, 720000 / 6000 + 5) = min(250, 125) = 125 + const expectedPeriod = BigInt( + Math.min(FALLBACK_MAX_HASH_COUNT, Math.floor(MORTAL_PERIOD / FALLBACK_PERIOD) + MAX_FINALITY_LAG), + ); + expect(ext.data).toEqual({ period: expectedPeriod, current: BigInt(FINALIZED_BLOCK_NUMBER) }); + }); + }); + + describe('init() with v2 rpc', () => { + it('should use block.finalized() for signing header', async () => { + let finalizedCalled = false; + const client = createMockClient({ + rpcVersion: 'v2', + block: { + finalized: async () => { + finalizedCalled = true; + return { hash: BLOCK_HASH, number: FINALIZED_BLOCK_NUMBER }; + }, + }, + }); + + const ext = createCheckMortality(client); + await ext.init(); + + expect(finalizedCalled).toBe(true); + expect(ext.additionalSigned).toBe(BLOCK_HASH); + }); + }); + + describe('fromPayload()', () => { + it('should restore state from a payload', async () => { + const ext = createCheckMortality(mockClient); + + await ext.fromPayload({ + era: '0x0000', + blockHash: BLOCK_HASH, + blockNumber: '0x64', + // Other required fields from SignerPayloadJSON with dummy values + address: '0x1234', + genesisHash: GENESIS_HASH, + method: '0x00', + nonce: '0x00', + specVersion: '0x01', + tip: '0x00', + transactionVersion: '0x01', + signedExtensions: [], + version: 4, + }); + + expect(ext.additionalSigned).toBe(BLOCK_HASH); + + const payload = ext.toPayload(); + expect(payload.blockHash).toBe(BLOCK_HASH); + expect(payload.blockNumber).toBe('0x64'); + }); + + it('should round-trip from init() to toPayload() to fromPayload()', async () => { + const ext1 = createCheckMortality(mockClient, { + mortality: { type: 'Mortal', period: 128 }, + }); + await ext1.init(); + + const payload = ext1.toPayload(); + + const ext2 = createCheckMortality(mockClient); + await ext2.fromPayload({ + era: payload.era!, + blockHash: payload.blockHash!, + blockNumber: payload.blockNumber!, + address: '0x1234', + genesisHash: GENESIS_HASH, + method: '0x00', + nonce: '0x00', + specVersion: '0x01', + tip: '0x00', + transactionVersion: '0x01', + signedExtensions: [], + version: 4, + }); + + expect(ext2.additionalSigned).toBe(ext1.additionalSigned); + + const payload2 = ext2.toPayload(); + expect(payload2.blockHash).toBe(payload.blockHash); + expect(payload2.blockNumber).toBe(payload.blockNumber); + }); + }); + + describe('toPayload()', () => { + it('should include era, blockHash, and blockNumber', async () => { + const ext = createCheckMortality(mockClient, { + mortality: { type: 'Mortal', period: 64 }, + }); + await ext.init(); + + const payload = ext.toPayload(); + expect(payload).toHaveProperty('era'); + expect(payload).toHaveProperty('blockHash'); + expect(payload).toHaveProperty('blockNumber'); + }); + }); +}); diff --git a/packages/api/src/extrinsic/extensions/known/CheckMortality.ts b/packages/api/src/extrinsic/extensions/known/CheckMortality.ts index be4500f2f..9a9f172e5 100644 --- a/packages/api/src/extrinsic/extensions/known/CheckMortality.ts +++ b/packages/api/src/extrinsic/extensions/known/CheckMortality.ts @@ -20,9 +20,18 @@ export class CheckMortality extends SignedExtension { #signingHeader!: SigningHeader; async init() { - this.#signingHeader = await this.#getSigningHeader(); - this.data = { period: this.#calculateMortalLength(), current: BigInt(this.#signingHeader.number) }; - this.additionalSigned = this.#signingHeader.hash; + const mortality = this.payloadOptions.mortality; + + if (mortality?.type === 'Immortal') { + this.data = { type: 'Immortal' }; + this.additionalSigned = this.client.genesisHash; + } else { + this.#signingHeader = await this.#getSigningHeader(); + const period = + mortality?.type === 'Mortal' ? BigInt(mortality.period) : this.#calculateMortalLength(); + this.data = { period, current: BigInt(this.#signingHeader.number) }; + this.additionalSigned = this.#signingHeader.hash; + } } async fromPayload(payload: SignerPayloadJSON): Promise { @@ -116,7 +125,7 @@ export class CheckMortality extends SignedExtension { return { era: u8aToHex(this.$Data.tryEncode(this.data)), blockHash: this.additionalSigned, - blockNumber: numberToHex(this.#signingHeader.number), + blockNumber: numberToHex(this.#signingHeader?.number ?? 0), }; } } diff --git a/packages/types/src/extrinsic.ts b/packages/types/src/extrinsic.ts index efd68be7f..aaabe321e 100644 --- a/packages/types/src/extrinsic.ts +++ b/packages/types/src/extrinsic.ts @@ -13,15 +13,16 @@ import { IKeyringPair, InjectedSigner } from './pjs-types.js'; export type AddressOrPair = IKeyringPair | string; // | AccountId32Like | MultiAddressLike; +export type MortalityOptions = + | { type: 'Immortal' } // immortal transaction (never expires) + | { type: 'Mortal'; period: number }; // custom mortal period in blocks + export interface PayloadOptions { nonce?: number; tip?: bigint; assetId?: AssetId; metadataHash?: HexString; // If empty -> disabled, if not empty -> enabled - - // TODO support customize mortality - // blockHash?: Uint8Array | HexString; - // era?: HexString + mortality?: MortalityOptions; [prop: string]: any; }