// Copyright 2017-2022 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 { extractAuthor } from '@polkadot/api-derive/type/util';
import { Compact, GenericCall, Struct, Vec } from '@polkadot/types';
import {
AccountId32,
Balance,
Block,
BlockHash,
BlockNumber,
DispatchInfo,
EventRecord,
Header,
} from '@polkadot/types/interfaces';
import { AnyJson, Codec, Registry } from '@polkadot/types/types';
import { ICompact, INumber } from '@polkadot/types-codec/types/interfaces';
import { u8aToHex } from '@polkadot/util';
import { blake2AsU8a } from '@polkadot/util-crypto';
import BN from 'bn.js';
import { BadRequest, InternalServerError } from 'http-errors';
import LRU from 'lru-cache';
import {
IBlock,
IExtrinsic,
IExtrinsicIndex,
ISanitizedCall,
ISanitizedEvent,
isFrameMethod,
} from '../../types/responses';
import { IOption } from '../../types/util';
import { isPaysFee } from '../../types/util';
import { subIntegers } from '../../util/integers/compare';
import { AbstractService } from '../AbstractService';
/**
* Types for fetchBlock's options
* @field eventDocs
* @field extrinsicDocs
* @field checkFinalized Option to reduce rpc calls. Equals true when blockId is a hash.
* @field queryFinalizedHead Option to reduce rpc calls. Equals true when finalized head has not been queried.
* @field omitFinalizedTag Option to omit the finalized tag, and return it as undefined.
*/
interface FetchBlockOptions {
eventDocs: boolean;
extrinsicDocs: boolean;
checkFinalized: boolean;
queryFinalizedHead: boolean;
omitFinalizedTag: boolean;
getFeeByEvent: boolean;
}
/**
* Event methods that we check for.
*/
enum Event {
success = 'ExtrinsicSuccess',
failure = 'ExtrinsicFailed',
withdraw = 'Withdraw',
deposit = 'Deposit',
}
export class BlocksService extends AbstractService {
constructor(
api: ApiPromise,
private minCalcFeeRuntime: IOption,
private blockStore: LRU
) {
super(api);
}
/**
* Fetch a block augmented with derived values.
*
* @param hash `BlockHash` of the block to fetch.
* @param FetchBlockOptions options for additonal information.
*/
async fetchBlock(
hash: BlockHash,
historicApi: ApiDecoration<'promise'>,
{
eventDocs,
extrinsicDocs,
checkFinalized,
queryFinalizedHead,
omitFinalizedTag,
getFeeByEvent,
}: FetchBlockOptions
): Promise {
const { api } = this;
// Before making any api calls check the cache if the queried block exists
const isBlockCached = this.blockStore.get(hash.toString());
if (isBlockCached) {
return isBlockCached;
}
const [
{ block },
{ specName, specVersion },
validators,
events,
finalizedHead,
] = await Promise.all([
api.rpc.chain.getBlock(hash),
api.rpc.state.getRuntimeVersion(hash),
this.fetchValidators(historicApi),
this.fetchEvents(historicApi),
queryFinalizedHead
? api.rpc.chain.getFinalizedHead()
: Promise.resolve(hash),
]);
if (block === undefined) {
throw new InternalServerError('Error querying for block');
}
const { parentHash, number, stateRoot, extrinsicsRoot, digest } =
block.header;
const authorId = extractAuthor(digest, validators);
const logs = digest.logs.map(({ type, index, value }) => {
return { type, index, value };
});
const nonSanitizedExtrinsics = this.extractExtrinsics(
block,
events,
historicApi.registry,
extrinsicDocs
);
const { extrinsics, onInitialize, onFinalize } = this.sanitizeEvents(
events,
nonSanitizedExtrinsics,
hash,
eventDocs
);
let finalized = undefined;
if (!omitFinalizedTag) {
// Check if the requested block is finalized
finalized = await this.isFinalizedBlock(
api,
number,
hash,
finalizedHead,
checkFinalized
);
}
// The genesis block is a special case with little information associated with it.
if (parentHash.every((byte) => !byte)) {
return {
number,
hash,
parentHash,
stateRoot,
extrinsicsRoot,
authorId,
logs,
onInitialize,
extrinsics,
onFinalize,
finalized,
};
}
for (let idx = 0; idx < block.extrinsics.length; ++idx) {
if (!extrinsics[idx].paysFee || !block.extrinsics[idx].isSigned) {
continue;
}
if (this.minCalcFeeRuntime === null) {
extrinsics[idx].info = {
error: `Fee calculation not supported for this network`,
};
continue;
}
if (this.minCalcFeeRuntime > specVersion.toNumber()) {
extrinsics[idx].info = {
error: `Fee calculation not supported for ${specVersion.toString()}#${specName.toString()}`,
};
continue;
}
const xtEvents = extrinsics[idx].events;
const completedEvent = xtEvents.find(
({ method }) =>
isFrameMethod(method) &&
(method.method === Event.success || method.method === Event.failure)
);
if (!completedEvent) {
extrinsics[idx].info = {
error: 'Unable to find success or failure event for extrinsic',
};
continue;
}
const completedData = completedEvent.data;
if (!completedData) {
extrinsics[idx].info = {
error:
'Success or failure event for extrinsic does not contain expected data',
};
continue;
}
// both ExtrinsicSuccess and ExtrinsicFailed events have DispatchInfo
// types as their final arg
const weightInfo = completedData[
completedData.length - 1
] as DispatchInfo;
if (!weightInfo.weight) {
extrinsics[idx].info = {
error:
'Success or failure event for extrinsic does not specify weight',
};
continue;
}
if (!api.rpc.payment || !api.rpc.payment.queryInfo) {
extrinsics[idx].info = {
error: 'Rpc method payment::queryInfo is not available',
};
continue;
}
const { dispatchClass, partialFee, error } = await this.getPartialFeeInfo(
extrinsics[idx].events,
block.extrinsics[idx].toHex(),
hash,
getFeeByEvent,
extrinsics[idx].tip
);
if (error) {
extrinsics[idx].info = {
error,
};
continue;
}
extrinsics[idx].info = api.createType('RuntimeDispatchInfo', {
weight: weightInfo.weight,
class: dispatchClass,
partialFee: partialFee,
});
}
const response = {
number,
hash,
parentHash,
stateRoot,
extrinsicsRoot,
authorId,
logs,
onInitialize,
extrinsics,
onFinalize,
finalized,
};
// Store the block in the cache
this.blockStore.set(hash.toString(), response);
return response;
}
/**
* Return the header of a block
*
* @param hash When no hash is inputted the header of the chain will be queried.
*/
async fetchBlockHeader(hash?: BlockHash): Promise {
const { api } = this;
const header = hash
? await api.rpc.chain.getHeader(hash)
: await api.rpc.chain.getHeader();
return header;
}
/**
*
* @param block Takes in a block which is the result of `BlocksService.fetchBlock`
* @param extrinsicIndex Parameter passed into the request
*/
fetchExtrinsicByIndex(
block: IBlock,
extrinsicIndex: number
): IExtrinsicIndex {
if (extrinsicIndex > block.extrinsics.length - 1) {
throw new BadRequest('Requested `extrinsicIndex` does not exist');
}
const { hash, number } = block;
const height = number.unwrap().toString(10);
return {
at: {
height,
hash,
},
extrinsics: block.extrinsics[extrinsicIndex],
};
}
/**
* Extract extrinsics from a block.
*
* @param block Block
* @param events events fetched by `fetchEvents`
* @param regsitry The corresponding blocks runtime registry
* @param extrinsicDocs To include the extrinsic docs or not
*/
private extractExtrinsics(
block: Block,
events: Vec | string,
registry: Registry,
extrinsicDocs: boolean
): IExtrinsic[] {
const defaultSuccess = typeof events === 'string' ? events : false;
return block.extrinsics.map((extrinsic) => {
const { method, nonce, signature, signer, isSigned, tip, era } =
extrinsic;
const hash = u8aToHex(blake2AsU8a(extrinsic.toU8a(), 256));
const call = registry.createType('Call', method);
return {
method: {
pallet: method.section,
method: method.method,
},
signature: isSigned ? { signature, signer } : null,
nonce: isSigned ? nonce : null,
args: this.parseGenericCall(call, registry).args,
tip: isSigned ? tip : null,
hash,
info: {},
era,
events: [] as ISanitizedEvent[],
success: defaultSuccess,
// paysFee overrides to bool if `system.ExtrinsicSuccess|ExtrinsicFailed` event is present
// we set to false if !isSigned because unsigned never pays a fee
paysFee: isSigned ? null : false,
docs: extrinsicDocs
? this.sanitizeDocs(extrinsic.meta.docs)
: undefined,
};
});
}
/**
* Sanitize events and attribute them to an extrinsic, onInitialize, or
* onFinalize.
*
* @param events events from `fetchEvents`
* @param extrinsics extrinsics from
* @param hash hash of the block the events are from
*/
private sanitizeEvents(
events: EventRecord[] | string,
extrinsics: IExtrinsic[],
hash: BlockHash,
eventDocs: boolean
) {
const onInitialize = { events: [] as ISanitizedEvent[] };
const onFinalize = { events: [] as ISanitizedEvent[] };
if (Array.isArray(events)) {
for (const record of events) {
const { event, phase } = record;
const sanitizedEvent = {
method: {
pallet: event.section,
method: event.method,
},
data: event.data,
docs: eventDocs ? this.sanitizeDocs(event.data.meta.docs) : undefined,
};
if (phase.isApplyExtrinsic) {
const extrinsicIdx = phase.asApplyExtrinsic.toNumber();
const extrinsic = extrinsics[extrinsicIdx];
if (!extrinsic) {
throw new Error(
`Missing extrinsic ${extrinsicIdx} in block ${hash.toString()}`
);
}
if (event.method === Event.success) {
extrinsic.success = true;
}
if (
event.method === Event.success ||
event.method === Event.failure
) {
const sanitizedData = event.data.toJSON() as AnyJson[];
for (const data of sanitizedData) {
if (extrinsic.signature && isPaysFee(data)) {
extrinsic.paysFee =
data.paysFee === true || data.paysFee === 'Yes';
break;
}
}
}
extrinsic.events.push(sanitizedEvent);
} else if (phase.isFinalization) {
onFinalize.events.push(sanitizedEvent);
} else if (phase.isInitialization) {
onInitialize.events.push(sanitizedEvent);
}
}
}
return {
extrinsics,
onInitialize,
onFinalize,
};
}
/**
* Fetch events for the specified block.
*
* @param historicApi ApiDecoration to use for the query
*/
private async fetchEvents(
historicApi: ApiDecoration<'promise'>
): Promise | string> {
try {
return await historicApi.query.system.events();
} catch {
return 'Unable to fetch Events, cannot confirm extrinsic status. Check pruning settings on the node.';
}
}
/**
* This will check whether we should query the fee by `payment::queryInfo`
* or by an extrinsics events.
*
* @param events The events to search through for a partialFee
* @param extrinsicHex Hex of the given extrinsic
* @param hash Blockhash we are querying
* @param getFeeByEvent `FeeByEvent` query parameter
*/
private async getPartialFeeInfo(
events: ISanitizedEvent[],
extrinsicHex: string,
hash: BlockHash,
getFeeByEvent: boolean,
tip: ICompact | null
) {
const { api } = this;
const { class: dispatchClass, partialFee } =
await api.rpc.payment.queryInfo(extrinsicHex, hash);
/**
* Check if we should retrieve the partial_fee from the Events
*/
let fee: Balance | string = partialFee;
let error: string | undefined;
if (getFeeByEvent) {
const feeInfo = this.getPartialFeeByEvents(events, partialFee, tip);
fee = feeInfo.partialFee;
error = feeInfo.error;
}
return {
partialFee: fee,
dispatchClass,
error,
};
}
/**
* This searches through an extrinsics given events to see if there is a partialFee
* within the data. If the estimated partialFee is within a given difference of the
* found fee within the data than we return that result.
*
* The order of the events we search through are:
* 1.Balances::Event::Withdraw
* 2.Treasury::Event::Deposit
* 3.Balances::Event::Deposit
*
* @param events The events to search through for a partialFee
* @param partialFee Estimated partialFee given by `payment::queryInfo`
*/
private getPartialFeeByEvents(
events: ISanitizedEvent[],
partialFee: Balance,
tip: ICompact | null
): { partialFee: string; error?: string } {
// Check Event:Withdraw event for the balances pallet
const withdrawEvent = this.findEvent(events, 'balances', Event.withdraw);
if (withdrawEvent.length > 0 && withdrawEvent[0].data) {
const dataArr = withdrawEvent[0].data.toJSON();
if (Array.isArray(dataArr)) {
const fee = (dataArr as Array)[dataArr.length - 1];
const adjustedPartialFee = tip
? tip.toBn().add(partialFee)
: partialFee;
// The difference between values is 00.00001% or less so they are alike.
if (this.areFeesSimilar(new BN(fee), adjustedPartialFee)) {
return {
partialFee: fee.toString(),
};
}
}
}
// Check the Event::Deposit for the treasury pallet
const treasuryEvent = this.findEvent(events, 'treasury', Event.deposit);
if (treasuryEvent.length > 0 && treasuryEvent[0].data) {
const dataArr = treasuryEvent[0].data.toJSON();
if (Array.isArray(dataArr)) {
const fee = (dataArr as Array)[0];
const adjustedPartialFee = tip
? tip.toBn().add(partialFee)
: partialFee;
// The difference between values is 00.00001% or less so they are alike.
if (this.areFeesSimilar(new BN(fee), adjustedPartialFee)) {
return {
partialFee: fee.toString(),
};
}
}
}
// Check Event::Deposit events for the balances pallet.
const depositEvents = this.findEvent(events, 'balances', Event.deposit);
if (depositEvents.length > 0) {
let sumOfFees = new BN(0);
depositEvents.forEach(
({ data }) =>
(sumOfFees = sumOfFees.add(new BN(data[data.length - 1].toString())))
);
const adjustedPartialFee = tip ? tip.toBn().add(partialFee) : partialFee;
// The difference between values is 00.00001% or less so they are alike.
if (this.areFeesSimilar(sumOfFees, adjustedPartialFee)) {
return {
partialFee: sumOfFees.toString(),
};
}
}
return {
partialFee: partialFee.toString(),
error: 'Could not find a reliable fee within the events data.',
};
}
/**
* Find the corresponding events relevant to the passed in pallet, and method name.
*
* @param events The events to search through for a partialFee
* @param palletName Pallet to search for
* @param methodName Method to search for
*/
private findEvent(
events: ISanitizedEvent[],
palletName: string,
methodName: string
): ISanitizedEvent[] {
return events.filter(
({ method, data }) =>
isFrameMethod(method) &&
method.method === methodName &&
method.pallet === palletName &&
data
);
}
/**
* Checks to see if the value in an event is within 00.00001% accuracy of
* the queried `partialFee` from `rpc::payment::queryInfo`.
*
* @param eventBalance Balance returned in the data of an event
* @param partialFee Fee queried from `rpc::payment::queryInfo`
* @param diff difference between the
*/
private areFeesSimilar(eventBalance: BN, partialFee: BN): boolean {
const diff = subIntegers(eventBalance, partialFee);
return (
eventBalance.toString().length - diff.toString().length > 5 &&
eventBalance.toString().length === partialFee.toString().length
);
}
/**
* Checks to see if the current chain has the session module, then retrieve all
* validators.
*
* @param historicApi ApiDecoration to use for the query
*/
private async fetchValidators(
historicApi: ApiDecoration<'promise'>
): Promise> {
return historicApi.query.session
? await historicApi.query.session.validators()
: ([] as unknown as Vec);
}
/**
* Helper function for `parseGenericCall`.
*
* @param argsArray array of `Codec` values
* @param registry type registry of the block the call belongs to
*/
private parseArrayGenericCalls(
argsArray: Codec[],
registry: Registry
): (Codec | ISanitizedCall)[] {
return argsArray.map((argument) => {
if (argument instanceof GenericCall) {
return this.parseGenericCall(argument as GenericCall, registry);
}
return argument;
});
}
/**
* Recursively parse a `GenericCall` in order to label its arguments with
* their param names and give a human friendly method name (opposed to just a
* call index). Parses `GenericCall`s that are nested as arguments.
*
* @param genericCall `GenericCall`
* @param registry type registry of the block the call belongs to
*/
private parseGenericCall(
genericCall: GenericCall,
registry: Registry
): ISanitizedCall {
const newArgs = {};
// Pull out the struct of arguments to this call
const callArgs = genericCall.get('args') as Struct;
// Make sure callArgs exists and we can access its keys
if (callArgs && callArgs.defKeys) {
// paramName is a string
for (const paramName of callArgs.defKeys) {
const argument = callArgs.get(paramName);
if (Array.isArray(argument)) {
newArgs[paramName] = this.parseArrayGenericCalls(argument, registry);
} else if (argument instanceof GenericCall) {
newArgs[paramName] = this.parseGenericCall(
argument as GenericCall,
registry
);
} else if (
argument &&
paramName === 'call' &&
['Bytes', 'WrapperKeepOpaque', 'WrapperOpaque'].includes(
argument?.toRawType()
)
) {
// multiSig.asMulti.args.call is either an OpaqueCall (Vec),
// WrapperKeepOpaque, or WrapperOpaque that we
// serialize to a polkadot-js Call and parse so it is not a hex blob.
try {
const call = registry.createType('Call', argument.toHex());
newArgs[paramName] = this.parseGenericCall(call, registry);
} catch {
newArgs[paramName] = argument;
}
} else {
newArgs[paramName] = argument;
}
}
}
return {
method: {
pallet: genericCall.section,
method: genericCall.method,
},
args: newArgs,
};
}
/**
* When querying a block this will immediately inform the request whether
* or not the queried block is considered finalized at the time of querying.
*
* @param api ApiPromise to use for query
* @param blockNumber Queried block number
* @param queriedHash Hash of user queried block
* @param finalizedHead Finalized head for our chain
* @param checkFinalized If the passed in blockId is a hash
*/
private async isFinalizedBlock(
api: ApiPromise,
blockNumber: Compact,
queriedHash: BlockHash,
finalizedHead: BlockHash,
checkFinalized: boolean
): Promise {
if (checkFinalized) {
// The blockId url param is a hash
const [finalizedHeadBlock, canonHash] = await Promise.all([
// Returns the header of the most recently finalized block
api.rpc.chain.getHeader(finalizedHead),
// Fetch the hash of the block with equal height on the canon chain.
// N.B. We assume when we query by number <= finalized head height,
// we will always get a block on the finalized, canonical chain.
api.rpc.chain.getBlockHash(blockNumber.unwrap()),
]);
// If queried by hash this is the original request param
const hash = queriedHash.toHex();
// If this conditional is satisfied, the queried hash is on a fork,
// and is not on the canonical chain and therefore not finalized
if (canonHash.toHex() !== hash) {
return false;
}
// Retreive the finalized head blockNumber
const finalizedHeadBlockNumber = finalizedHeadBlock?.number;
// If the finalized head blockNumber is undefined return false
if (!finalizedHeadBlockNumber) {
return false;
}
// Check if the user's block is less than or equal to the finalized head.
// If so, the user's block is finalized.
return blockNumber.unwrap().lte(finalizedHeadBlockNumber.unwrap());
} else {
// The blockId url param is an integer
// Returns the header of the most recently finalized block
const finalizedHeadBlock = await api.rpc.chain.getHeader(finalizedHead);
// Retreive the finalized head blockNumber
const finalizedHeadBlockNumber = finalizedHeadBlock?.number;
// If the finalized head blockNumber is undefined return false
if (!finalizedHeadBlockNumber) {
return false;
}
// Check if the user's block is less than or equal to the finalized head.
// If so, the user's block is finalized.
return blockNumber.unwrap().lte(finalizedHeadBlockNumber.unwrap());
}
}
}