diff --git a/CHANGELOG.md b/CHANGELOG.md index 123c20cc53..bb9b8e5691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Changes: - Add missing historic `Heartbeat: HeartbeatTo244` mapping to Westend - Add `BlockNumberFor` type mapping - Add Westend 9080 upgrade block +- Add `derive.crowdloan.contributions(paraId)` - Allow building as a completely stand-alone browser bundle - Update to latest `@polkadot/x-rxjs` with RxJs 7.2 diff --git a/packages/api-derive/src/bundle.ts b/packages/api-derive/src/bundle.ts index 406fcfe0f7..b1d5012aa9 100644 --- a/packages/api-derive/src/bundle.ts +++ b/packages/api-derive/src/bundle.ts @@ -11,6 +11,7 @@ import * as bounties from './bounties'; import * as chain from './chain'; import * as contracts from './contracts'; import * as council from './council'; +import * as crowdloan from './crowdloan'; import * as democracy from './democracy'; import * as elections from './elections'; import * as imOnline from './imOnline'; @@ -31,7 +32,7 @@ interface Avail { withDetect?: boolean; } -export const derive = { accounts, balances, bounties, chain, contracts, council, democracy, elections, imOnline, membership, parachains, session, society, staking, technicalCommittee, treasury, tx }; +export const derive = { accounts, balances, bounties, chain, contracts, council, crowdloan, democracy, elections, imOnline, membership, parachains, session, society, staking, technicalCommittee, treasury, tx }; type DeriveSection
= { [Method in keyof Section]: Section[Method] extends AnyFunction @@ -42,14 +43,17 @@ type DeriveAllSections = { [Section in keyof AllSections]: DeriveSection }; -export type DeriveCustom = Record (...args: any[]) => Observable>>; +type DeriveCreator = (instanceId: string, api: ApiInterfaceRx) => (...args: unknown[]) => Observable; + +export type DeriveCustom = Record>; export type ExactDerive = DeriveAllSections; // Enable derive only if some of these modules are available -const deriveAvail: Record = { +const checks: Record = { contracts: { instances: ['contracts'] }, council: { instances: ['council'], withDetect: true }, + crowdloan: { instances: ['crowdloan'] }, democracy: { instances: ['democracy'] }, elections: { instances: ['phragmenElection', 'electionsPhragmen', 'elections', 'council'], withDetect: true }, imOnline: { instances: ['imOnline'] }, @@ -74,34 +78,28 @@ function injectFunctions (instanceId: string, api: ApiInterfaceRx, return Object .keys(allSections) .filter((sectionName) => - !deriveAvail[sectionName] || - deriveAvail[sectionName].instances.some((q) => queryKeys.includes(q)) || + !checks[sectionName] || + checks[sectionName].instances.some((q) => queryKeys.includes(q)) || ( - deriveAvail[sectionName].withDetect && - deriveAvail[sectionName].instances.some((q) => + checks[sectionName].withDetect && + checks[sectionName].instances.some((q) => (api.registry.getModuleInstances(specName, q) || []).some((q) => queryKeys.includes(q)) ) ) ) - .reduce((deriveAcc, sectionName): DeriveAllSections => { + .reduce((derives, sectionName): DeriveAllSections => { const section = allSections[sectionName as keyof AllSections]; - deriveAcc[sectionName as keyof AllSections] = Object - .keys(section) - .reduce((sectionAcc, _methodName): DeriveSection => { - const methodName = _methodName as keyof typeof section; - // Not sure what to do here, casting as any. Though the final types are good + derives[sectionName as keyof AllSections] = Object + .entries(section) + .reduce((methods, [methodName, creator]): DeriveSection => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call - const method = (section[methodName] as any)(instanceId, api); + methods[methodName as keyof typeof section] = creator(instanceId, api); - // idem - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access - (sectionAcc as any)[methodName] = method; - - return sectionAcc; + return methods; }, {} as DeriveSection); - return deriveAcc; + return derives; }, {} as DeriveAllSections); } diff --git a/packages/api-derive/src/crowdloan/childKey.ts b/packages/api-derive/src/crowdloan/childKey.ts new file mode 100644 index 0000000000..88754f91e7 --- /dev/null +++ b/packages/api-derive/src/crowdloan/childKey.ts @@ -0,0 +1,37 @@ +// Copyright 2017-2021 @polkadot/api-derive authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Observable } from 'rxjs'; +import type { ApiInterfaceRx } from '@polkadot/api/types'; +import type { Option } from '@polkadot/types'; +import type { FundInfo, ParaId } from '@polkadot/types/interfaces'; + +import { map } from 'rxjs'; + +import { u8aConcat, u8aToHex } from '@polkadot/util'; +import { blake2AsU8a } from '@polkadot/util-crypto'; + +import { memo } from '../util'; + +function createChildKey ({ trieIndex }: FundInfo): string { + return u8aToHex( + u8aConcat( + ':child_storage:default:', + blake2AsU8a( + u8aConcat('crowdloan', trieIndex.toU8a()) + ) + ) + ); +} + +export function childKey (instanceId: string, api: ApiInterfaceRx): (paraId: string | number | ParaId) => Observable { + return memo(instanceId, (paraId: string | number | ParaId): Observable => + api.query.crowdloan.funds>(paraId).pipe( + map((optInfo) => + optInfo.isSome + ? createChildKey(optInfo.unwrap()) + : null + ) + ) + ); +} diff --git a/packages/api-derive/src/crowdloan/contributions.ts b/packages/api-derive/src/crowdloan/contributions.ts new file mode 100644 index 0000000000..0e3109fc9a --- /dev/null +++ b/packages/api-derive/src/crowdloan/contributions.ts @@ -0,0 +1,135 @@ +// Copyright 2017-2021 @polkadot/api-derive authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Observable } from 'rxjs'; +import type { ApiInterfaceRx } from '@polkadot/api/types'; +import type { StorageKey } from '@polkadot/types'; +import type { ParaId } from '@polkadot/types/interfaces'; +import type { DeriveContributions } from '../types'; + +import { BehaviorSubject, combineLatest, EMPTY, map, of, startWith, switchMap, tap, toArray } from 'rxjs'; + +import { arrayFlatten, isFunction } from '@polkadot/util'; + +import { memo } from '../util'; +import { extractContributed } from './util'; + +interface Changes { + added: string[]; + blockHash: string; + removed: string[]; +} + +const PAGE_SIZE_K = 1000; // limit aligned with the 1k on the node (trie lookups are heavy) + +function _getUpdates (api: ApiInterfaceRx, paraId: string | number | ParaId): Observable { + let added: string[] = []; + let removed: string[] = []; + + return api.query.system.events().pipe( + switchMap((events): Observable => { + const changes = extractContributed(paraId, events); + + if (changes.added.length || changes.removed.length) { + added = added.concat(...changes.added); + removed = removed.concat(...changes.removed); + + return of({ added, addedDelta: changes.added, blockHash: events.createdAtHash?.toHex() || '-', removed, removedDelta: changes.removed }); + } + + return EMPTY; + }), + startWith({ added, addedDelta: [], blockHash: '-', removed, removedDelta: [] }) + ); +} + +function _eventTriggerAll (api: ApiInterfaceRx, paraId: string | number | ParaId): Observable { + return api.query.system.events().pipe( + switchMap((events): Observable => { + const items = events.filter(({ event: { data: [eventParaId], method, section } }) => + section === 'crowdloan' && + ['AllRefunded', 'Dissolved', 'PartiallyRefunded'].includes(method) && + eventParaId.eq(paraId) + ); + + return items.length + ? of(events.createdAtHash?.toHex() || '-') + : EMPTY; + }), + startWith('-') + ); +} + +function _getKeysPaged (api: ApiInterfaceRx, childKey: string): Observable { + const startSubject = new BehaviorSubject(undefined); + + return startSubject.pipe( + switchMap((startKey) => + api.rpc.childstate.getKeysPaged(childKey, '0x', PAGE_SIZE_K, startKey) + ), + tap((keys): void => { + setTimeout((): void => { + keys.length === PAGE_SIZE_K + ? startSubject.next(keys[PAGE_SIZE_K - 1].toHex()) + : startSubject.complete(); + }, 0); + }), + toArray(), // toArray since we want to startSubject to be completed + map((keyArr: StorageKey[][]) => arrayFlatten(keyArr)) + ); +} + +function _getAll (api: ApiInterfaceRx, paraId: string | number | ParaId, childKey: string): Observable { + return _eventTriggerAll(api, paraId).pipe( + switchMap(() => + // FIXME Needs testing and being enabled + // eslint-disable-next-line no-constant-condition + isFunction(api.rpc.childstate.getKeysPaged) && false + ? _getKeysPaged(api, childKey) + : api.rpc.childstate.getKeys(childKey, '0x') + ), + map((keys) => + keys.map((k) => k.toHex()) + ) + ); +} + +function _contributions (api: ApiInterfaceRx, paraId: string | number | ParaId, childKey: string): Observable { + return combineLatest([ + _getAll(api, paraId, childKey), + _getUpdates(api, paraId) + ]).pipe( + map(([keys, { added, blockHash, removed }]): DeriveContributions => { + const contributorsMap: Record = {}; + + keys.forEach((k): void => { + contributorsMap[k] = true; + }); + + added.forEach((k): void => { + contributorsMap[k] = true; + }); + + removed.forEach((k): void => { + delete contributorsMap[k]; + }); + + return { + blockHash, + contributorsHex: Object.keys(contributorsMap) + }; + }) + ); +} + +export function contributions (instanceId: string, api: ApiInterfaceRx): (paraId: string | number | ParaId) => Observable { + return memo(instanceId, (paraId: string | number | ParaId): Observable => + api.derive.crowdloan.childKey(paraId).pipe( + switchMap((childKey) => + childKey + ? _contributions(api, paraId, childKey) + : of({ blockHash: '-', contributorsHex: [] }) + ) + ) + ); +} diff --git a/packages/api-derive/src/crowdloan/index.ts b/packages/api-derive/src/crowdloan/index.ts new file mode 100644 index 0000000000..77ebd02849 --- /dev/null +++ b/packages/api-derive/src/crowdloan/index.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2021 @polkadot/api-derive authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export * from './childKey'; +export * from './contributions'; +export * from './onwContributions'; diff --git a/packages/api-derive/src/crowdloan/onwContributions.ts b/packages/api-derive/src/crowdloan/onwContributions.ts new file mode 100644 index 0000000000..bc74d446d3 --- /dev/null +++ b/packages/api-derive/src/crowdloan/onwContributions.ts @@ -0,0 +1,72 @@ +// Copyright 2017-2021 @polkadot/api-derive authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Observable } from 'rxjs'; +import type { ApiInterfaceRx } from '@polkadot/api/types'; +import type { ParaId } from '@polkadot/types/interfaces'; +import type { DeriveOwnContributions } from '../types'; + +import { combineLatest, EMPTY, map, of, startWith, switchMap } from 'rxjs'; + +import { memo } from '../util'; +import { extractContributed } from './util'; + +function _getValues (api: ApiInterfaceRx, childKey: string, keys: string[]): Observable { + // We actually would love to use multi-keys https://github.com/paritytech/substrate/issues/9203 + return combineLatest(keys.map((k) => api.rpc.childstate.getStorage(childKey, k))).pipe( + map((values) => + values + .map((v) => api.registry.createType('Option', v)) + .map((o) => + o.isSome + ? api.registry.createType('Balance', o.unwrap()) + : api.registry.createType('Balance') + ) + .reduce((all: DeriveOwnContributions, b, index): DeriveOwnContributions => ({ + ...all, + [keys[index]]: b + }), {}) + ) + ); +} + +function _watchOwnChanges (api: ApiInterfaceRx, paraId: string | number | ParaId, childkey: string, keys: string[]): Observable { + return api.query.system.events().pipe( + switchMap((events): Observable => { + const changes = extractContributed(paraId, events); + const filtered = keys.filter((k) => + changes.added.includes(k) || + changes.removed.includes(k) + ); + + return filtered.length + ? _getValues(api, childkey, filtered) + : EMPTY; + }), + startWith({}) + ); +} + +function _contributions (api: ApiInterfaceRx, paraId: string | number | ParaId, childKey: string, keys: string[]): Observable { + return combineLatest([ + _getValues(api, childKey, keys), + _watchOwnChanges(api, paraId, childKey, keys) + ]).pipe( + map(([all, latest]) => ({ + ...all, + ...latest + })) + ); +} + +export function ownContributions (instanceId: string, api: ApiInterfaceRx): (paraId: string | number | ParaId, keys: string[]) => Observable { + return memo(instanceId, (paraId: string | number | ParaId, keys: string[]): Observable => + api.derive.crowdloan.childKey(paraId).pipe( + switchMap((childKey) => + childKey && keys.length + ? _contributions(api, paraId, childKey, keys) + : of({}) + ) + ) + ); +} diff --git a/packages/api-derive/src/crowdloan/types.ts b/packages/api-derive/src/crowdloan/types.ts new file mode 100644 index 0000000000..b1892e92ee --- /dev/null +++ b/packages/api-derive/src/crowdloan/types.ts @@ -0,0 +1,11 @@ +// Copyright 2017-2021 @polkadot/api-derive authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Balance } from '@polkadot/types/interfaces'; + +export interface DeriveContributions { + blockHash: string; + contributorsHex: string[]; +} + +export type DeriveOwnContributions = Record; diff --git a/packages/api-derive/src/crowdloan/util.ts b/packages/api-derive/src/crowdloan/util.ts new file mode 100644 index 0000000000..d2a8b6ebda --- /dev/null +++ b/packages/api-derive/src/crowdloan/util.ts @@ -0,0 +1,32 @@ +// Copyright 2017-2021 @polkadot/api-derive authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Vec } from '@polkadot/types'; +import type { EventRecord, ParaId } from '@polkadot/types/interfaces'; + +interface Changes { + added: string[]; + blockHash: string; + removed: string[]; +} + +export function extractContributed (paraId: string | number | ParaId, events: Vec): Changes { + const added: string[] = []; + const removed: string[] = []; + + return events + .filter(({ event: { data: [, eventParaId], method, section } }) => + section === 'crowdloan' && + ['Contributed', 'Withdrew'].includes(method) && + eventParaId.eq(paraId) + ) + .reduce((result: Changes, { event: { data: [accountId], method } }): Changes => { + if (method === 'Contributed') { + result.added.push(accountId.toHex()); + } else { + result.removed.push(accountId.toHex()); + } + + return result; + }, { added, blockHash: events.createdAtHash?.toHex() || '-', removed }); +} diff --git a/packages/api-derive/src/types.ts b/packages/api-derive/src/types.ts index 503d1c1522..4aee12c046 100644 --- a/packages/api-derive/src/types.ts +++ b/packages/api-derive/src/types.ts @@ -7,6 +7,7 @@ import type { BN } from '@polkadot/util'; export * from './accounts/types'; export * from './council/types'; +export * from './crowdloan/types'; export * from './democracy/types'; export * from './elections/types'; export * from './parachains/types';