diff --git a/src/renderer/domains/collectives/model/members/model.ts b/src/renderer/domains/collectives/model/members/model.ts index 5ed4cff2fc..fea8b8b169 100644 --- a/src/renderer/domains/collectives/model/members/model.ts +++ b/src/renderer/domains/collectives/model/members/model.ts @@ -26,17 +26,17 @@ const { } = createDataSubscription, RequestParams, (Member | CoreMember)[]>({ initial: {}, fn: ({ api, palletType }, callback) => { - let currentAbortController = new AbortController(); + let abortController = new AbortController(); const fn = async () => { - currentAbortController.abort(); - currentAbortController = new AbortController(); + abortController.abort(); + abortController = new AbortController(); const collectiveMembers = await collectivePallet.storage.members(palletType, api); - if (currentAbortController.signal.aborted) return; + if (abortController.signal.aborted) return; const coreMembers = await collectiveCorePallet.storage.member(palletType, api); - if (currentAbortController.signal.aborted) return; + if (abortController.signal.aborted) return; const result: Member[] = []; @@ -71,7 +71,7 @@ const { // TODO check if section name is correct return polkadotjsHelpers.subscribeSystemEvents({ api, section: `${palletType}Core` }, fn).then(fn => () => { - currentAbortController.abort(); + abortController.abort(); fn(); }); }, diff --git a/src/renderer/domains/collectives/model/referendum/model.ts b/src/renderer/domains/collectives/model/referendum/model.ts index f965d7dfa2..f0e24f0882 100644 --- a/src/renderer/domains/collectives/model/referendum/model.ts +++ b/src/renderer/domains/collectives/model/referendum/model.ts @@ -26,19 +26,19 @@ const { pending, subscribe, unsubscribe, received, fulfilled } = createDataSubsc >({ initial: $list, fn: ({ api, palletType }, callback) => { - let currectAbortController = new AbortController(); + let abortController = new AbortController(); const fetchPages = createPagesHandler({ fn: () => referendaPallet.storage.referendumInfoForPaged(palletType, api, 200), map: mapReferendums, }); - fetchPages(currectAbortController, callback); + fetchPages(abortController, callback); const fn = () => { - currectAbortController.abort(); - currectAbortController = new AbortController(); - fetchPages(currectAbortController, callback); + abortController.abort(); + abortController = new AbortController(); + fetchPages(abortController, callback); }; /** @@ -47,7 +47,7 @@ const { pending, subscribe, unsubscribe, received, fulfilled } = createDataSubsc * @see https://github.com/paritytech/polkadot-sdk/blob/43cd6fd4370d3043272f64a79aeb9e6dc0edd13f/substrate/frame/collective/src/lib.rs#L459 */ return polkadotjsHelpers.subscribeSystemEvents({ api, section: `${palletType}Referenda` }, fn).then(fn => () => { - currectAbortController.abort(); + abortController.abort(); fn(); }); }, diff --git a/src/renderer/domains/multisig/index.ts b/src/renderer/domains/multisig/index.ts new file mode 100644 index 0000000000..dc369de33c --- /dev/null +++ b/src/renderer/domains/multisig/index.ts @@ -0,0 +1,7 @@ +import { multisigsDomainModel } from './model/multisigs/model'; + +export const multisigDomain = { + multisigs: multisigsDomainModel, +}; + +export type { Multisig } from './model/multisigs/types'; diff --git a/src/renderer/domains/multisig/model/multisigs/constants.ts b/src/renderer/domains/multisig/model/multisigs/constants.ts new file mode 100644 index 0000000000..361e1abcd2 --- /dev/null +++ b/src/renderer/domains/multisig/model/multisigs/constants.ts @@ -0,0 +1,6 @@ +export const MultisigEventFieldIndex = { + ACCOUNT_ID: 0, + TIMEPOINT: 1, + MULTISIG: 2, + CALL_HASH: 3, +}; diff --git a/src/renderer/domains/multisig/model/multisigs/model.ts b/src/renderer/domains/multisig/model/multisigs/model.ts new file mode 100644 index 0000000000..0547e38575 --- /dev/null +++ b/src/renderer/domains/multisig/model/multisigs/model.ts @@ -0,0 +1,153 @@ +import { type ApiPromise } from '@polkadot/api'; +import { type Event } from '@polkadot/types/interfaces/system'; +import { createStore } from 'effector'; +import { cloneDeep } from 'lodash'; + +import { type CallHash, type ChainId, type HexString } from '@/shared/core'; +import { createDataSource, createDataSubscription } from '@/shared/effector'; +import { nullable, setNestedValue } from '@/shared/lib/utils'; +import { multisigPallet } from '@/shared/pallet/multisig'; +import { polkadotjsHelpers } from '@/shared/polkadotjs-helpers'; +import { type AccountId } from '@/shared/polkadotjs-schemas'; + +import { MultisigEventFieldIndex } from './constants'; +import { multisigOperationService } from './service'; +import { type Multisig, type MultisigEvent } from './types'; + +type Store = Record>; + +type RequestParams = { + accountId: AccountId; + api: ApiPromise; +}; + +const $multisigOperations = createStore({}); + +const { request } = createDataSource>({ + initial: $multisigOperations, + async fn(inputs) { + const result: Record = {}; + + for (const { api, accountId } of inputs) { + const response = await multisigPallet.storage.multisigs(api, accountId); + const chainId = api.genesisHash.toHex(); + + for (const multisig of response) { + if (nullable(multisig.multisig)) continue; + result[chainId] = result[chainId] || []; + + result[chainId].push({ + status: 'pending', + accountId: multisig.key.accountId, + callHash: multisig.key.callHash as HexString, + depositor: multisig.multisig.depositor, + events: multisig.multisig.approvals.map(accountId => ({ + accountId, + status: 'approved', + blockCreated: multisig.multisig!.when.height, + indexCreated: multisig.multisig!.when.index, + })), + blockCreated: multisig.multisig.when.height, + indexCreated: multisig.multisig.when.index, + deposit: multisig.multisig.deposit, + }); + } + } + + return result; + }, + map(store, { params, result }) { + let newStore = {}; + + for (const { api, accountId } of params) { + const chainId = api.genesisHash.toHex(); + const oldOperations = store[chainId]?.[accountId] || []; + const newOperations = result[chainId] || []; + const multisigOperations = multisigOperationService.mergeMultisigOperations(oldOperations, newOperations); + + newStore = setNestedValue(store, chainId, accountId, multisigOperations); + } + + return newStore; + }, +}); + +const { subscribe, unsubscribe } = createDataSubscription< + Store, + RequestParams[], + { callHash: CallHash; multisigId: AccountId; chainId: ChainId } & MultisigEvent +>({ + initial: $multisigOperations, + fn: (params, callback) => { + const unsubscribeFns: Promise[] = []; + + for (const { accountId, api } of params) { + const subscribeEventCallback = (event: Event) => { + if (event.data[MultisigEventFieldIndex.MULTISIG]?.toHex() !== accountId) return; + + const blockCreated = (event.data[MultisigEventFieldIndex.TIMEPOINT] as any).height.toNumber(); + const indexCreated = (event.data[MultisigEventFieldIndex.TIMEPOINT] as any).index.toNumber(); + + callback({ + done: true, + value: { + multisigId: accountId, + chainId: api.genesisHash.toHex(), + callHash: event.data[MultisigEventFieldIndex.CALL_HASH]!.toHex(), + accountId: event.data[MultisigEventFieldIndex.ACCOUNT_ID]!.toHex() as AccountId, + status: event.method === 'MultisigCancelled' ? 'rejected' : 'approved', + indexCreated, + blockCreated, + }, + }); + }; + + const unsubscribeFn = polkadotjsHelpers + .subscribeSystemEvents( + { + api, + section: `multisig`, + // TODO: add NewMultisig event + methods: ['MultisigApproval', 'MultisigExecuted', 'MultisigCancelled'], + }, + subscribeEventCallback, + ) + .then(unsubscribe => unsubscribe()); + + unsubscribeFns.push(unsubscribeFn); + } + + return () => { + Promise.all(unsubscribeFns); + }; + }, + map: (store, { result: { callHash, multisigId, chainId, ...event } }) => { + const newStore = cloneDeep(store); + + if (!newStore[chainId]) { + newStore[chainId] = {}; + } + + if (!newStore[chainId][multisigId]) { + newStore[chainId][multisigId] = []; + } + + const multisig = newStore[chainId][multisigId].find( + multisig => multisig.callHash === callHash && multisig.status === 'pending', + ); + + if (multisig) { + multisig.events = multisigOperationService.mergeEvents(multisig.events, [event]); + } + + return newStore; + }, +}); + +export const multisigsDomainModel = { + $multisigOperations, + + request, + subscribe, + unsubscribe, +}; diff --git a/src/renderer/domains/multisig/model/multisigs/service.ts b/src/renderer/domains/multisig/model/multisigs/service.ts new file mode 100644 index 0000000000..cdba8faf1e --- /dev/null +++ b/src/renderer/domains/multisig/model/multisigs/service.ts @@ -0,0 +1,47 @@ +import { cloneDeep } from 'lodash'; + +import { type Multisig, type MultisigEvent } from './types'; + +export const multisigOperationService = { + isSameMultisig, + isSameEvent, + mergeEvents, + mergeMultisigOperations, +}; + +function isSameMultisig(a: Multisig, b: Multisig) { + const isSameCallHash = a.callHash === b.callHash; + const isSameTimepoint = a.blockCreated === b.blockCreated && a.indexCreated === b.indexCreated; + const isSameAccount = a.accountId === b.accountId; + + return isSameCallHash && isSameTimepoint && isSameAccount; +} + +function isSameEvent(a: MultisigEvent, b: MultisigEvent) { + const isSameAccount = a.accountId === b.accountId; + const isSameTimepoint = a.blockCreated === b.blockCreated && a.indexCreated === b.indexCreated; + + return isSameAccount && isSameTimepoint; +} + +function mergeEvents(oldEvents: MultisigEvent[], events: MultisigEvent[]) { + const newEvents = events.filter(e => !oldEvents.some(o => isSameEvent(o, e))); + + return [...oldEvents, ...newEvents]; +} + +function mergeMultisigOperations(oldMultisigs: Multisig[], newMultisigs: Multisig[]): Multisig[] { + const result = cloneDeep(oldMultisigs); + + for (const newMultisig of newMultisigs) { + const oldMultisig = result.find(m => isSameMultisig(m, newMultisig)); + + if (oldMultisig) { + oldMultisig.events = mergeEvents(oldMultisig.events, newMultisig.events); + } else { + result.push(newMultisig); + } + } + + return result; +} diff --git a/src/renderer/domains/multisig/model/multisigs/types.ts b/src/renderer/domains/multisig/model/multisigs/types.ts new file mode 100644 index 0000000000..5c321a19f0 --- /dev/null +++ b/src/renderer/domains/multisig/model/multisigs/types.ts @@ -0,0 +1,29 @@ +import { type BN } from '@polkadot/util'; + +import { type CallData, type CallHash, type HexString } from '@/shared/core'; +import { type AccountId, type BlockHeight } from '@/shared/polkadotjs-schemas'; + +export type Timepoint = { + height: BlockHeight; + index: number; +}; + +export type MultisigEvent = { + accountId: AccountId; + status: 'approved' | 'rejected'; + blockCreated: BlockHeight; + indexCreated: number; + extrinsicHash?: HexString; +}; + +export type Multisig = { + status: 'pending' | 'cancelled' | 'executed' | 'error'; + accountId: AccountId; + callHash: CallHash; + callData?: CallData; + deposit: BN; + depositor: AccountId; + blockCreated: BlockHeight; + indexCreated: number; + events: MultisigEvent[]; +}; diff --git a/src/renderer/entities/governance/lib/governanceSubscribeService.ts b/src/renderer/entities/governance/lib/governanceSubscribeService.ts index 1b0d3db4e7..19ffdeb8eb 100644 --- a/src/renderer/entities/governance/lib/governanceSubscribeService.ts +++ b/src/renderer/entities/governance/lib/governanceSubscribeService.ts @@ -134,7 +134,7 @@ function subscribeVotingFor( } function subscribeReferendums(api: ApiPromise, callback: (referendums: IteratorResult) => unknown) { - let currectAbortController = new AbortController(); + let currentAbortController = new AbortController(); const fetchPages = async (abort: AbortController) => { for await (const page of referendaPallet.storage.referendumInfoForPaged('governance', api, 500)) { @@ -153,12 +153,12 @@ function subscribeReferendums(api: ApiPromise, callback: (referendums: IteratorR callback({ done: true, value: undefined }); }; - fetchPages(currectAbortController); + fetchPages(currentAbortController); const fn = () => { - currectAbortController.abort(); - currectAbortController = new AbortController(); - fetchPages(currectAbortController); + currentAbortController.abort(); + currentAbortController = new AbortController(); + fetchPages(currentAbortController); }; const unsubscribeSystemReferenda = polkadotjsHelpers.subscribeSystemEvents({ api, section: 'referenda' }, fn); @@ -174,7 +174,7 @@ function subscribeReferendums(api: ApiPromise, callback: (referendums: IteratorR return Promise.all([unsubscribeSystemReferenda, unsubscribeSystemConvictionVoting, unsubscribeExtrinsics]).then( (fns) => () => { - currectAbortController.abort(); + currentAbortController.abort(); for (const fn of fns) { fn(); } diff --git a/src/renderer/features/multisig-operations/constants.ts b/src/renderer/features/multisig-operations/constants.ts new file mode 100644 index 0000000000..c70a793a5a --- /dev/null +++ b/src/renderer/features/multisig-operations/constants.ts @@ -0,0 +1,3 @@ +export const ERROR = { + networkDisabled: 'Network disabled', +}; diff --git a/src/renderer/features/multisig-operations/index.ts b/src/renderer/features/multisig-operations/index.ts new file mode 100644 index 0000000000..f0c5368ab5 --- /dev/null +++ b/src/renderer/features/multisig-operations/index.ts @@ -0,0 +1,8 @@ +import { operationsModel } from './model/list'; + +export const multisigOperationsFeature = { + model: { + operations: operationsModel, + }, + views: {}, +}; diff --git a/src/renderer/features/multisig-operations/model/list.ts b/src/renderer/features/multisig-operations/model/list.ts new file mode 100644 index 0000000000..f651616de4 --- /dev/null +++ b/src/renderer/features/multisig-operations/model/list.ts @@ -0,0 +1,24 @@ +import { sample } from 'effector'; + +import { multisigDomain } from '@/domains/multisig'; + +import { multisigOperationsFeatureStatus } from './status'; + +sample({ + clock: multisigOperationsFeatureStatus.running, + target: [multisigDomain.multisigs.request, multisigDomain.multisigs.subscribe], +}); + +sample({ + clock: multisigOperationsFeatureStatus.stopped, + target: multisigDomain.multisigs.unsubscribe, +}); + +const $operations = multisigDomain.multisigs.$multisigOperations.map((list) => list ?? {}); + +export const operationsModel = { + $operations, + + $pending: multisigOperationsFeatureStatus.isStarting, + $fulfilled: multisigOperationsFeatureStatus.isRunning, +}; diff --git a/src/renderer/features/multisig-operations/model/status.ts b/src/renderer/features/multisig-operations/model/status.ts new file mode 100644 index 0000000000..ae440aa6fb --- /dev/null +++ b/src/renderer/features/multisig-operations/model/status.ts @@ -0,0 +1,86 @@ +import { type ApiPromise } from '@polkadot/api'; +import { combine, createStore, sample } from 'effector'; +import { debounce } from 'patronum'; + +import { type ChainId } from '@/shared/core'; +import { createFeature } from '@/shared/effector'; +import { nullable } from '@/shared/lib/utils'; +import { type AccountId } from '@/shared/polkadotjs-schemas'; +import { networkModel, networkUtils } from '@/entities/network'; +import { walletModel, walletUtils } from '@/entities/wallet'; + +const $trigger = createStore(''); +const $debouncedApis = createStore>({}); + +sample({ + clock: debounce(networkModel.$apis, 2000), + source: networkModel.$chains, + fn: (chains, apis) => { + const multisigChains = Object.values(chains) + .filter((chain) => apis[chain.chainId] && networkUtils.isMultisigSupported(chain.options)) + .map((c) => c.chainId); + + return multisigChains.join(','); + }, + target: $trigger, +}); + +sample({ + clock: $trigger, + source: networkModel.$apis, + target: $debouncedApis, +}); + +const $input = combine( + { + apis: $debouncedApis, + chains: networkModel.$chains, + wallet: walletModel.$activeWallet, + }, + ({ apis, chains, wallet }) => { + if (nullable(wallet) || !walletUtils.isMultisig(wallet)) return null; + + const input = []; + + for (const account of wallet.accounts) { + if (account.chainId) { + const api = apis[account.chainId]; + + if (api) { + input.push({ + api, + accountId: account.accountId as AccountId, + }); + } + } else { + const multisigChains = Object.values(chains).filter((chain) => networkUtils.isMultisigSupported(chain.options)); + + for (const chain of multisigChains) { + const api = apis[chain.chainId]; + + if (api) { + input.push({ + api, + accountId: account.accountId as AccountId, + }); + } + } + } + } + + return input; + }, +); + +export const multisigOperationsFeatureStatus = createFeature({ + name: 'multisigOperations', + input: $input, +}); + +multisigOperationsFeatureStatus.start(); + +sample({ + clock: walletModel.$activeWallet, + filter: walletUtils.isMultisig, + target: multisigOperationsFeatureStatus.restore, +}); diff --git a/src/renderer/shared/pallet/multisig/consts.ts b/src/renderer/shared/pallet/multisig/consts.ts new file mode 100644 index 0000000000..a4cc4238b0 --- /dev/null +++ b/src/renderer/shared/pallet/multisig/consts.ts @@ -0,0 +1,37 @@ +import { type ApiPromise } from '@polkadot/api'; + +import { pjsSchema } from '@/shared/polkadotjs-schemas'; + +const getPallet = (api: ApiPromise) => { + const pallet = api.consts['multisig']; + if (!pallet) { + throw new TypeError('multisig pallet not found'); + } + + return pallet; +}; + +export const consts = { + /** + * The base amount of currency needed to reserve for creating a multisig + * execution or to store a dispatch call for later. + */ + depositBase(api: ApiPromise) { + return pjsSchema.u128.parse(getPallet(api)['depositBase']); + }, + + /** + * The amount of currency needed per unit threshold when creating a multisig + * execution. + */ + depositFactor(api: ApiPromise) { + return pjsSchema.u128.parse(getPallet(api)['depositFactor']); + }, + + /** + * The maximum amount of signatories allowed in the multisig. + */ + maxSignatories(api: ApiPromise) { + return pjsSchema.u32.parse(getPallet(api)['maxSignatories']); + }, +}; diff --git a/src/renderer/shared/pallet/multisig/index.ts b/src/renderer/shared/pallet/multisig/index.ts new file mode 100644 index 0000000000..405f1a9434 --- /dev/null +++ b/src/renderer/shared/pallet/multisig/index.ts @@ -0,0 +1,11 @@ +import { consts } from './consts'; +import * as schema from './schema'; +import { storage } from './storage'; + +export const multisigPallet = { + consts, + schema, + storage, +}; + +export type { MultisigTimepoint, Multisig } from './schema'; diff --git a/src/renderer/shared/pallet/multisig/schema.ts b/src/renderer/shared/pallet/multisig/schema.ts new file mode 100644 index 0000000000..e0416ba884 --- /dev/null +++ b/src/renderer/shared/pallet/multisig/schema.ts @@ -0,0 +1,17 @@ +import { type z } from 'zod'; + +import { pjsSchema } from '@/shared/polkadotjs-schemas'; + +export type MultisigTimepoint = z.infer; +export const multisigTimepoint = pjsSchema.object({ + height: pjsSchema.blockHeight, + index: pjsSchema.u32, +}); + +export type Multisig = z.infer; +export const multisig = pjsSchema.object({ + when: multisigTimepoint, + deposit: pjsSchema.u128, + depositor: pjsSchema.accountId, + approvals: pjsSchema.vec(pjsSchema.accountId), +}); diff --git a/src/renderer/shared/pallet/multisig/storage.ts b/src/renderer/shared/pallet/multisig/storage.ts new file mode 100644 index 0000000000..56e2db0c55 --- /dev/null +++ b/src/renderer/shared/pallet/multisig/storage.ts @@ -0,0 +1,42 @@ +import { type ApiPromise } from '@polkadot/api'; + +import { substrateRpcPool } from '@/shared/api/substrate-helpers'; +import { type AccountId, pjsSchema } from '@/shared/polkadotjs-schemas'; + +import { multisig } from './schema'; + +const getQuery = (api: ApiPromise, name: string) => { + const pallet = api.query['multisig']; + if (!pallet) { + throw new TypeError(`multisig pallet not found in ${api.runtimeChain.toString()} chain`); + } + + const query = pallet[name]; + + if (!query) { + throw new TypeError(`${name} query not found`); + } + + return query; +}; + +export const storage = { + /** + * Get list of all multisig operations for given account + */ + multisigs(api: ApiPromise, accountId: AccountId) { + const schema = pjsSchema.vec( + pjsSchema.tupleMap( + [ + 'key', + pjsSchema + .storageKey(pjsSchema.accountId, pjsSchema.u8Array) + .transform(([accountId, callHash]) => ({ accountId, callHash })), + ], + ['multisig', pjsSchema.optional(multisig)], + ), + ); + + return substrateRpcPool.call(() => getQuery(api, 'multisigs').entries(accountId)).then(schema.parse); + }, +}; diff --git a/src/renderer/shared/polkadotjs-helpers/subscribeSystemEvents.ts b/src/renderer/shared/polkadotjs-helpers/subscribeSystemEvents.ts index 5d1291d0b2..8d7c85a20c 100644 --- a/src/renderer/shared/polkadotjs-helpers/subscribeSystemEvents.ts +++ b/src/renderer/shared/polkadotjs-helpers/subscribeSystemEvents.ts @@ -13,10 +13,11 @@ export const subscribeSystemEvents = ( ) => { const isValidEvent = (event: Event) => { const isCorrectSection = event.section.toString() === section; + if (!methods || methods.length === 0) { return isCorrectSection; } - const isCorrectMethod = methods.includes(event.method); + const isCorrectMethod = methods.includes(event.method.toString()); return isCorrectSection && isCorrectMethod; }; diff --git a/src/renderer/shared/polkadotjs-schemas/index.ts b/src/renderer/shared/polkadotjs-schemas/index.ts index c3cab0baa9..f68f0a4691 100644 --- a/src/renderer/shared/polkadotjs-schemas/index.ts +++ b/src/renderer/shared/polkadotjs-schemas/index.ts @@ -58,6 +58,7 @@ export const pjsSchema = { structHex: structHexSchema, dataString: dataStringSchema, hex: hexSchema, + u8Array: hexSchema, object: objectSchema, optional: optionalSchema,