From bc86226668239053c78ec545320863882e1e029e Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 15:40:49 -0300 Subject: [PATCH 01/15] feat: add metadataHash endpoint --- package.json | 1 + .../assetHubKusamaControllers.ts | 1 + .../assetHubNextWestendControllers.ts | 1 + .../assetHubPolkadotControllers.ts | 1 + .../assetHubWestendControllers.ts | 1 + src/chains-config/defaultControllers.ts | 1 + src/chains-config/kusamaControllers.ts | 1 + src/chains-config/polkadotControllers.ts | 1 + src/chains-config/westendControllers.ts | 1 + src/controllers/index.ts | 9 +- .../TransactionMetadataBlobController.ts | 126 ++++++++++++++ src/controllers/transaction/index.ts | 1 + .../TransactionMetadataBlobService.ts | 160 ++++++++++++++++++ src/services/transaction/index.ts | 1 + src/types/requests.ts | 32 ++++ .../responses/TransactionMetadataBlob.ts | 28 +++ src/types/responses/index.ts | 1 + yarn.lock | 34 ++++ 18 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src/controllers/transaction/TransactionMetadataBlobController.ts create mode 100644 src/services/transaction/TransactionMetadataBlobService.ts create mode 100644 src/types/responses/TransactionMetadataBlob.ts diff --git a/package.json b/package.json index f0fb499ee..dd549550a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@polkadot/api": "16.5.2", "@polkadot/api-augment": "16.5.2", "@polkadot/api-contract": "16.5.2", + "@polkadot-api/merkleize-metadata": "^1.1.29", "@polkadot/types": "16.5.2", "@polkadot/types-codec": "16.5.2", "@polkadot/util": "13.5.8", diff --git a/src/chains-config/assetHubKusamaControllers.ts b/src/chains-config/assetHubKusamaControllers.ts index 5fd7bc47b..ee686ac74 100644 --- a/src/chains-config/assetHubKusamaControllers.ts +++ b/src/chains-config/assetHubKusamaControllers.ts @@ -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..887260140 100644 --- a/src/chains-config/assetHubNextWestendControllers.ts +++ b/src/chains-config/assetHubNextWestendControllers.ts @@ -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..4a6ea9349 100644 --- a/src/chains-config/assetHubPolkadotControllers.ts +++ b/src/chains-config/assetHubPolkadotControllers.ts @@ -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..c9b966247 100644 --- a/src/chains-config/assetHubWestendControllers.ts +++ b/src/chains-config/assetHubWestendControllers.ts @@ -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..8f405f8de 100644 --- a/src/chains-config/defaultControllers.ts +++ b/src/chains-config/defaultControllers.ts @@ -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..c59a54ac6 100644 --- a/src/chains-config/kusamaControllers.ts +++ b/src/chains-config/kusamaControllers.ts @@ -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..c6506de48 100644 --- a/src/chains-config/polkadotControllers.ts +++ b/src/chains-config/polkadotControllers.ts @@ -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..074f92e51 100644 --- a/src/chains-config/westendControllers.ts +++ b/src/chains-config/westendControllers.ts @@ -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..6ec388039 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -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..d23afcdb6 --- /dev/null +++ b/src/controllers/transaction/TransactionMetadataBlobController.ts @@ -0,0 +1,126 @@ +// Copyright 2017-2025 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 { IPostRequestHandler, IMetadataBlobBody } from '../../types/requests'; +import AbstractController from '../AbstractController'; + +/** + * 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; +} + +/** + * 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..744e56686 100644 --- a/src/controllers/transaction/index.ts +++ b/src/controllers/transaction/index.ts @@ -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.ts b/src/services/transaction/TransactionMetadataBlobService.ts new file mode 100644 index 000000000..0aa2deb18 --- /dev/null +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -0,0 +1,160 @@ +// Copyright 2017-2025 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 { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; +import { ApiPromise } from '@polkadot/api'; +import { ApiDecoration } from '@polkadot/api/types'; +import { u8aToHex } from '@polkadot/util'; +import type { BlockHash } from '@polkadot/types/interfaces'; +import { BadRequest, InternalServerError } from 'http-errors'; +import { ITransactionMetadataBlob } from 'src/types/responses'; + +import { AbstractService } from '../AbstractService'; +import { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; + +export class TransactionMetadataBlobService extends AbstractService { + /** + * Fetch metadata blob (proof) for a given transaction. + * This returns the minimal metadata needed by offline signers to decode + * the transaction, alog with the metadata hash for CheckMetadataHas. + * + * @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 = 10; + if (!!tokenDecimalsRaw) { + const decimalsJson = tokenDecimalsRaw.toJSON(); + if (Array.isArray(decimalsJson)) { + decimals = (decimalsJson[0] as number) ?? 10; + } else if (typeof decimalsJson === 'number') { + decimals = decimalsJson; + } + } + + let tokenSymbol = 'DOT'; + if (!!tokenSymbolRaw) { + const symbolJson = tokenSymbolRaw.toJSON(); + if (Array.isArray(symbolJson)) { + tokenSymbol = (symbolJson[0] as string) ?? 'DOT'; + } else if (typeof symbolJson === 'string') { + tokenSymbol = symbolJson; + } + } + + const base58Prefix = ss58Format?.toNumber() ?? 42; + + 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..a8e998954 100644 --- a/src/services/transaction/index.ts +++ b/src/services/transaction/index.ts @@ -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..b8cc15d91 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -28,6 +28,38 @@ 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 alogside 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; +} + /** * 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..7cf737488 --- /dev/null +++ b/src/types/responses/TransactionMetadataBlob.ts @@ -0,0 +1,28 @@ +// Copyright 2017-2025 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..aa6d8c6ed 100644 --- a/src/types/responses/index.ts +++ b/src/types/responses/index.ts @@ -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" From b21ca1f452fb1457a382f1d26b6cb27ac2cf7d52 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 15:46:53 -0300 Subject: [PATCH 02/15] linting --- .../TransactionMetadataBlobController.ts | 7 ++----- .../TransactionMetadataBlobService.ts | 19 +++++++------------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/controllers/transaction/TransactionMetadataBlobController.ts b/src/controllers/transaction/TransactionMetadataBlobController.ts index d23afcdb6..c97bedf5e 100644 --- a/src/controllers/transaction/TransactionMetadataBlobController.ts +++ b/src/controllers/transaction/TransactionMetadataBlobController.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { TransactionMetadataBlobService } from '../../services'; -import { IPostRequestHandler, IMetadataBlobBody } from '../../types/requests'; +import { IMetadataBlobBody, IPostRequestHandler } from '../../types/requests'; import AbstractController from '../AbstractController'; /** @@ -97,10 +97,7 @@ export default class TransactionMetadataBlobController extends AbstractControlle } protected initRoutes(): void { - this.router.post( - this.path, - TransactionMetadataBlobController.catchWrap(this.getMetadataBlob), - ); + this.router.post(this.path, TransactionMetadataBlobController.catchWrap(this.getMetadataBlob)); } /** diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index 0aa2deb18..4c01f9567 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -14,16 +14,16 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; import { ApiPromise } from '@polkadot/api'; import { ApiDecoration } from '@polkadot/api/types'; -import { u8aToHex } from '@polkadot/util'; import type { BlockHash } from '@polkadot/types/interfaces'; +import { u8aToHex } from '@polkadot/util'; +import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; import { BadRequest, InternalServerError } from 'http-errors'; import { ITransactionMetadataBlob } from 'src/types/responses'; -import { AbstractService } from '../AbstractService'; import { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; +import { AbstractService } from '../AbstractService'; export class TransactionMetadataBlobService extends AbstractService { /** @@ -66,7 +66,7 @@ export class TransactionMetadataBlobService extends AbstractService { const ss58Format = properties.ss58Format.isSome ? properties.ss58Format.value : null; let decimals = 10; - if (!!tokenDecimalsRaw) { + if (tokenDecimalsRaw) { const decimalsJson = tokenDecimalsRaw.toJSON(); if (Array.isArray(decimalsJson)) { decimals = (decimalsJson[0] as number) ?? 10; @@ -76,7 +76,7 @@ export class TransactionMetadataBlobService extends AbstractService { } let tokenSymbol = 'DOT'; - if (!!tokenSymbolRaw) { + if (tokenSymbolRaw) { const symbolJson = tokenSymbolRaw.toJSON(); if (Array.isArray(symbolJson)) { tokenSymbol = (symbolJson[0] as string) ?? 'DOT'; @@ -97,10 +97,7 @@ export class TransactionMetadataBlobService extends AbstractService { let metadataBlobBytes: Uint8Array; if (params.tx) { - metadataBlobBytes = merkleized.getProofForExtrinsic( - params.tx, - params.txAdditionalSigned, - ); + metadataBlobBytes = merkleized.getProofForExtrinsic(params.tx, params.txAdditionalSigned); } else { metadataBlobBytes = merkleized.getProofForExtrinsicParts( params.callData!, @@ -136,9 +133,7 @@ export class TransactionMetadataBlobService extends AbstractService { 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.', - ); + throw new BadRequest('Metadata V15 is not available on this chain. CheckMetadataHash requires V15 metadata.'); } const metadataOpt = await apiAt.call.metadata.metadataAtVersion(15); From 7fc9e9e9054a1064de0cb79e778e716b95b6ceac Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 15:48:33 -0300 Subject: [PATCH 03/15] update copyright --- src/chains-config/assetHubKusamaControllers.ts | 2 +- src/chains-config/assetHubNextWestendControllers.ts | 2 +- src/chains-config/assetHubPolkadotControllers.ts | 2 +- src/chains-config/assetHubWestendControllers.ts | 2 +- src/chains-config/defaultControllers.ts | 2 +- src/chains-config/kusamaControllers.ts | 2 +- src/chains-config/polkadotControllers.ts | 2 +- src/chains-config/westendControllers.ts | 2 +- src/controllers/index.ts | 2 +- .../transaction/TransactionMetadataBlobController.ts | 2 +- src/controllers/transaction/index.ts | 2 +- src/services/transaction/TransactionMetadataBlobService.ts | 2 +- src/services/transaction/index.ts | 2 +- src/types/requests.ts | 2 +- src/types/responses/TransactionMetadataBlob.ts | 2 +- src/types/responses/index.ts | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/chains-config/assetHubKusamaControllers.ts b/src/chains-config/assetHubKusamaControllers.ts index ee686ac74..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 diff --git a/src/chains-config/assetHubNextWestendControllers.ts b/src/chains-config/assetHubNextWestendControllers.ts index 887260140..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 diff --git a/src/chains-config/assetHubPolkadotControllers.ts b/src/chains-config/assetHubPolkadotControllers.ts index 4a6ea9349..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 diff --git a/src/chains-config/assetHubWestendControllers.ts b/src/chains-config/assetHubWestendControllers.ts index c9b966247..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 diff --git a/src/chains-config/defaultControllers.ts b/src/chains-config/defaultControllers.ts index 8f405f8de..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 diff --git a/src/chains-config/kusamaControllers.ts b/src/chains-config/kusamaControllers.ts index c59a54ac6..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 diff --git a/src/chains-config/polkadotControllers.ts b/src/chains-config/polkadotControllers.ts index c6506de48..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 diff --git a/src/chains-config/westendControllers.ts b/src/chains-config/westendControllers.ts index 074f92e51..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 diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 6ec388039..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 diff --git a/src/controllers/transaction/TransactionMetadataBlobController.ts b/src/controllers/transaction/TransactionMetadataBlobController.ts index c97bedf5e..242649a6a 100644 --- a/src/controllers/transaction/TransactionMetadataBlobController.ts +++ b/src/controllers/transaction/TransactionMetadataBlobController.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 diff --git a/src/controllers/transaction/index.ts b/src/controllers/transaction/index.ts index 744e56686..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 diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index 4c01f9567..0909ecaa1 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.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 diff --git a/src/services/transaction/index.ts b/src/services/transaction/index.ts index a8e998954..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 diff --git a/src/types/requests.ts b/src/types/requests.ts index b8cc15d91..204ffb2e5 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 diff --git a/src/types/responses/TransactionMetadataBlob.ts b/src/types/responses/TransactionMetadataBlob.ts index 7cf737488..bb21bb1ce 100644 --- a/src/types/responses/TransactionMetadataBlob.ts +++ b/src/types/responses/TransactionMetadataBlob.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 diff --git a/src/types/responses/index.ts b/src/types/responses/index.ts index aa6d8c6ed..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 From e59e47d00b1bfc011535eef262f7b7e3835d40e4 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 15:50:00 -0300 Subject: [PATCH 04/15] typos --- src/services/transaction/TransactionMetadataBlobService.ts | 2 +- src/types/requests.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index 0909ecaa1..e4e8a442f 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -29,7 +29,7 @@ export class TransactionMetadataBlobService extends AbstractService { /** * Fetch metadata blob (proof) for a given transaction. * This returns the minimal metadata needed by offline signers to decode - * the transaction, alog with the metadata hash for CheckMetadataHas. + * the transaction, along with the metadata hash for CheckMetadataHash. * * @param api ApiPromise to use for the call * @param hash `BlockHash` hash to query at diff --git a/src/types/requests.ts b/src/types/requests.ts index 204ffb2e5..ae04da6d0 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -43,7 +43,7 @@ export interface IMetadataBlobBody { */ txAdditionalSigned?: string; /** - * Call data as hex string. Use this alogside includedInExtrinsic and includedInSignedData. + * Call data as hex string. Use this alongside includedInExtrinsic and includedInSignedData. */ callData?: string; /** From 2a3367ff5b4c04cfe155933046b6c5294fdff781 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 16:25:41 -0300 Subject: [PATCH 05/15] linting --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd549550a..672a4831a 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,10 @@ "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", - "@polkadot-api/merkleize-metadata": "^1.1.29", "@polkadot/types": "16.5.2", "@polkadot/types-codec": "16.5.2", "@polkadot/util": "13.5.8", From d74ed5dac4a3c8700f66414f29ac595d4839da69 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 16:36:23 -0300 Subject: [PATCH 06/15] override @scure/base version --- package.json | 5 ++++- yarn.lock | 9 +-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 672a4831a..baf152116 100644 --- a/package.json +++ b/package.json @@ -91,5 +91,8 @@ "polkadot", "kusama" ], - "packageManager": "yarn@4.6.0" + "packageManager": "yarn@4.6.0", + "resolutions": { + "@scure/base": "^1.2.6" + } } diff --git a/yarn.lock b/yarn.lock index a872534e6..43177bde6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2403,20 +2403,13 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.1.1, @scure/base@npm:^1.1.7, @scure/base@npm:^1.2.6": +"@scure/base@npm:^1.2.6": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 languageName: node linkType: hard -"@scure/base@npm:^2.0.0": - version: 2.0.0 - resolution: "@scure/base@npm:2.0.0" - checksum: 10/8fb86024f22e9c532d513b8df8a672252e58bd5695920ce646162287f0accd38e89cab58722a738b3d247b5dcf7760362ae2d82d502be7e62a555f5d98f8a110 - languageName: node - linkType: hard - "@scure/sr25519@npm:^0.2.0": version: 0.2.0 resolution: "@scure/sr25519@npm:0.2.0" From 27e4aadc1afb94ea34d8e295bf990b558b88d13e Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 16:52:34 -0300 Subject: [PATCH 07/15] update doc --- docs-v2/openapi-v1.yaml | 98 ++++++++++++++++++++++++++++++++++ docs/src/openapi-v1.yaml | 112 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) 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: From cf9b86aafe19060a76de578b9a1f562b6f9b9a7f Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 16:53:01 -0300 Subject: [PATCH 08/15] dynamic imports --- jest.config.js | 4 +++- package.json | 5 +---- .../TransactionMetadataBlobService.ts | 22 ++++++++++++++++++- yarn.lock | 9 +++++++- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/jest.config.js b/jest.config.js index 7b589e315..84ebda2a5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,5 +16,7 @@ module.exports = { maxWorkers: '50%', testPathIgnorePatterns: ['/build/', '/node_modules/', '/docs/', '/e2e-tests/'], // The below resolves `jest-haste-map: Haste module naming collision: @substrate/api-sidecar` - modulePathIgnorePatterns: ['/build'] + modulePathIgnorePatterns: ['/build'], + // Transform ESM modules from @polkadot-api, @noble, and @scure packages + transformIgnorePatterns: ['/node_modules/(?!(@polkadot-api|@noble|@scure)/)'], }; diff --git a/package.json b/package.json index baf152116..672a4831a 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,5 @@ "polkadot", "kusama" ], - "packageManager": "yarn@4.6.0", - "resolutions": { - "@scure/base": "^1.2.6" - } + "packageManager": "yarn@4.6.0" } diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index e4e8a442f..833c911a3 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -18,13 +18,31 @@ import { ApiPromise } from '@polkadot/api'; import { ApiDecoration } from '@polkadot/api/types'; import type { BlockHash } from '@polkadot/types/interfaces'; import { u8aToHex } from '@polkadot/util'; -import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; import { BadRequest, InternalServerError } from 'http-errors'; import { ITransactionMetadataBlob } from 'src/types/responses'; import { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; import { AbstractService } from '../AbstractService'; +// 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 getMerkleizeMetadata(): 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 { /** * Fetch metadata blob (proof) for a given transaction. @@ -87,6 +105,8 @@ export class TransactionMetadataBlobService extends AbstractService { const base58Prefix = ss58Format?.toNumber() ?? 42; + // Use dynamic import for ESM module compatibility + const merkleizeMetadata = await getMerkleizeMetadata(); const merkleized = merkleizeMetadata(metadataV15Raw, { decimals, tokenSymbol, diff --git a/yarn.lock b/yarn.lock index 43177bde6..a872534e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2403,13 +2403,20 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.2.6": +"@scure/base@npm:^1.1.1, @scure/base@npm:^1.1.7, @scure/base@npm:^1.2.6": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 languageName: node linkType: hard +"@scure/base@npm:^2.0.0": + version: 2.0.0 + resolution: "@scure/base@npm:2.0.0" + checksum: 10/8fb86024f22e9c532d513b8df8a672252e58bd5695920ce646162287f0accd38e89cab58722a738b3d247b5dcf7760362ae2d82d502be7e62a555f5d98f8a110 + languageName: node + linkType: hard + "@scure/sr25519@npm:^0.2.0": version: 0.2.0 resolution: "@scure/sr25519@npm:0.2.0" From ae76467c75036657e4952fac9298a094560671d5 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 17:19:13 -0300 Subject: [PATCH 09/15] downgrade merkleize-metadata --- jest.config.js | 4 +--- package.json | 2 +- .../TransactionMetadataBlobService.ts | 22 +------------------ yarn.lock | 4 ++-- 4 files changed, 5 insertions(+), 27 deletions(-) diff --git a/jest.config.js b/jest.config.js index 84ebda2a5..7b589e315 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,7 +16,5 @@ module.exports = { maxWorkers: '50%', testPathIgnorePatterns: ['/build/', '/node_modules/', '/docs/', '/e2e-tests/'], // The below resolves `jest-haste-map: Haste module naming collision: @substrate/api-sidecar` - modulePathIgnorePatterns: ['/build'], - // Transform ESM modules from @polkadot-api, @noble, and @scure packages - transformIgnorePatterns: ['/node_modules/(?!(@polkadot-api|@noble|@scure)/)'], + modulePathIgnorePatterns: ['/build'] }; diff --git a/package.json b/package.json index 672a4831a..e3c57a2b2 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:test-release": "yarn build:scripts && node scripts/build/runYarnPack.js" }, "dependencies": { - "@polkadot-api/merkleize-metadata": "^1.1.29", + "@polkadot-api/merkleize-metadata": "^1.1.22", "@polkadot/api": "16.5.2", "@polkadot/api-augment": "16.5.2", "@polkadot/api-contract": "16.5.2", diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index 833c911a3..2e299bd5c 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -23,25 +23,7 @@ import { ITransactionMetadataBlob } from 'src/types/responses'; import { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; import { AbstractService } from '../AbstractService'; - -// 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 getMerkleizeMetadata(): Promise< - (typeof import('@polkadot-api/merkleize-metadata'))['merkleizeMetadata'] -> { - if (!merkleizeMetadataModule) { - merkleizeMetadataModule = await dynamicImport( - '@polkadot-api/merkleize-metadata', - ); - } - return merkleizeMetadataModule.merkleizeMetadata; -} +import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; export class TransactionMetadataBlobService extends AbstractService { /** @@ -105,8 +87,6 @@ export class TransactionMetadataBlobService extends AbstractService { const base58Prefix = ss58Format?.toNumber() ?? 42; - // Use dynamic import for ESM module compatibility - const merkleizeMetadata = await getMerkleizeMetadata(); const merkleized = merkleizeMetadata(metadataV15Raw, { decimals, tokenSymbol, diff --git a/yarn.lock b/yarn.lock index a872534e6..6821ab316 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1464,7 +1464,7 @@ __metadata: languageName: node linkType: hard -"@polkadot-api/merkleize-metadata@npm:^1.1.29": +"@polkadot-api/merkleize-metadata@npm:^1.1.22": version: 1.1.29 resolution: "@polkadot-api/merkleize-metadata@npm:1.1.29" dependencies: @@ -2498,7 +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/merkleize-metadata": "npm:^1.1.22" "@polkadot/api": "npm:16.5.2" "@polkadot/api-augment": "npm:16.5.2" "@polkadot/api-contract": "npm:16.5.2" From a7a6904535f0a633f2274abdd6770d044305363a Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 17:24:41 -0300 Subject: [PATCH 10/15] downgrade again --- package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e3c57a2b2..6eccb4d7b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:test-release": "yarn build:scripts && node scripts/build/runYarnPack.js" }, "dependencies": { - "@polkadot-api/merkleize-metadata": "^1.1.22", + "@polkadot-api/merkleize-metadata": "^1.1.21", "@polkadot/api": "16.5.2", "@polkadot/api-augment": "16.5.2", "@polkadot/api-contract": "16.5.2", diff --git a/yarn.lock b/yarn.lock index 6821ab316..6c901c523 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1464,7 +1464,7 @@ __metadata: languageName: node linkType: hard -"@polkadot-api/merkleize-metadata@npm:^1.1.22": +"@polkadot-api/merkleize-metadata@npm:^1.1.21": version: 1.1.29 resolution: "@polkadot-api/merkleize-metadata@npm:1.1.29" dependencies: @@ -2498,7 +2498,7 @@ __metadata: resolution: "@substrate/api-sidecar@workspace:." dependencies: "@acala-network/chopsticks-testing": "npm:^1.2.5" - "@polkadot-api/merkleize-metadata": "npm:^1.1.22" + "@polkadot-api/merkleize-metadata": "npm:^1.1.21" "@polkadot/api": "npm:16.5.2" "@polkadot/api-augment": "npm:16.5.2" "@polkadot/api-contract": "npm:16.5.2" From b153eb8a8b4c7353b9054dbe0880bb49a27677bd Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 17:26:52 -0300 Subject: [PATCH 11/15] linting --- src/services/transaction/TransactionMetadataBlobService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index 2e299bd5c..e4e8a442f 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -18,12 +18,12 @@ import { ApiPromise } from '@polkadot/api'; import { ApiDecoration } from '@polkadot/api/types'; import type { BlockHash } from '@polkadot/types/interfaces'; import { u8aToHex } from '@polkadot/util'; +import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; import { BadRequest, InternalServerError } from 'http-errors'; import { ITransactionMetadataBlob } from 'src/types/responses'; import { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; import { AbstractService } from '../AbstractService'; -import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; export class TransactionMetadataBlobService extends AbstractService { /** From b971c85128434e985b3decfcc163aaa23ce4a308 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 17:34:09 -0300 Subject: [PATCH 12/15] add dynamic import --- package.json | 2 +- .../TransactionMetadataBlobService.ts | 22 ++++++++++++++++++- yarn.lock | 4 ++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6eccb4d7b..672a4831a 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:test-release": "yarn build:scripts && node scripts/build/runYarnPack.js" }, "dependencies": { - "@polkadot-api/merkleize-metadata": "^1.1.21", + "@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/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index e4e8a442f..833c911a3 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -18,13 +18,31 @@ import { ApiPromise } from '@polkadot/api'; import { ApiDecoration } from '@polkadot/api/types'; import type { BlockHash } from '@polkadot/types/interfaces'; import { u8aToHex } from '@polkadot/util'; -import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; import { BadRequest, InternalServerError } from 'http-errors'; import { ITransactionMetadataBlob } from 'src/types/responses'; import { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; import { AbstractService } from '../AbstractService'; +// 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 getMerkleizeMetadata(): 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 { /** * Fetch metadata blob (proof) for a given transaction. @@ -87,6 +105,8 @@ export class TransactionMetadataBlobService extends AbstractService { const base58Prefix = ss58Format?.toNumber() ?? 42; + // Use dynamic import for ESM module compatibility + const merkleizeMetadata = await getMerkleizeMetadata(); const merkleized = merkleizeMetadata(metadataV15Raw, { decimals, tokenSymbol, diff --git a/yarn.lock b/yarn.lock index 6c901c523..a872534e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1464,7 +1464,7 @@ __metadata: languageName: node linkType: hard -"@polkadot-api/merkleize-metadata@npm:^1.1.21": +"@polkadot-api/merkleize-metadata@npm:^1.1.29": version: 1.1.29 resolution: "@polkadot-api/merkleize-metadata@npm:1.1.29" dependencies: @@ -2498,7 +2498,7 @@ __metadata: resolution: "@substrate/api-sidecar@workspace:." dependencies: "@acala-network/chopsticks-testing": "npm:^1.2.5" - "@polkadot-api/merkleize-metadata": "npm:^1.1.21" + "@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" From a3a7a8521c94585176b6fb894284c70eca63ab0c Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 17:47:14 -0300 Subject: [PATCH 13/15] test --- .../TransactionMetadataBlobService.spec.ts | 330 ++++++++++++++++++ .../TransactionMetadataBlobService.ts | 10 +- 2 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/services/transaction/TransactionMetadataBlobService.spec.ts diff --git a/src/services/transaction/TransactionMetadataBlobService.spec.ts b/src/services/transaction/TransactionMetadataBlobService.spec.ts new file mode 100644 index 000000000..6260a633b --- /dev/null +++ b/src/services/transaction/TransactionMetadataBlobService.spec.ts @@ -0,0 +1,330 @@ +// 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 index 833c911a3..b2487588f 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -32,7 +32,7 @@ const dynamicImport = new Function('specifier', 'return import(specifier)') as < // Cached module after first load let merkleizeMetadataModule: typeof import('@polkadot-api/merkleize-metadata') | null = null; -async function getMerkleizeMetadata(): Promise< +async function loadMerkleizeMetadata(): Promise< (typeof import('@polkadot-api/merkleize-metadata'))['merkleizeMetadata'] > { if (!merkleizeMetadataModule) { @@ -44,6 +44,12 @@ async function getMerkleizeMetadata(): Promise< } 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 @@ -106,7 +112,7 @@ export class TransactionMetadataBlobService extends AbstractService { const base58Prefix = ss58Format?.toNumber() ?? 42; // Use dynamic import for ESM module compatibility - const merkleizeMetadata = await getMerkleizeMetadata(); + const merkleizeMetadata = await this.getMerkleizeMetadata(); const merkleized = merkleizeMetadata(metadataV15Raw, { decimals, tokenSymbol, From 4d3e187d851b4e9fd7bf7f7e88c16d79b0278b4d Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 17:54:19 -0300 Subject: [PATCH 14/15] linting --- .../TransactionMetadataBlobService.spec.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/services/transaction/TransactionMetadataBlobService.spec.ts b/src/services/transaction/TransactionMetadataBlobService.spec.ts index 6260a633b..deb1e4386 100644 --- a/src/services/transaction/TransactionMetadataBlobService.spec.ts +++ b/src/services/transaction/TransactionMetadataBlobService.spec.ts @@ -25,12 +25,14 @@ 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 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])); From 3cb2227e87d5c85d60357e433942896843e112b5 Mon Sep 17 00:00:00 2001 From: Alberto Nicolas Penayo Date: Fri, 30 Jan 2026 19:15:01 -0300 Subject: [PATCH 15/15] address comments --- .../TransactionMetadataBlobController.ts | 26 ------------------- .../TransactionMetadataBlobService.ts | 16 +++++++----- src/types/requests.ts | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/controllers/transaction/TransactionMetadataBlobController.ts b/src/controllers/transaction/TransactionMetadataBlobController.ts index 242649a6a..f768fc132 100644 --- a/src/controllers/transaction/TransactionMetadataBlobController.ts +++ b/src/controllers/transaction/TransactionMetadataBlobController.ts @@ -18,32 +18,6 @@ import { TransactionMetadataBlobService } from '../../services'; import { IMetadataBlobBody, IPostRequestHandler } from '../../types/requests'; import AbstractController from '../AbstractController'; -/** - * 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; -} - /** * POST metadata blob for a transaction. * diff --git a/src/services/transaction/TransactionMetadataBlobService.ts b/src/services/transaction/TransactionMetadataBlobService.ts index b2487588f..af376e975 100644 --- a/src/services/transaction/TransactionMetadataBlobService.ts +++ b/src/services/transaction/TransactionMetadataBlobService.ts @@ -19,11 +19,15 @@ 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 { MetadataBlobParams } from '../../controllers/transaction/TransactionMetadataBlobController'; 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 @@ -89,27 +93,27 @@ export class TransactionMetadataBlobService extends AbstractService { const tokenSymbolRaw = properties.tokenSymbol.isSome ? properties.tokenSymbol.value : null; const ss58Format = properties.ss58Format.isSome ? properties.ss58Format.value : null; - let decimals = 10; + let decimals = DEFAULT_DECIMALS; if (tokenDecimalsRaw) { const decimalsJson = tokenDecimalsRaw.toJSON(); if (Array.isArray(decimalsJson)) { - decimals = (decimalsJson[0] as number) ?? 10; + decimals = (decimalsJson[0] as number) ?? DEFAULT_DECIMALS; } else if (typeof decimalsJson === 'number') { decimals = decimalsJson; } } - let tokenSymbol = 'DOT'; + let tokenSymbol = DEFAULT_TOKEN_SYMBOL; if (tokenSymbolRaw) { const symbolJson = tokenSymbolRaw.toJSON(); if (Array.isArray(symbolJson)) { - tokenSymbol = (symbolJson[0] as string) ?? 'DOT'; + tokenSymbol = (symbolJson[0] as string) ?? DEFAULT_TOKEN_SYMBOL; } else if (typeof symbolJson === 'string') { tokenSymbol = symbolJson; } } - const base58Prefix = ss58Format?.toNumber() ?? 42; + const base58Prefix = ss58Format?.toNumber() ?? DEFAULT_SS58_PREFIX; // Use dynamic import for ESM module compatibility const merkleizeMetadata = await this.getMerkleizeMetadata(); diff --git a/src/types/requests.ts b/src/types/requests.ts index ae04da6d0..154361c0a 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -60,6 +60,32 @@ export interface IMetadataBlobBody { 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. */