diff --git a/CHANGELOG.md b/CHANGELOG.md index 312a9892e3..e819e7ae4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 1.9.0-beta.x +- Tx signing via `signAndSend` & `signAsync` now tries to use the last finalized block (depending on finalized drift) - Adjust `Error.message` on RPC failures to not include the (already known) method signature - Additional cleanups and rework of democracy derives, including support for new Polkadot/Substrate vote retrievals - Internal adjustment of interface types to align with modules (no external impact via `@polkadot/types/interfaces`) diff --git a/packages/api-derive/src/index.ts b/packages/api-derive/src/index.ts index f4e49ad2a0..7fd29b5c26 100644 --- a/packages/api-derive/src/index.ts +++ b/packages/api-derive/src/index.ts @@ -21,10 +21,11 @@ import * as society from './society'; import * as staking from './staking'; import * as technicalCommittee from './technicalCommittee'; import * as treasury from './treasury'; +import * as tx from './tx'; export * from './type'; -export const derive = { accounts, balances, chain, contracts, council, democracy, elections, imOnline, parachains, session, society, staking, technicalCommittee, treasury }; +export const derive = { accounts, balances, chain, contracts, council, democracy, elections, imOnline, parachains, session, society, staking, technicalCommittee, treasury, tx }; type DeriveSection
= { [Method in keyof Section]: Section[Method] extends AnyFunction diff --git a/packages/api-derive/src/tx/constants.ts b/packages/api-derive/src/tx/constants.ts new file mode 100644 index 0000000000..4ce4a9e3f8 --- /dev/null +++ b/packages/api-derive/src/tx/constants.ts @@ -0,0 +1,7 @@ +// Copyright 2017-2020 @polkadot/api-derive authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import BN from 'bn.js'; + +export const MAX_FINALITY_LAG = new BN(5); diff --git a/packages/api-derive/src/tx/events.ts b/packages/api-derive/src/tx/events.ts new file mode 100644 index 0000000000..d288e94e98 --- /dev/null +++ b/packages/api-derive/src/tx/events.ts @@ -0,0 +1,30 @@ +// Copyright 2017-2020 @polkadot/api-derive authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { EventRecord, Hash, SignedBlock } from '@polkadot/types/interfaces'; + +import { Observable, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiInterfaceRx } from '@polkadot/api/types'; + +import { memo } from '../util'; + +interface Result { + block: SignedBlock; + events: EventRecord[]; +} + +export function events (api: ApiInterfaceRx): (at: Hash) => Observable { + return memo((at: Hash): Observable => + combineLatest([ + api.query.system.events.at(at), + api.rpc.chain.getBlock(at) + ]).pipe( + map(([events, block]) => ({ + block, + events + })) + ) + ); +} diff --git a/packages/api-derive/src/tx/index.ts b/packages/api-derive/src/tx/index.ts new file mode 100644 index 0000000000..80220a757b --- /dev/null +++ b/packages/api-derive/src/tx/index.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2020 @polkadot/api-derive authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +export * from './events'; +export * from './signingInfo'; diff --git a/packages/api-derive/src/tx/signingInfo.ts b/packages/api-derive/src/tx/signingInfo.ts new file mode 100644 index 0000000000..40a36633ef --- /dev/null +++ b/packages/api-derive/src/tx/signingInfo.ts @@ -0,0 +1,69 @@ +// Copyright 2017-2020 @polkadot/api-derive authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Header, Index } from '@polkadot/types/interfaces'; +import { AnyNumber, Codec, IExtrinsicEra } from '@polkadot/types/types'; + +import BN from 'bn.js'; +import { Observable, combineLatest, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { ApiInterfaceRx } from '@polkadot/api/types'; +import { isNumber, isUndefined } from '@polkadot/util'; + +interface Result { + header: Header | null; + mortalLength: number; + nonce: Index; +} + +// default here to 5 min eras, adjusted based on the actual blocktime +const FALLBACK_PERIOD = new BN(6 * 1000); +const MAX_FINALITY_LAG = new BN(5); +const MORTAL_PERIOD = new BN(5 * 60 * 1000); + +function latestNonce (api: ApiInterfaceRx, address: string): Observable { + return api.derive.balances.account(address).pipe( + map(({ accountNonce }): Index => accountNonce) + ); +} + +function signingHeader (api: ApiInterfaceRx): Observable
{ + return combineLatest([ + api.rpc.chain.getHeader(), + api.rpc.chain.getFinalizedHead().pipe( + switchMap((hash) => api.rpc.chain.getHeader(hash)) + ) + ]).pipe( + map(([current, finalized]): Header => + // determine the hash to use, current when lag > max, else finalized + current.number.unwrap().sub(finalized.number.unwrap()).gt(MAX_FINALITY_LAG) + ? current + : finalized + ) + ); +} + +export function signingInfo (api: ApiInterfaceRx): (address: string, nonce?: AnyNumber | Codec, era?: IExtrinsicEra | number) => Observable { + // no memo, we want to do this fresh on each run + return (address: string, nonce?: AnyNumber | Codec, era?: IExtrinsicEra | number): Observable => + combineLatest([ + // retrieve nonce if none was specified + isUndefined(nonce) + ? latestNonce(api, address) + : of(api.registry.createType('Index', nonce)), + // if no era (create) or era > 0 (mortal), do block retrieval + (isUndefined(era) || (isNumber(era) && era > 0)) + ? signingHeader(api) + : of(null) + ]).pipe( + map(([nonce, header]) => ({ + header, + nonce, + mortalLength: MORTAL_PERIOD + .div(api.consts.babe?.expectedBlockTime || api.consts.timestamp?.minimumPeriod.muln(2) || FALLBACK_PERIOD) + .add(MAX_FINALITY_LAG) + .toNumber() + })) + ); +} diff --git a/packages/api/src/promise/Api.spec.ts b/packages/api/src/promise/Api.spec.ts index a6f8a407cd..2a92b1f210 100644 --- a/packages/api/src/promise/Api.spec.ts +++ b/packages/api/src/promise/Api.spec.ts @@ -13,7 +13,7 @@ import { hexToU8a } from '@polkadot/util'; import { SingleAccountSigner } from '../../test/util'; import ApiPromise from './Api'; -const TRANSFER_SIG = '0x62bfcd07a9f50ba32ed9e2c41ca11267ac62a24c95949e25efdccc4989d48573f84754dbbaa1d0937ceb888ad8a2cf71daced1545a839d9d6cceac33a5771401'; +const TRANSFER_SIG = '0xa1d493ab6b74550da434fbc153a739429be68c9d30e59e631fb5d08f348504deefb5577f4824e138741ad102f479109827e8fa9552812a3e5f505a24d10e820f'; describe('ApiPromise', (): void => { const registry = new TypeRegistry(); diff --git a/packages/api/src/submittable/createClass.ts b/packages/api/src/submittable/createClass.ts index e0ef94c32d..b21b8b447b 100644 --- a/packages/api/src/submittable/createClass.ts +++ b/packages/api/src/submittable/createClass.ts @@ -8,9 +8,9 @@ import { Callback, Codec, Constructor, IKeyringPair, Registry, SignatureOptions, import { ApiInterfaceRx, ApiTypes, SignerResult } from '../types'; import { AddressOrPair, SignerOptions, SubmittableExtrinsic, SubmittablePaymentResult, SubmittableResultResult, SubmittableResultSubscription, SubmittableThis } from './types'; -import { Observable, combineLatest, of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { first, map, mapTo, mergeMap, switchMap, tap } from 'rxjs/operators'; -import { assert, isBn, isFunction, isNumber, isUndefined } from '@polkadot/util'; +import { assert, isBn, isFunction, isNumber } from '@polkadot/util'; import { filterEvents, isKeyringPair } from '../util'; import ApiBase from '../base'; @@ -22,13 +22,6 @@ interface SubmittableOptions { decorateMethod: ApiBase['decorateMethod']; } -// The default for 6s allowing for 5min eras. When translating this to faster blocks - -// - 4s = (10 / 15) * 5 = 3.33m -// - 2s = (10 / 30) * 5 = 1.66m -const BLOCKTIME = 6; -const ONE_MINUTE = 60 / BLOCKTIME; -const DEFAULT_MORTAL_LENGTH = 5 * ONE_MINUTE; - export default function createClass ({ api, apiType, decorateMethod }: SubmittableOptions): Constructor> { // an instance of the base extrinsic for us to extend const ExtrinsicBase = api.registry.createClass('Extrinsic'); @@ -49,11 +42,11 @@ export default function createClass ({ api, apiType, return decorateMethod( (): Observable => - this.#getPrelimState(address, allOptions).pipe( + api.derive.tx.signingInfo(address, allOptions.nonce, allOptions.era).pipe( first(), - switchMap(([nonce, header]): Observable => { + switchMap((signingInfo): Observable => { // setup our options (same way as in signAndSend) - const eraOptions = this.#makeEraOptions(allOptions, { header, nonce }); + const eraOptions = this.#makeEraOptions(allOptions, signingInfo); const signOptions = this.#makeSignOptions(eraOptions, {}); // add a fake signature to the extrinsic @@ -125,23 +118,7 @@ export default function createClass ({ api, apiType, )(statusCb); } - #getPrelimState = (address: string, options: Partial): Observable<[Index, Header | null]> => { - return combineLatest([ - // if we have a nonce already, don't retrieve the latest, use what is there - isUndefined(options.nonce) - ? api.derive.balances.account(address).pipe( - map(({ accountNonce }): Index => accountNonce) - ) - : of(this.registry.createType('Index', options.nonce)), - // if we have an era provided already or eraLength is <= 0 (immortal) - // don't get the latest block, just pass null, handle in mergeMap - (isUndefined(options.era) || (isNumber(options.era) && options.era > 0)) - ? api.rpc.chain.getHeader() - : of(null) - ]); - } - - #makeEraOptions = (options: Partial, { header, nonce }: { header: Header | null; nonce: Index }): SignatureOptions => { + #makeEraOptions = (options: Partial, { header, mortalLength, nonce }: { header: Header | null; mortalLength: number; nonce: Index }): SignatureOptions => { if (!header) { if (isNumber(options.era)) { // since we have no header, it is immortal, remove any option overrides @@ -157,7 +134,7 @@ export default function createClass ({ api, apiType, blockHash: header.hash, era: this.registry.createType('ExtrinsicEra', { current: header.number, - period: options.era || DEFAULT_MORTAL_LENGTH + period: options.era || mortalLength }), nonce }); @@ -191,15 +168,15 @@ export default function createClass ({ api, apiType, const options = this.#optionsOrNonce(optionsOrNonce); let updateId: number | undefined; - return this.#getPrelimState(address, options).pipe( + return api.derive.tx.signingInfo(address, options.nonce, options.era).pipe( first(), - mergeMap(async ([nonce, header]): Promise => { - const eraOptions = this.#makeEraOptions(options, { header, nonce }); + mergeMap(async (signingInfo): Promise => { + const eraOptions = this.#makeEraOptions(options, signingInfo); if (isKeyringPair(account)) { this.sign(account, eraOptions); } else { - updateId = await this.#signViaSigner(address, eraOptions, header); + updateId = await this.#signViaSigner(address, eraOptions, signingInfo.header); } }), mapTo(updateId) @@ -215,13 +192,10 @@ export default function createClass ({ api, apiType, ? status.asInBlock : status.asFinalized; - return combineLatest([ - api.rpc.chain.getBlock(blockHash), - api.query.system.events.at(blockHash) - ]).pipe( - map(([signedBlock, allEvents]): ISubmittableResult => + return api.derive.tx.events(blockHash).pipe( + map(({ block, events }): ISubmittableResult => new SubmittableResult({ - events: filterEvents(this.hash, signedBlock, allEvents, status), + events: filterEvents(this.hash, block, events, status), status }) ) @@ -229,26 +203,22 @@ export default function createClass ({ api, apiType, } #observeSend = (updateId = -1): Observable => { - return api.rpc.author - .submitExtrinsic(this) - .pipe( - tap((hash): void => { - this.#updateSigner(updateId, hash); - }) - ); + return api.rpc.author.submitExtrinsic(this).pipe( + tap((hash): void => { + this.#updateSigner(updateId, hash); + }) + ); } #observeSubscribe = (updateId = -1): Observable => { - return api.rpc.author - .submitAndWatchExtrinsic(this) - .pipe( - switchMap((status): Observable => - this.#observeStatus(status) - ), - tap((status): void => { - this.#updateSigner(updateId, status); - }) - ); + return api.rpc.author.submitAndWatchExtrinsic(this).pipe( + switchMap((status): Observable => + this.#observeStatus(status) + ), + tap((status): void => { + this.#updateSigner(updateId, status); + }) + ); } // NOTE here we actually override nonce if it was specified (backwards compat for diff --git a/packages/rpc-provider/src/mock/index.ts b/packages/rpc-provider/src/mock/index.ts index a85f9c6287..78f7b840c8 100644 --- a/packages/rpc-provider/src/mock/index.ts +++ b/packages/rpc-provider/src/mock/index.ts @@ -54,7 +54,8 @@ export default class Mock implements ProviderInterface { chain_getBlock: (hash: string): any => this.registry.createType('SignedBlock', rpcSignedBlock.result).toJSON(), // eslint-disable-next-line @typescript-eslint/no-unused-vars chain_getBlockHash: (blockNumber: number): string => '0x1234', - chain_getHeader: (): any => this.registry.createType('Header', rpcHeader.result).toJSON(), + chain_getFinalizedHead: () => this.registry.createType('Header', rpcHeader.result).hash, + chain_getHeader: () => this.registry.createType('Header', rpcHeader.result).toJSON(), state_getKeys: (): string[] => [], state_getKeysPaged: (): string[] => [], state_getRuntimeVersion: (): string => this.registry.createType('RuntimeVersion').toHex(),