diff --git a/docs-v2/openapi-v1.yaml b/docs-v2/openapi-v1.yaml index 688d82b4c..17d6470f0 100755 --- a/docs-v2/openapi-v1.yaml +++ b/docs-v2/openapi-v1.yaml @@ -2667,6 +2667,30 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /transaction/metadata-blob: + post: + tags: + - transaction + summary: Get the metadata blob for offline signers. + description: Returns the minimal metadata ("metadata blob" or "proof") needed by offline + signers to decode a transaction's signing payload, along with the metadata + hash for the CheckMetadataHash signed extension per RFC-0078. + operationId: getTransactionMetadataBlob + requestBody: + $ref: '#/components/requestBodies/TransactionMetadataBlob' + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionMetadataBlob' + "400": + description: invalid request body or metadata V15 not available + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /pallets/assets/{assetId}/asset-info: get: tags: @@ -8406,6 +8430,74 @@ components: - `RuntimeVersion`: https://crates.parity.io/sp_version/struct.RuntimeVersion.html - `SignedExtension`: https://crates.parity.io/sp_runtime/traits/trait.SignedExtension.html - FRAME Support: https://crates.parity.io/frame_support/metadata/index.html + MetadataBlobBody: + type: object + properties: + tx: + type: string + format: hex + description: Full encoded extrinsic as a hex string. + txAdditionalSigned: + type: string + format: hex + description: Optional additional signed data for the extrinsic. Used together with `tx`. + callData: + type: string + format: hex + description: Call data as hex string. Use this alongside includedInExtrinsic + and includedInSignedData instead of `tx`. + includedInExtrinsic: + type: string + format: hex + description: Signed extension data included in the extrinsic. Required when using callData. + includedInSignedData: + type: string + format: hex + description: Signed extension data included in the signature. Required when using callData. + at: + type: string + description: Block hash or number to query at. Defaults to finalized head. + format: unsignedInteger or $hex + description: >- + Request body for generating the metadata blob. You must provide either: + (1) `tx` (full extrinsic) with optional `txAdditionalSigned`, or + (2) `callData` with `includedInExtrinsic` and `includedInSignedData`. + TransactionMetadataBlob: + type: object + properties: + at: + $ref: '#/components/schemas/BlockIdentifiers' + metadataHash: + type: string + format: hex + description: The 32-byte metadata hash (hex-encoded) that should be used with + the CheckMetadataHash signed extension. + metadataBlob: + type: string + format: hex + description: The metadata blob (hex-encoded) containing the minimal metadata + required for offline signers to decode the transaction. This is a SCALE-encoded + Proof structure per RFC-0078. + specVersion: + type: number + description: The chain's spec version. + specName: + type: string + description: The chain's spec name. + base58Prefix: + type: number + description: The chain's SS58 address prefix. + decimals: + type: number + description: Number of decimals for the chain's native token. + tokenSymbol: + type: string + description: Symbol for the chain's native token. + description: >- + Response containing the metadata blob for offline signers. The metadataBlob + contains type definitions needed to decode the specific transaction, Merkle + proofs verifying these types are part of the full metadata, and extra chain + info. TransactionPool: type: object properties: @@ -8533,6 +8625,12 @@ components: schema: $ref: '#/components/schemas/DryRunBody' required: true + TransactionMetadataBlob: + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataBlobBody' + required: true ContractMetadata: content: application/json: diff --git a/docs/src/openapi-v1.yaml b/docs/src/openapi-v1.yaml index 688d82b4c..c5e87c982 100755 --- a/docs/src/openapi-v1.yaml +++ b/docs/src/openapi-v1.yaml @@ -2667,6 +2667,44 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /transaction/metadata-blob: + post: + tags: + - transaction + summary: Get the metadata blob for offline signers. + description: >- + Returns the minimal metadata ("metadata blob" or "proof") needed by offline + signers to decode a transaction's signing payload, along with the metadata + hash for the CheckMetadataHash signed extension per RFC-0078. + + + The metadata blob contains type definitions needed to decode the specific + transaction, Merkle proofs verifying these types are part of the full metadata, + and extra chain info (specVersion, specName, base58Prefix, decimals, tokenSymbol). + + + Offline signers can use this to decode the transaction to display what the user + is signing and verify the metadata subset matches the on-chain metadata via + Merkle proofs. + + + Reference: RFC-0078 https://polkadot-fellows.github.io/RFCs/approved/0078-merkleized-metadata.html + operationId: getTransactionMetadataBlob + requestBody: + $ref: '#/components/requestBodies/TransactionMetadataBlob' + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionMetadataBlob' + "400": + description: invalid request body or metadata V15 not available + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /pallets/assets/{assetId}/asset-info: get: tags: @@ -8406,6 +8444,74 @@ components: - `RuntimeVersion`: https://crates.parity.io/sp_version/struct.RuntimeVersion.html - `SignedExtension`: https://crates.parity.io/sp_runtime/traits/trait.SignedExtension.html - FRAME Support: https://crates.parity.io/frame_support/metadata/index.html + MetadataBlobBody: + type: object + properties: + tx: + type: string + format: hex + description: Full encoded extrinsic as a hex string. + txAdditionalSigned: + type: string + format: hex + description: Optional additional signed data for the extrinsic. Used together with `tx`. + callData: + type: string + format: hex + description: Call data as hex string. Use this alongside includedInExtrinsic + and includedInSignedData instead of `tx`. + includedInExtrinsic: + type: string + format: hex + description: Signed extension data included in the extrinsic. Required when using callData. + includedInSignedData: + type: string + format: hex + description: Signed extension data included in the signature. Required when using callData. + at: + type: string + description: Block hash or number to query at. Defaults to finalized head. + format: unsignedInteger or $hex + description: >- + Request body for generating the metadata blob. You must provide either: + (1) `tx` (full extrinsic) with optional `txAdditionalSigned`, or + (2) `callData` with `includedInExtrinsic` and `includedInSignedData`. + TransactionMetadataBlob: + type: object + properties: + at: + $ref: '#/components/schemas/BlockIdentifiers' + metadataHash: + type: string + format: hex + description: The 32-byte metadata hash (hex-encoded) that should be used with + the CheckMetadataHash signed extension. + metadataBlob: + type: string + format: hex + description: The metadata blob (hex-encoded) containing the minimal metadata + required for offline signers to decode the transaction. This is a SCALE-encoded + Proof structure per RFC-0078. + specVersion: + type: number + description: The chain's spec version. + specName: + type: string + description: The chain's spec name. + base58Prefix: + type: number + description: The chain's SS58 address prefix. + decimals: + type: number + description: Number of decimals for the chain's native token. + tokenSymbol: + type: string + description: Symbol for the chain's native token. + description: >- + Response containing the metadata blob for offline signers. The metadataBlob + contains type definitions needed to decode the specific transaction, Merkle + proofs verifying these types are part of the full metadata, and extra chain + info. TransactionPool: type: object properties: @@ -8533,6 +8639,12 @@ components: schema: $ref: '#/components/schemas/DryRunBody' required: true + TransactionMetadataBlob: + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataBlobBody' + required: true ContractMetadata: content: application/json: diff --git a/package.json b/package.json index f0fb499ee..672a4831a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "test:test-release": "yarn build:scripts && node scripts/build/runYarnPack.js" }, "dependencies": { + "@polkadot-api/merkleize-metadata": "^1.1.29", "@polkadot/api": "16.5.2", "@polkadot/api-augment": "16.5.2", "@polkadot/api-contract": "16.5.2", diff --git a/src/chains-config/assetHubKusamaControllers.ts b/src/chains-config/assetHubKusamaControllers.ts index 5fd7bc47b..834b4d9b1 100644 --- a/src/chains-config/assetHubKusamaControllers.ts +++ b/src/chains-config/assetHubKusamaControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -88,6 +88,7 @@ export const assetHubKusamaControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/assetHubNextWestendControllers.ts b/src/chains-config/assetHubNextWestendControllers.ts index ef932c18e..2c004023a 100644 --- a/src/chains-config/assetHubNextWestendControllers.ts +++ b/src/chains-config/assetHubNextWestendControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -86,6 +86,7 @@ export const assetHubNextWestendControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/assetHubPolkadotControllers.ts b/src/chains-config/assetHubPolkadotControllers.ts index 41e86395d..cd1eecd1f 100644 --- a/src/chains-config/assetHubPolkadotControllers.ts +++ b/src/chains-config/assetHubPolkadotControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -88,6 +88,7 @@ export const assetHubPolkadotControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/assetHubWestendControllers.ts b/src/chains-config/assetHubWestendControllers.ts index bef457b15..34687a47a 100644 --- a/src/chains-config/assetHubWestendControllers.ts +++ b/src/chains-config/assetHubWestendControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -87,6 +87,7 @@ export const assetHubWestendControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/defaultControllers.ts b/src/chains-config/defaultControllers.ts index 59dd24fba..705a1146c 100644 --- a/src/chains-config/defaultControllers.ts +++ b/src/chains-config/defaultControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -54,6 +54,7 @@ export const defaultControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/kusamaControllers.ts b/src/chains-config/kusamaControllers.ts index 7433282e2..1710d922e 100644 --- a/src/chains-config/kusamaControllers.ts +++ b/src/chains-config/kusamaControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -56,6 +56,7 @@ export const kusamaControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/polkadotControllers.ts b/src/chains-config/polkadotControllers.ts index b14e46a9c..807d76d2d 100644 --- a/src/chains-config/polkadotControllers.ts +++ b/src/chains-config/polkadotControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -55,6 +55,7 @@ export const polkadotControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/chains-config/westendControllers.ts b/src/chains-config/westendControllers.ts index 496879e20..40cc08527 100644 --- a/src/chains-config/westendControllers.ts +++ b/src/chains-config/westendControllers.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -55,6 +55,7 @@ export const westendControllers: ControllerConfig = { 'TransactionDryRun', 'TransactionFeeEstimate', 'TransactionMaterial', + 'TransactionMetadataBlob', 'TransactionSubmit', ], options: { diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 97d29b45c..c1a12d41a 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -81,7 +81,13 @@ import { RcTransactionSubmit, } from './rc/transaction'; import { RuntimeCode, RuntimeMetadata, RuntimeSpec } from './runtime'; -import { TransactionDryRun, TransactionFeeEstimate, TransactionMaterial, TransactionSubmit } from './transaction'; +import { + TransactionDryRun, + TransactionFeeEstimate, + TransactionMaterial, + TransactionMetadataBlob, + TransactionSubmit, +} from './transaction'; /** * Object containing every controller class definition. */ @@ -154,6 +160,7 @@ export const controllers = { TransactionDryRun, TransactionFeeEstimate, TransactionMaterial, + TransactionMetadataBlob, TransactionSubmit, Paras, ParasInclusion, diff --git a/src/controllers/transaction/TransactionMetadataBlobController.ts b/src/controllers/transaction/TransactionMetadataBlobController.ts new file mode 100644 index 000000000..f768fc132 --- /dev/null +++ b/src/controllers/transaction/TransactionMetadataBlobController.ts @@ -0,0 +1,97 @@ +// Copyright 2017-2026 Parity Technologies (UK) Ltd. +// This file is part of Substrate API Sidecar. +// +// Substrate API Sidecar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import { TransactionMetadataBlobService } from '../../services'; +import { IMetadataBlobBody, IPostRequestHandler } from '../../types/requests'; +import AbstractController from '../AbstractController'; + +/** + * POST metadata blob for a transaction. + * + * This endpoint generates the minimal metadata ("metadata blob" or "proof") + * needed by offline signers to decode a transaction's signing payload. + * It also returns the metadata hash that should be used with the + * CheckMetadataHash signed extension per RFC-0078. + * + * Request body: + * - Alternative 1: Full extrinsic + * - `tx`: Hex-encoded full extrinsic + * - `txAdditionalSigned`: (Optional) Hex-encoded additional signed data + * + * - Alternative 2: Extrinsic parts + * - `callData`: Hex-encoded call data + * - `includedInExtrinsic`: Hex-encoded signed extension extra data + * - `includedInSignedData`: Hex-encoded signed extension additional signed data + * + * - `at`: (Optional) Block hash or number. Defaults to finalized head. + * + * Returns: + * - `at`: Block context (hash and height) + * - `metadataHash`: The 32-byte metadata hash for CheckMetadataHash as hex + * - `metadataBlob`: The minimal metadata proof for offline signers as hex + * - `specVersion`: Runtime spec version + * - `specName`: Runtime spec name + * - `base58Prefix`: SS58 address prefix + * - `decimals`: Native token decimals + * - `tokenSymbol`: Native token symbol + * + * The `metadataBlob` contains: + * - Type definitions needed to decode the specific transaction + * - Merkle proofs verifying these types are part of the full metadata + * - Extra info (specVersion, specName, base58Prefix, decimals, tokenSymbol) + * + * Offline signers can use this to: + * 1. Decode the transaction to display what the user is signing + * 2. Verify the metadata subset matches the on-chain metadata via merkle proofs + * + * Reference: + * - RFC-0078: https://polkadot-fellows.github.io/RFCs/approved/0078-merkleized-metadata.html + * - Original issue: https://github.com/paritytech/substrate-api-sidecar/issues/1783 + */ +export default class TransactionMetadataBlobController extends AbstractController { + static controllerName = 'TransactionMetadataBlob'; + static requiredPallets = []; + + constructor(api: string) { + super(api, '/transaction/metadata-blob', new TransactionMetadataBlobService(api)); + this.initRoutes(); + } + + protected initRoutes(): void { + this.router.post(this.path, TransactionMetadataBlobController.catchWrap(this.getMetadataBlob)); + } + + /** + * POST handler for generating metadata blob. + */ + private getMetadataBlob: IPostRequestHandler = async ( + { body: { tx, txAdditionalSigned, callData, includedInExtrinsic, includedInSignedData, at } }, + res, + ): Promise => { + const hash = await this.getHashFromAt(at); + + TransactionMetadataBlobController.sanitizedSend( + res, + await this.service.fetchMetadataBlob(this.api, hash, { + tx, + txAdditionalSigned, + callData, + includedInExtrinsic, + includedInSignedData, + }), + ); + }; +} diff --git a/src/controllers/transaction/index.ts b/src/controllers/transaction/index.ts index 0727b6b32..02968332a 100644 --- a/src/controllers/transaction/index.ts +++ b/src/controllers/transaction/index.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -17,4 +17,5 @@ export { default as TransactionDryRun } from './TransactionDryRunController'; export { default as TransactionFeeEstimate } from './TransactionFeeEstimateController'; export { default as TransactionMaterial } from './TransactionMaterialController'; +export { default as TransactionMetadataBlob } from './TransactionMetadataBlobController'; export { default as TransactionSubmit } from './TransactionSubmitController'; diff --git a/src/services/transaction/TransactionMetadataBlobService.spec.ts b/src/services/transaction/TransactionMetadataBlobService.spec.ts new file mode 100644 index 000000000..deb1e4386 --- /dev/null +++ b/src/services/transaction/TransactionMetadataBlobService.spec.ts @@ -0,0 +1,332 @@ +// Copyright 2017-2026 Parity Technologies (UK) Ltd. +// This file is part of Substrate API Sidecar. +// +// Substrate API Sidecar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import type { ApiPromise } from '@polkadot/api'; +import { ApiDecoration } from '@polkadot/api/types'; +import type { Hash } from '@polkadot/types/interfaces'; +import { BadRequest } from 'http-errors'; + +import { polkadotMetadataRpcV1003000 } from '../../test-helpers/metadata/polkadotV1003000Metadata'; +import { polkadotRegistryV1003000 } from '../../test-helpers/registries'; +import { blockHash22887036 } from '../test-helpers/mock'; +import { TransactionMetadataBlobService } from './TransactionMetadataBlobService'; + +// Mock the merkleize-metadata functions +const mockDigest = jest + .fn() + .mockReturnValue( + new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, + ]), + ); +const mockGetProofForExtrinsic = jest.fn().mockReturnValue(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); +const mockGetProofForExtrinsicParts = jest.fn().mockReturnValue(new Uint8Array([0xca, 0xfe, 0xba, 0xbe])); + +const mockMerkleized = { + digest: mockDigest, + getProofForExtrinsic: mockGetProofForExtrinsic, + getProofForExtrinsicParts: mockGetProofForExtrinsicParts, +}; + +const mockMerkleizeMetadata = jest.fn().mockReturnValue(mockMerkleized); + +// Mock metadata functions +const mockMetadataAtVersion = () => + Promise.resolve().then(() => { + return polkadotRegistryV1003000.createType('Option', polkadotMetadataRpcV1003000); + }); + +const mockMetadataVersions = jest.fn().mockResolvedValue({ + toJSON: jest.fn().mockReturnValue([14, 15]), +}); + +const mockMetadataVersionsNoV15 = jest.fn().mockResolvedValue({ + toJSON: jest.fn().mockReturnValue([14]), +}); + +const mockHeader = { + number: { + unwrap: () => ({ + toString: () => '22887036', + }), + }, +}; + +const runtimeVersion = { + specName: polkadotRegistryV1003000.createType('Text', 'polkadot'), + specVersion: polkadotRegistryV1003000.createType('u32', 1003000), + transactionVersion: polkadotRegistryV1003000.createType('u32', 26), +}; + +const mockProperties = { + ss58Format: { + isSome: true, + value: { + toNumber: () => 0, + }, + }, + tokenDecimals: { + isSome: true, + value: { + toJSON: () => 10, + }, + }, + tokenSymbol: { + isSome: true, + value: { + toJSON: () => 'DOT', + }, + }, +}; + +const mockHistoricApi = { + call: { + metadata: { + metadataVersions: mockMetadataVersions, + metadataAtVersion: mockMetadataAtVersion, + }, + }, + registry: polkadotRegistryV1003000, +} as unknown as ApiDecoration<'promise'>; + +const mockApi = { + ...mockHistoricApi, + rpc: { + chain: { + getHeader: () => Promise.resolve(mockHeader), + }, + state: { + getRuntimeVersion: () => Promise.resolve(runtimeVersion), + }, + system: { + properties: () => Promise.resolve(mockProperties), + }, + }, + at: (_hash: Hash) => Promise.resolve(mockHistoricApi), +} as unknown as ApiPromise; + +describe('TransactionMetadataBlobService', () => { + let getMerkleizeMetadataSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock the getMerkleizeMetadata method on the prototype to return our mock + getMerkleizeMetadataSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(TransactionMetadataBlobService.prototype as any, 'getMerkleizeMetadata') + .mockResolvedValue(mockMerkleizeMetadata); + }); + + afterEach(() => { + getMerkleizeMetadataSpy.mockRestore(); + }); + + describe('fetchMetadataBlob', () => { + it('should return metadata blob when given a full extrinsic (tx)', async () => { + const service = new TransactionMetadataBlobService('mock'); + // timestamp.set() + const tx = '0x280403000b207eba5c8501'; + + const result = await service.fetchMetadataBlob(mockApi, blockHash22887036, { tx }); + + expect(result.at.hash).toEqual(blockHash22887036); + expect(result.at.height).toEqual('22887036'); + expect(result.metadataHash).toMatch(/^0x[a-f0-9]{64}$/); + expect(result.metadataBlob).toMatch(/^0x[a-f0-9]+$/); + expect(result.specVersion).toEqual(1003000); + expect(result.specName).toEqual('polkadot'); + expect(result.base58Prefix).toEqual(0); + expect(result.decimals).toEqual(10); + expect(result.tokenSymbol).toEqual('DOT'); + expect(mockGetProofForExtrinsic).toHaveBeenCalledWith(tx, undefined); + }); + + it('should return metadata blob when given extrinsic parts', async () => { + const service = new TransactionMetadataBlobService('mock'); + const params = { + callData: '0x0403', + includedInExtrinsic: '0x00', + includedInSignedData: '0x00', + }; + + const result = await service.fetchMetadataBlob(mockApi, blockHash22887036, params); + + expect(result.at.hash).toEqual(blockHash22887036); + expect(result.metadataBlob).toMatch(/^0x[a-f0-9]+$/); + expect(mockGetProofForExtrinsicParts).toHaveBeenCalledWith( + params.callData, + params.includedInExtrinsic, + params.includedInSignedData, + ); + }); + + it('should use txAdditionalSigned when provided with tx', async () => { + const service = new TransactionMetadataBlobService('mock'); + // timestamp.set() + const tx = '0x280403000b207eba5c8501'; + const txAdditionalSigned = '0x1234'; + + await service.fetchMetadataBlob(mockApi, blockHash22887036, { tx, txAdditionalSigned }); + + expect(mockGetProofForExtrinsic).toHaveBeenCalledWith(tx, txAdditionalSigned); + }); + + it('should throw BadRequest when neither tx nor callData is provided', async () => { + const service = new TransactionMetadataBlobService('mock'); + + await expect(service.fetchMetadataBlob(mockApi, blockHash22887036, {})).rejects.toThrow(BadRequest); + await expect(service.fetchMetadataBlob(mockApi, blockHash22887036, {})).rejects.toThrow( + 'Must provide either `tx` (full extrinsic) or `callData` with `includedInExtrinsic` and `includedInSignedData`.', + ); + }); + + it('should throw BadRequest when callData is provided without includedInExtrinsic', async () => { + const service = new TransactionMetadataBlobService('mock'); + const params = { + callData: '0x0403', + includedInSignedData: '0x00', + }; + + await expect(service.fetchMetadataBlob(mockApi, blockHash22887036, params)).rejects.toThrow(BadRequest); + await expect(service.fetchMetadataBlob(mockApi, blockHash22887036, params)).rejects.toThrow( + 'When using `callData`, must also provide `includedInExtrinsic` and `includedInSignedData`.', + ); + }); + + it('should throw BadRequest when callData is provided without includedInSignedData', async () => { + const service = new TransactionMetadataBlobService('mock'); + const params = { + callData: '0x0403', + includedInExtrinsic: '0x00', + }; + + await expect(service.fetchMetadataBlob(mockApi, blockHash22887036, params)).rejects.toThrow(BadRequest); + await expect(service.fetchMetadataBlob(mockApi, blockHash22887036, params)).rejects.toThrow( + 'When using `callData`, must also provide `includedInExtrinsic` and `includedInSignedData`.', + ); + }); + + it('should throw BadRequest when V15 metadata is not available', async () => { + const mockHistoricApiNoV15 = { + call: { + metadata: { + metadataVersions: mockMetadataVersionsNoV15, + metadataAtVersion: mockMetadataAtVersion, + }, + }, + registry: polkadotRegistryV1003000, + } as unknown as ApiDecoration<'promise'>; + + const mockApiNoV15 = { + ...mockApi, + at: (_hash: Hash) => Promise.resolve(mockHistoricApiNoV15), + } as unknown as ApiPromise; + + const service = new TransactionMetadataBlobService('mock'); + const tx = '0x280403000b207eba5c8501'; + + await expect(service.fetchMetadataBlob(mockApiNoV15, blockHash22887036, { tx })).rejects.toThrow(BadRequest); + await expect(service.fetchMetadataBlob(mockApiNoV15, blockHash22887036, { tx })).rejects.toThrow( + 'Metadata V15 is not available on this chain. CheckMetadataHash requires V15 metadata.', + ); + }); + + it('should handle array token decimals and symbols', async () => { + const mockPropertiesArray = { + ss58Format: { + isSome: true, + value: { + toNumber: () => 2, + }, + }, + tokenDecimals: { + isSome: true, + value: { + toJSON: () => [12, 10], + }, + }, + tokenSymbol: { + isSome: true, + value: { + toJSON: () => ['KSM', 'DOT'], + }, + }, + }; + + const mockApiArray = { + ...mockApi, + rpc: { + ...mockApi.rpc, + system: { + properties: () => Promise.resolve(mockPropertiesArray), + }, + }, + } as unknown as ApiPromise; + + const service = new TransactionMetadataBlobService('mock'); + const tx = '0x280403000b207eba5c8501'; + + const result = await service.fetchMetadataBlob(mockApiArray, blockHash22887036, { tx }); + + expect(result.decimals).toEqual(12); + expect(result.tokenSymbol).toEqual('KSM'); + expect(result.base58Prefix).toEqual(2); + }); + + it('should use default values when properties are not available', async () => { + const mockPropertiesEmpty = { + ss58Format: { + isSome: false, + }, + tokenDecimals: { + isSome: false, + }, + tokenSymbol: { + isSome: false, + }, + }; + + const mockApiEmpty = { + ...mockApi, + rpc: { + ...mockApi.rpc, + system: { + properties: () => Promise.resolve(mockPropertiesEmpty), + }, + }, + } as unknown as ApiPromise; + + const service = new TransactionMetadataBlobService('mock'); + const tx = '0x280403000b207eba5c8501'; + + const result = await service.fetchMetadataBlob(mockApiEmpty, blockHash22887036, { tx }); + + expect(result.decimals).toEqual(10); + expect(result.tokenSymbol).toEqual('DOT'); + expect(result.base58Prefix).toEqual(42); + }); + + it('should call merkleizeMetadata with correct parameters', async () => { + const service = new TransactionMetadataBlobService('mock'); + const tx = '0x280403000b207eba5c8501'; + + await service.fetchMetadataBlob(mockApi, blockHash22887036, { tx }); + + expect(mockMerkleizeMetadata).toHaveBeenCalledWith(expect.any(Uint8Array), { decimals: 10, tokenSymbol: 'DOT' }); + }); + }); +}); diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts new file mode 100644 index 000000000..af376e975 --- /dev/null +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -0,0 +1,185 @@ +// Copyright 2017-2026 Parity Technologies (UK) Ltd. +// This file is part of Substrate API Sidecar. +// +// Substrate API Sidecar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import { ApiPromise } from '@polkadot/api'; +import { ApiDecoration } from '@polkadot/api/types'; +import type { BlockHash } from '@polkadot/types/interfaces'; +import { u8aToHex } from '@polkadot/util'; +import { BadRequest, InternalServerError } from 'http-errors'; +import { MetadataBlobParams } from 'src/types/requests'; +import { ITransactionMetadataBlob } from 'src/types/responses'; + +import { AbstractService } from '../AbstractService'; + +const DEFAULT_DECIMALS = 10; +const DEFAULT_SS58_PREFIX = 42; +const DEFAULT_TOKEN_SYMBOL = 'DOT'; + +// Dynamic import helper that TypeScript won't transform to require() +// This is necessary because @polkadot-api/merkleize-metadata is an ESM-only package +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; + +// Cached module after first load +let merkleizeMetadataModule: typeof import('@polkadot-api/merkleize-metadata') | null = null; + +async function loadMerkleizeMetadata(): Promise< + (typeof import('@polkadot-api/merkleize-metadata'))['merkleizeMetadata'] +> { + if (!merkleizeMetadataModule) { + merkleizeMetadataModule = await dynamicImport( + '@polkadot-api/merkleize-metadata', + ); + } + return merkleizeMetadataModule.merkleizeMetadata; +} + +export class TransactionMetadataBlobService extends AbstractService { + protected async getMerkleizeMetadata(): Promise< + (typeof import('@polkadot-api/merkleize-metadata'))['merkleizeMetadata'] + > { + return loadMerkleizeMetadata(); + } + + /** + * Fetch metadata blob (proof) for a given transaction. + * This returns the minimal metadata needed by offline signers to decode + * the transaction, along with the metadata hash for CheckMetadataHash. + * + * @param api ApiPromise to use for the call + * @param hash `BlockHash` hash to query at + * @param params Request parameters + */ + async fetchMetadataBlob( + api: ApiPromise, + hash: BlockHash, + params: MetadataBlobParams, + ): Promise { + if (!params.tx && !params.callData) { + throw new BadRequest( + 'Must provide either `tx` (full extrinsic) or `callData` with `includedInExtrinsic` and `includedInSignedData`.', + ); + } + + if (params.callData && (!params.includedInExtrinsic || !params.includedInSignedData)) { + throw new BadRequest( + 'When using `callData`, must also provide `includedInExtrinsic` and `includedInSignedData`.', + ); + } + + const historicApi = await api.at(hash); + + const [header, version, properties, metadataV15Raw] = await Promise.all([ + api.rpc.chain.getHeader(hash), + api.rpc.state.getRuntimeVersion(hash), + api.rpc.system.properties(), + this.fetchMetadataV15(historicApi), + ]); + + const tokenDecimalsRaw = properties.tokenDecimals.isSome ? properties.tokenDecimals.value : null; + const tokenSymbolRaw = properties.tokenSymbol.isSome ? properties.tokenSymbol.value : null; + const ss58Format = properties.ss58Format.isSome ? properties.ss58Format.value : null; + + let decimals = DEFAULT_DECIMALS; + if (tokenDecimalsRaw) { + const decimalsJson = tokenDecimalsRaw.toJSON(); + if (Array.isArray(decimalsJson)) { + decimals = (decimalsJson[0] as number) ?? DEFAULT_DECIMALS; + } else if (typeof decimalsJson === 'number') { + decimals = decimalsJson; + } + } + + let tokenSymbol = DEFAULT_TOKEN_SYMBOL; + if (tokenSymbolRaw) { + const symbolJson = tokenSymbolRaw.toJSON(); + if (Array.isArray(symbolJson)) { + tokenSymbol = (symbolJson[0] as string) ?? DEFAULT_TOKEN_SYMBOL; + } else if (typeof symbolJson === 'string') { + tokenSymbol = symbolJson; + } + } + + const base58Prefix = ss58Format?.toNumber() ?? DEFAULT_SS58_PREFIX; + + // Use dynamic import for ESM module compatibility + const merkleizeMetadata = await this.getMerkleizeMetadata(); + const merkleized = merkleizeMetadata(metadataV15Raw, { + decimals, + tokenSymbol, + }); + + const metadataHash = u8aToHex(merkleized.digest()); + + let metadataBlobBytes: Uint8Array; + + if (params.tx) { + metadataBlobBytes = merkleized.getProofForExtrinsic(params.tx, params.txAdditionalSigned); + } else { + metadataBlobBytes = merkleized.getProofForExtrinsicParts( + params.callData!, + params.includedInExtrinsic!, + params.includedInSignedData!, + ); + } + + const metadataBlob = u8aToHex(metadataBlobBytes); + + return { + at: { + hash, + height: header.number.unwrap().toString(10), + }, + metadataHash, + metadataBlob, + specVersion: version.specVersion.toNumber(), + specName: version.specName.toString(), + base58Prefix, + decimals, + tokenSymbol, + }; + } + + /** + * Fetch V15 metadata from the chain. + * V15 metadata is required for RFC-0078 merkleization. + */ + private async fetchMetadataV15(apiAt: ApiDecoration<'promise'>): Promise { + try { + const availableVersions = await apiAt.call.metadata.metadataVersions(); + const versions = availableVersions.toJSON() as number[]; + + if (!versions.includes(15)) { + throw new BadRequest('Metadata V15 is not available on this chain. CheckMetadataHash requires V15 metadata.'); + } + + const metadataOpt = await apiAt.call.metadata.metadataAtVersion(15); + + if (metadataOpt.isNone) { + throw new InternalServerError('Failed to fetch metadata V15 from the chain.'); + } + + return metadataOpt.unwrap().toU8a(); + } catch (err) { + if (err instanceof BadRequest || err instanceof InternalServerError) { + throw err; + } + throw new InternalServerError( + `Failed to fetch metadata V15: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + } + } +} diff --git a/src/services/transaction/index.ts b/src/services/transaction/index.ts index d68170c6e..76b9a2ece 100644 --- a/src/services/transaction/index.ts +++ b/src/services/transaction/index.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -17,4 +17,5 @@ export * from './TransactionDryRunService'; export * from './TransactionFeeEstimateService'; export * from './TransactionMaterialService'; +export * from './TransactionMetadataBlobService'; export * from './TransactionSubmitService'; diff --git a/src/types/requests.ts b/src/types/requests.ts index bb1187454..154361c0a 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -28,6 +28,64 @@ export interface ITx { at: string; } +/** + * Body for the metadata blob request. Contains transaction data for generating + * the minimal metadata proof needed by offline signers. + */ +export interface IMetadataBlobBody { + /** + * Full encoded extrinsic as a hex string. Use this OR the individual components below. + */ + tx?: string; + /** + * Optional additional signed data for the extrinsic. + * Used together with `tx`. + */ + txAdditionalSigned?: string; + /** + * Call data as hex string. Use this alongside includedInExtrinsic and includedInSignedData. + */ + callData?: string; + /** + * Signed Extension data included in the extrinsic. + */ + includedInExtrinsic?: string; + /** + * Signed Extension data included in the signature. + */ + includedInSignedData?: string; + /** + * Block hash or number to query at. Defaults to finalized head. + */ + at?: string; +} + +/** + * Parameters for generating metadata blob proof. + */ +export interface MetadataBlobParams { + /** + * Full encoded extrinsic. Use this OR the individual parts. + */ + tx?: string; + /** + * Optional tx additional signed data. + */ + txAdditionalSigned?: string; + /** + * Call data. Use with includedInExtrinsic and includedInSignedData. + */ + callData?: string; + /** + * Signed Extension data included in the extrinsic. + */ + includedInExtrinsic?: string; + /** + * Signed Extension data included in the signature. + */ + includedInSignedData?: string; +} + /** * Body for the RequestHandlerContract. In other words, the body of the POST route that a message to a contract. */ diff --git a/src/types/responses/TransactionMetadataBlob.ts b/src/types/responses/TransactionMetadataBlob.ts new file mode 100644 index 000000000..bb21bb1ce --- /dev/null +++ b/src/types/responses/TransactionMetadataBlob.ts @@ -0,0 +1,28 @@ +// Copyright 2017-2026 Parity Technologies (UK) Ltd. +// This file is part of Substrate API Sidecar. +// +// Substrate API Sidecar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import { IAt } from '.'; + +export interface ITransactionMetadataBlob { + at: IAt; + metadataHash: string; + metadataBlob: string; + specVersion: number; + specName: string; + base58Prefix: number; + decimals: number; + tokenSymbol: string; +} diff --git a/src/types/responses/index.ts b/src/types/responses/index.ts index a9e5777d1..f71c29a7c 100644 --- a/src/types/responses/index.ts +++ b/src/types/responses/index.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2025 Parity Technologies (UK) Ltd. +// Copyright 2017-2026 Parity Technologies (UK) Ltd. // This file is part of Substrate API Sidecar. // // Substrate API Sidecar is free software: you can redistribute it and/or modify @@ -67,4 +67,5 @@ export * from './SanitizedEventItemMetadata'; export * from './SanitizedStorageItemMetadata'; export * from './TransactionDryRun'; export * from './TransactionMaterial'; +export * from './TransactionMetadataBlob'; export * from './ValidateAddress'; diff --git a/yarn.lock b/yarn.lock index 5f18a055e..a872534e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1464,6 +1464,17 @@ __metadata: languageName: node linkType: hard +"@polkadot-api/merkleize-metadata@npm:^1.1.29": + version: 1.1.29 + resolution: "@polkadot-api/merkleize-metadata@npm:1.1.29" + dependencies: + "@polkadot-api/metadata-builders": "npm:0.13.9" + "@polkadot-api/substrate-bindings": "npm:0.17.0" + "@polkadot-api/utils": "npm:0.2.0" + checksum: 10/1ad9356b6345c6ed234c9c4f766dca43647a2b9d4bbd9bd3a04c9ef26e9fcce12343753c7ff400b3d53930cae7a48a859f7e774070ad2cdc9332fe52baa284a3 + languageName: node + linkType: hard + "@polkadot-api/metadata-builders@npm:0.13.7": version: 0.13.7 resolution: "@polkadot-api/metadata-builders@npm:0.13.7" @@ -1474,6 +1485,16 @@ __metadata: languageName: node linkType: hard +"@polkadot-api/metadata-builders@npm:0.13.9": + version: 0.13.9 + resolution: "@polkadot-api/metadata-builders@npm:0.13.9" + dependencies: + "@polkadot-api/substrate-bindings": "npm:0.17.0" + "@polkadot-api/utils": "npm:0.2.0" + checksum: 10/4935e492aa91c82d06cd19a5b3ad9059d61d4b75416bd582398645fe0334b7d9e1ec26c7e8c1a3674752f86e70b9872a221047849007b7dfdfc0df6ebc5af7a5 + languageName: node + linkType: hard + "@polkadot-api/metadata-builders@npm:0.3.2": version: 0.3.2 resolution: "@polkadot-api/metadata-builders@npm:0.3.2" @@ -1620,6 +1641,18 @@ __metadata: languageName: node linkType: hard +"@polkadot-api/substrate-bindings@npm:0.17.0": + version: 0.17.0 + resolution: "@polkadot-api/substrate-bindings@npm:0.17.0" + dependencies: + "@noble/hashes": "npm:^2.0.1" + "@polkadot-api/utils": "npm:0.2.0" + "@scure/base": "npm:^2.0.0" + scale-ts: "npm:^1.6.1" + checksum: 10/dcf75f2db092b6247530a26791446da3a8512a62887d30b6f3173a9f74f890287e7df8f692c23e2fd620f508d7321f89d7580083a3065b1a783eeb9f6ba5dc34 + languageName: node + linkType: hard + "@polkadot-api/substrate-bindings@npm:0.6.0": version: 0.6.0 resolution: "@polkadot-api/substrate-bindings@npm:0.6.0" @@ -2465,6 +2498,7 @@ __metadata: resolution: "@substrate/api-sidecar@workspace:." dependencies: "@acala-network/chopsticks-testing": "npm:^1.2.5" + "@polkadot-api/merkleize-metadata": "npm:^1.1.29" "@polkadot/api": "npm:16.5.2" "@polkadot/api-augment": "npm:16.5.2" "@polkadot/api-contract": "npm:16.5.2"