diff --git a/jest.config.ts b/jest.config.ts index 4ca72c3a74..f2849f9330 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -47,7 +47,15 @@ const config: Config = { '^dexie$': '/node_modules/dexie/dist/dexie.js', '^lottie': 'lottie-react', }, - modulePathIgnorePatterns: ['/tests'], + modulePathIgnorePatterns: [ + '/tests', + + // Files, excluded because of cyclic dependecies with OperationSign + '/src/renderer/features/proxy-add/model/__tests__/add-proxy-model.test.ts', + '/src/renderer/features/proxy-add-pure/model/__tests__/add-pure-proxied-model.test.ts', + '/src/renderer/pages/Governance/lib/__tests__/governancePageUtils.test.ts', + '/src/renderer/pages/Onboarding/Vault/ManageVault/model/__tests__/manage-vault-model.test.ts', + ], collectCoverageFrom: [ 'src/renderer/**/*.{js,ts}', '!src/renderer/pages/**/*.{js,ts}', diff --git a/src/renderer/app/App.tsx b/src/renderer/app/App.tsx index f84058c50d..f757c7d710 100644 --- a/src/renderer/app/App.tsx +++ b/src/renderer/app/App.tsx @@ -8,7 +8,6 @@ import { Paths } from '@/shared/routes'; import { walletModel } from '@/entities/wallet'; import { navigationModel } from '@/features/navigation'; import { CreateWalletProvider } from '@/widgets/CreateWallet'; -import { WalletDetailsProvider } from '@/widgets/WalletDetails'; import { ROUTES_CONFIG } from '@/pages/index'; import { initModel } from './modelInit'; @@ -39,7 +38,6 @@ export const App = () => { {appRoutes} - diff --git a/src/renderer/app/modelInit.ts b/src/renderer/app/modelInit.ts index a4c3783809..2655974c22 100644 --- a/src/renderer/app/modelInit.ts +++ b/src/renderer/app/modelInit.ts @@ -1,4 +1,7 @@ +/* eslint-disable import-x/max-dependencies */ + import { kernelModel } from '@/shared/core'; +import { registerFeatures } from '@/shared/effector'; import { basketModel } from '@/entities/basket'; import { governanceModel } from '@/entities/governance'; import { networkModel } from '@/entities/network'; @@ -12,13 +15,31 @@ import { contactsNavigationFeature } from '@/features/contacts-navigation'; import { fellowshipNavigationFeature } from '@/features/fellowship-navigation'; import { governanceNavigationFeature } from '@/features/governance-navigation'; import { notificationsNavigationFeature } from '@/features/notifications-navigation'; +import * as operationDetails from '@/features/operation-details'; import { operationsNavigationFeature } from '@/features/operations-navigation'; import { proxiesModel } from '@/features/proxies'; import { settingsNavigationFeature } from '@/features/settings-navigation'; import { stakingNavigationFeature } from '@/features/staking-navigation'; -import { walletsSelectFeature } from '@/features/wallets-select'; +import { walletSelectFeature } from '@/features/wallet-select'; export const initModel = () => { + registerFeatures([ + assetsNavigationFeature, + stakingNavigationFeature, + governanceNavigationFeature, + fellowshipNavigationFeature, + operationsNavigationFeature, + contactsNavigationFeature, + notificationsNavigationFeature, + settingsNavigationFeature, + walletSelectFeature.feature, + operationDetails.transferOperationDetailFeature, + operationDetails.stakingOperationDetailFeature, + operationDetails.multisigOperationDetailsFeature, + operationDetails.proxyOperationDetailFeature, + operationDetails.governanceOperationDetailFeature, + ]); + assetsNavigationFeature.start(); stakingNavigationFeature.start(); governanceNavigationFeature.start(); @@ -28,7 +49,7 @@ export const initModel = () => { notificationsNavigationFeature.start(); settingsNavigationFeature.start(); - walletsSelectFeature.start(); + walletSelectFeature.feature.start(); kernelModel.events.appStarted(); governanceModel.events.governanceStarted(); @@ -39,5 +60,5 @@ export const initModel = () => { assetsSettingsModel.events.assetsStarted(); notificationModel.events.notificationsStarted(); basketModel.events.basketStarted(); - multisigsModel.events.multisigsDiscoveryStarted(); + multisigsModel.events.subscribe(); }; 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/entities/multisig/lib/multisigTx/common/utils.ts b/src/renderer/entities/multisig/lib/multisigTx/common/utils.ts index 3564ac5adf..8d2652f233 100644 --- a/src/renderer/entities/multisig/lib/multisigTx/common/utils.ts +++ b/src/renderer/entities/multisig/lib/multisigTx/common/utils.ts @@ -57,7 +57,7 @@ export const createNewEventsPayload = ( approvals: Vec, ): MultisigEvent[] => { return approvals.reduce((acc, a) => { - const hasApprovalEvent = events.find((e) => e.status === 'SIGNED' && e.accountId === a.toHex()); + const hasApprovalEvent = events.some((e) => e.status === 'SIGNED' && e.accountId === a.toHex()); if (!hasApprovalEvent) { acc.push({ diff --git a/src/renderer/entities/multisig/lib/multisigTx/multisigTxService.ts b/src/renderer/entities/multisig/lib/multisigTx/multisigTxService.ts index 2fbd5c13bb..dc77ab424d 100644 --- a/src/renderer/entities/multisig/lib/multisigTx/multisigTxService.ts +++ b/src/renderer/entities/multisig/lib/multisigTx/multisigTxService.ts @@ -84,6 +84,7 @@ export const useMultisigTx = ({ addTask }: Props): IMultisigTxService => { }); const newEvents = createNewEventsPayload(oldEvents, newestOldTx, pendingTx.params.approvals); + for (const e of newEvents) { addEventWithQueue(e); } diff --git a/src/renderer/entities/network/lib/network-utils.ts b/src/renderer/entities/network/lib/network-utils.ts index 84c59e3d28..daeb683f97 100644 --- a/src/renderer/entities/network/lib/network-utils.ts +++ b/src/renderer/entities/network/lib/network-utils.ts @@ -6,6 +6,7 @@ import { type Connection, ConnectionStatus, ConnectionType, + ExternalType, } from '@/shared/core'; import { RelayChains } from '@/shared/lib/utils'; @@ -29,6 +30,7 @@ export const networkUtils = { getNewestMetadata, getLightClientChains, + getProxyExternalApi, getMainRelaychains, chainNameToUrl, @@ -88,6 +90,18 @@ function isAutoBalanceConnection(connection: Connection): boolean { return connection.connectionType === ConnectionType.AUTO_BALANCE; } +function getProxyExternalApi(chain: Chain) { + if (isMultisigSupported(chain.options)) { + if (!chain.externalApi) return null; + const proxyExternalApis = chain.externalApi[ExternalType.PROXY]; + if (!proxyExternalApis) return null; + + return proxyExternalApis.find((x) => x.url) ?? null; + } + + return null; +} + function getNewestMetadata(metadata: ChainMetadata[]): Record { return metadata.reduce>( (acc, data) => { diff --git a/src/renderer/entities/network/model/network-model.ts b/src/renderer/entities/network/model/network-model.ts index 7d2efe560e..97055fa83b 100644 --- a/src/renderer/entities/network/model/network-model.ts +++ b/src/renderer/entities/network/model/network-model.ts @@ -454,6 +454,7 @@ export const networkModel = { networkStarted, chainConnected, chainDisconnected, + connectionsPopulated: populateConnectionsFx.doneData, }, output: { diff --git a/src/renderer/entities/operations/index.ts b/src/renderer/entities/operations/index.ts index 7224f2f978..9d394c3367 100644 --- a/src/renderer/entities/operations/index.ts +++ b/src/renderer/entities/operations/index.ts @@ -1,3 +1,5 @@ export * from './ui'; export { operationsModel } from './model/operations-model'; export { operationsUtils } from './lib/operationsUtils'; +export * as operationDetailsUtils from './lib/operationDetailsUtils'; +export * from './lib/constants'; diff --git a/src/renderer/entities/operations/lib/constants.ts b/src/renderer/entities/operations/lib/constants.ts new file mode 100644 index 0000000000..85b98d402d --- /dev/null +++ b/src/renderer/entities/operations/lib/constants.ts @@ -0,0 +1,3 @@ +export const InteractionStyle = + 'rounded hover:bg-action-background-hover hover:text-text-primary cursor-pointer py-[3px] px-2 -mr-2'; +export const AddressStyle = 'text-footnote text-inherit'; diff --git a/src/renderer/entities/operations/lib/operationDetailsUtils.ts b/src/renderer/entities/operations/lib/operationDetailsUtils.ts new file mode 100644 index 0000000000..af8449b362 --- /dev/null +++ b/src/renderer/entities/operations/lib/operationDetailsUtils.ts @@ -0,0 +1,303 @@ +import { type ApiPromise } from '@polkadot/api'; +import { BN } from '@polkadot/util'; + +import { + type Account, + type AccountId, + type Address, + type Chain, + type ChainId, + type Contact, + type DecodedTransaction, + type Explorer, + type HexString, + type MultisigEvent, + type MultisigTransaction, + type ProxyType, + type Signatory, + type Transaction, + TransactionType, + type Wallet, +} from '@/shared/core'; +import { toAddress } from '@/shared/lib/utils'; +import { convictionVotingPallet } from '@/shared/pallet/convictionVoting'; +import { type TransactionVote, votingService } from '@/entities/governance'; +import { isDelegateTransaction, isProxyTransaction, isUndelegateTransaction } from '@/entities/transaction'; +import { accountUtils, walletUtils } from '@/entities/wallet'; + +export const getMultisigExtrinsicLink = ( + callHash?: HexString, + indexCreated?: number, + blockCreated?: number, + explorers?: Explorer[], +): string | undefined => { + if (!callHash || !indexCreated || !blockCreated || !explorers) return; + + const multisigLink = explorers.find((e) => e.multisig)?.multisig; + + if (!multisigLink) return; + + return multisigLink.replace('{index}', `${blockCreated}-${indexCreated}`).replace('{callHash}', callHash); +}; + +export const getSignatoryName = ( + signatoryId: AccountId, + txSignatories: Signatory[], + contacts: Contact[], + wallets: Wallet[], + addressPrefix?: number, +): string => { + const finderFn = (collection: T[]): T | undefined => { + return collection.find((c) => c.accountId === signatoryId); + }; + + // signatory data source priority: transaction -> contacts -> wallets -> address + const fromTx = finderFn(txSignatories)?.name; + if (fromTx) return fromTx; + + const fromContact = finderFn(contacts)?.name; + if (fromContact) return fromContact; + + const accounts = wallets.map((wallet) => wallet.accounts).flat(); + const fromAccount = finderFn(accounts)?.name; + if (fromAccount) return fromAccount; + + return toAddress(signatoryId, { chunk: 5, prefix: addressPrefix }); +}; + +export const getSignatoryAccounts = ( + accounts: Account[], + wallets: Wallet[], + events: MultisigEvent[], + signatories: Signatory[], + chainId: ChainId, +): Account[] => { + const walletsMap = new Map(wallets.map((wallet) => [wallet.id, wallet])); + + return signatories.reduce((acc: Account[], signatory) => { + const filteredAccounts = accounts.filter( + (a) => a.accountId === signatory.accountId && !events.some((e) => e.accountId === a.accountId), + ); + + const signatoryAccount = filteredAccounts.find((a) => { + const isChainMatch = accountUtils.isChainIdMatch(a, chainId); + const wallet = walletsMap.get(a.walletId); + + return isChainMatch && walletUtils.isValidSignatory(wallet); + }); + + if (signatoryAccount) { + acc.push(signatoryAccount); + } else { + const legacySignatoryAccount = filteredAccounts.find( + (a) => accountUtils.isChainAccount(a) && a.chainId === chainId, + ); + if (legacySignatoryAccount) { + acc.push(legacySignatoryAccount); + } + } + + return acc; + }, []); +}; + +export const getDestination = ( + tx: MultisigTransaction, + chains: Record, + destinationChain?: ChainId, +): Address | undefined => { + if (!tx.transaction) return undefined; + + const chain = destinationChain ? chains[destinationChain] : chains[tx.transaction.chainId]; + + if (isProxyTransaction(tx.transaction)) { + return toAddress(tx.transaction.args.transaction.args.dest, { prefix: chain.addressPrefix }); + } + + return toAddress(tx.transaction.args.dest, { prefix: chain.addressPrefix }); +}; + +export const getDestinationAccountId = (tx: MultisigTransaction): AccountId | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction.args.dest; + } + + return tx.transaction.args.dest; +}; + +export const getPayee = (tx: MultisigTransaction): { Account: Address } | string | undefined => { + if (!tx.transaction) return undefined; + + const args = isProxyTransaction(tx.transaction) ? tx.transaction.args.transaction.args : tx.transaction.args; + + if (tx.transaction.type === TransactionType.BATCH_ALL) { + return args.transactions.at(0).args.payee; + } + + return args.payee; +}; + +export const getDelegate = (tx: MultisigTransaction): Address | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction.args.delegate; + } + + return tx.transaction.args.delegate; +}; + +export const getDestinationChain = (tx: MultisigTransaction): ChainId | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction.args.destinationChain; + } + + return tx.transaction.args.destinationChain; +}; + +export const getSender = (tx: MultisigTransaction): Address | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction.real; + } + + return tx.transaction.address; +}; + +export const getSpawner = (tx: MultisigTransaction): AccountId | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction.args.spawner; + } + + return tx.transaction.args.spawner; +}; + +export const getProxyType = (tx: MultisigTransaction): ProxyType | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction.args.proxyType; + } + + return tx.transaction.args.proxyType; +}; + +export const getDelegationVotes = (tx: MultisigTransaction): string | undefined => { + if (!tx.transaction) return undefined; + + let coreTx; + + if (isProxyTransaction(tx.transaction)) { + coreTx = tx.transaction.args.transaction; + } else if (tx.transaction.type === TransactionType.BATCH_ALL) { + coreTx = tx.transaction.args.transactions?.find((tx: Transaction) => tx.type === TransactionType.DELEGATE); + } else if (isDelegateTransaction(tx.transaction)) { + coreTx = tx.transaction; + } + + if (!coreTx) return; + + const balance = new BN(coreTx.args.balance || 0); + const conviction = new BN(votingService.getConvictionMultiplier(coreTx.args.conviction) || 0); + + return balance.mul(conviction).toString(); +}; + +export const getDelegationTarget = (tx: MultisigTransaction): string | undefined => { + if (!tx.transaction) return undefined; + + let coreTx; + + if (isProxyTransaction(tx.transaction)) { + coreTx = tx.transaction.args.transaction; + } else if (tx.transaction.type === TransactionType.BATCH_ALL) { + coreTx = tx.transaction.args.transactions?.find((tx: Transaction) => tx.type === TransactionType.DELEGATE); + } else if (isDelegateTransaction(tx.transaction)) { + coreTx = tx.transaction; + } + + return coreTx?.args.target; +}; + +export const getDelegationTracks = (tx: MultisigTransaction): string[] | undefined => { + if (!tx.transaction) return undefined; + + let coreTxs; + + if (isProxyTransaction(tx.transaction)) { + coreTxs = [tx.transaction.args.transaction]; + } else if (tx.transaction.type === TransactionType.BATCH_ALL) { + const delegateTxs = tx.transaction.args.transactions?.filter( + (tx: Transaction) => TransactionType.DELEGATE === tx.type, + ); + const undelegateTxs = tx.transaction.args.transactions?.filter( + (tx: Transaction) => TransactionType.UNDELEGATE === tx.type, + ); + + coreTxs = delegateTxs?.length > 0 ? delegateTxs : undelegateTxs; + } else if (isDelegateTransaction(tx.transaction) || isUndelegateTransaction(tx.transaction)) { + coreTxs = [tx.transaction]; + } + + if (!coreTxs || coreTxs.length === 0) return; + + return coreTxs.map((tx: Transaction) => tx.args.track?.toString()); +}; + +export const getUndelegationData = async ( + api: ApiPromise, + tx: MultisigTransaction, +): Promise<{ votes: string | undefined; target: string | undefined }> => { + if (!tx.transaction || !api) return { votes: undefined, target: undefined }; + + let coreTx; + + if (isProxyTransaction(tx.transaction)) { + coreTx = tx.transaction.args.transaction; + } else if (tx.transaction.type === TransactionType.BATCH_ALL) { + coreTx = tx.transaction.args.transactions?.find((tx: Transaction) => tx.type === TransactionType.UNDELEGATE); + } else if (isUndelegateTransaction(tx.transaction)) { + coreTx = tx.transaction; + } + + if (!coreTx) return { votes: undefined, target: undefined }; + + const votes = await convictionVotingPallet.storage.votingFor(api, [[coreTx.address, coreTx.args.track]]); + + const delegation = votes.find((vote) => vote.type === 'Delegating'); + + return { + votes: + delegation && votingService.calculateVotingPower(delegation.data.balance, delegation.data.conviction).toString(), + target: delegation && toAddress(delegation.data.target), + }; +}; + +export const getReferendumId = (tx: MultisigTransaction): string | undefined => { + const coreTx = getCoreTx(tx); + + return coreTx?.args.referendum; +}; + +export const getVote = (tx: MultisigTransaction): TransactionVote | undefined => { + const coreTx = getCoreTx(tx); + + return coreTx?.args.vote; +}; + +const getCoreTx = (tx: MultisigTransaction): Transaction | DecodedTransaction | undefined => { + if (!tx.transaction) return undefined; + + if (isProxyTransaction(tx.transaction)) { + return tx.transaction.args.transaction; + } + + return tx.transaction; +}; diff --git a/src/renderer/pages/Operations/components/Status.tsx b/src/renderer/entities/operations/ui/Status.tsx similarity index 100% rename from src/renderer/pages/Operations/components/Status.tsx rename to src/renderer/entities/operations/ui/Status.tsx diff --git a/src/renderer/entities/operations/ui/index.ts b/src/renderer/entities/operations/ui/index.ts index d2fde64ba8..1db1ac352d 100644 --- a/src/renderer/entities/operations/ui/index.ts +++ b/src/renderer/entities/operations/ui/index.ts @@ -1,2 +1,3 @@ export { SignatorySelector } from './SignatorySelector'; export { SignButton } from './SignButton'; +export { Status } from './Status'; diff --git a/src/renderer/entities/proxy/lib/__tests__/mocks/proxy-mocks.ts b/src/renderer/entities/proxy/lib/__tests__/mocks/proxy-mocks.ts index a38bd98fd8..5902ca195e 100644 --- a/src/renderer/entities/proxy/lib/__tests__/mocks/proxy-mocks.ts +++ b/src/renderer/entities/proxy/lib/__tests__/mocks/proxy-mocks.ts @@ -1,5 +1,5 @@ import { type BaseAccount, type ProxyAccount, type ProxyDeposits, type Wallet, type WcAccount } from '@/shared/core'; -import { AccountType, ChainType, CryptoType, ProxyType, SigningType, WalletType } from '@/shared/core'; +import { AccountType, ChainType, CryptoType, SigningType, WalletType } from '@/shared/core'; import { TEST_ACCOUNTS } from '@/shared/lib/utils'; const oldProxy: ProxyAccount = { @@ -7,7 +7,7 @@ const oldProxy: ProxyAccount = { accountId: TEST_ACCOUNTS[0], proxiedAccountId: TEST_ACCOUNTS[1], chainId: '0x05', - proxyType: ProxyType.ANY, + proxyType: 'Any', delay: 0, }; @@ -16,7 +16,7 @@ const newProxy: ProxyAccount = { accountId: TEST_ACCOUNTS[1], proxiedAccountId: TEST_ACCOUNTS[2], chainId: '0x04', - proxyType: ProxyType.CANCEL_PROXY, + proxyType: 'CancelProxy', delay: 0, }; @@ -86,7 +86,7 @@ const proxyAccounts: ProxyAccount[] = [ accountId: '0x01', proxiedAccountId: '0x02', chainId: '0x05', - proxyType: ProxyType.CANCEL_PROXY, + proxyType: 'CancelProxy', delay: 0, }, { @@ -94,7 +94,7 @@ const proxyAccounts: ProxyAccount[] = [ accountId: '0x01', proxiedAccountId: '0x02', chainId: '0x05', - proxyType: ProxyType.GOVERNANCE, + proxyType: 'CancelProxy', delay: 0, }, { @@ -102,7 +102,7 @@ const proxyAccounts: ProxyAccount[] = [ accountId: '0x01', proxiedAccountId: '0x02', chainId: '0x05', - proxyType: ProxyType.NON_TRANSFER, + proxyType: 'NonTransfer', delay: 0, }, ]; diff --git a/src/renderer/entities/proxy/lib/__tests__/proxy-utils.test.ts b/src/renderer/entities/proxy/lib/__tests__/proxy-utils.test.ts index e1eb90acda..863737f75f 100644 --- a/src/renderer/entities/proxy/lib/__tests__/proxy-utils.test.ts +++ b/src/renderer/entities/proxy/lib/__tests__/proxy-utils.test.ts @@ -1,4 +1,4 @@ -import { type ProxiedAccount, ProxyType, ProxyVariant } from '@/shared/core'; +import { type ProxiedAccount, ProxyVariant } from '@/shared/core'; import { TEST_ACCOUNTS } from '@/shared/lib/utils'; import { proxyUtils } from '../proxy-utils'; @@ -22,7 +22,7 @@ describe('entities/proxy/lib/proxy-utils', () => { test('should return proxied name for a given proxied account', () => { const proxiedAccount = { accountId: TEST_ACCOUNTS[0], - proxyType: ProxyType.ANY, + proxyType: 'Any', proxyVariant: ProxyVariant.REGULAR, } as unknown as ProxiedAccount; diff --git a/src/renderer/entities/proxy/lib/constants.ts b/src/renderer/entities/proxy/lib/constants.ts index 0a0e383954..a816335b8a 100644 --- a/src/renderer/entities/proxy/lib/constants.ts +++ b/src/renderer/entities/proxy/lib/constants.ts @@ -1,12 +1,12 @@ -import { ProxyType } from '@/shared/core'; +import { type ProxyType } from '@/shared/core'; export const ProxyTypeName: Record = { - [ProxyType.ANY]: 'proxy.names.any', - [ProxyType.NON_TRANSFER]: 'proxy.names.nonTransfer', - [ProxyType.STAKING]: 'proxy.names.staking', - [ProxyType.AUCTION]: 'proxy.names.auction', - [ProxyType.CANCEL_PROXY]: 'proxy.names.cancelProxy', - [ProxyType.GOVERNANCE]: 'proxy.names.governance', - [ProxyType.IDENTITY_JUDGEMENT]: 'proxy.names.identityJudgement', - [ProxyType.NOMINATION_POOLS]: 'proxy.names.nominationPools', + Any: 'proxy.names.any', + NonTransfer: 'proxy.names.nonTransfer', + Staking: 'proxy.names.staking', + Auction: 'proxy.names.auction', + CancelProxy: 'proxy.names.cancelProxy', + Governance: 'proxy.names.governance', + IdentityJudgement: 'proxy.names.identityJudgement', + NominationPools: 'proxy.names.nominationPools', }; diff --git a/src/renderer/entities/proxy/lib/proxy-utils.ts b/src/renderer/entities/proxy/lib/proxy-utils.ts index 8ce2d9ad3b..0fc93c93be 100644 --- a/src/renderer/entities/proxy/lib/proxy-utils.ts +++ b/src/renderer/entities/proxy/lib/proxy-utils.ts @@ -1,14 +1,19 @@ import sortBy from 'lodash/sortBy'; +import uniqBy from 'lodash/uniqBy'; import { + type Account, + type AccountId, + type ChainId, type NoID, type PartialProxiedAccount, type ProxyAccount, type ProxyDeposits, type ProxyGroup, + type ProxyType, + ProxyVariant, type Wallet, } from '@/shared/core'; -import { ProxyType, ProxyVariant } from '@/shared/core'; import { splitCamelCaseString, toAddress } from '@/shared/lib/utils'; import { accountUtils } from '@/entities/wallet'; @@ -22,6 +27,7 @@ export const proxyUtils = { getProxyGroups, createProxyGroups, getProxyTypeName, + getProxyAccountsOnChain, }; function isSameProxy(oldProxy: ProxyAccount, newProxy: ProxyAccount): boolean { @@ -35,14 +41,14 @@ function isSameProxy(oldProxy: ProxyAccount, newProxy: ProxyAccount): boolean { } function sortAccountsByProxyType(accounts: ProxyAccount[]): ProxyAccount[] { const typeOrder = [ - ProxyType.ANY, - ProxyType.NON_TRANSFER, - ProxyType.STAKING, - ProxyType.AUCTION, - ProxyType.CANCEL_PROXY, - ProxyType.GOVERNANCE, - ProxyType.IDENTITY_JUDGEMENT, - ProxyType.NOMINATION_POOLS, + 'Any', + 'NonTransfer', + 'Staking', + 'Auction', + 'CancelProxy', + 'Governance', + 'IdentityJudgement', + 'NominationPools', ]; return sortBy(accounts, (account) => typeOrder.indexOf(account.proxyType)); @@ -128,3 +134,30 @@ function createProxyGroups(wallets: Wallet[], groups: ProxyGroup[], deposits: Pr function getProxyTypeName(proxyType: ProxyType | string): string { return ProxyTypeName[proxyType as ProxyType] || splitCamelCaseString(proxyType as string); } + +function getProxyAccountsOnChain(accounts: Account[], chains: ChainId[], proxies: Record) { + if (accounts.length === 0) return {}; + + const proxiesForAccounts = uniqBy(accounts, 'accountId').reduce((acc, account) => { + if (proxies[account.accountId]) { + acc.push(...proxies[account.accountId]); + } + + return acc; + }, []); + + const sortedProxiesAccount = sortAccountsByProxyType(proxiesForAccounts); + const chainsMap: Record = {}; + + return sortedProxiesAccount.reduce((acc, proxy) => { + if (chains.includes(proxy.chainId)) { + if (proxy.chainId in acc) { + acc[proxy.chainId].push(proxy); + } else { + acc[proxy.chainId] = [proxy]; + } + } + + return acc; + }, chainsMap); +} diff --git a/src/renderer/entities/proxy/model/__tests__/proxy-model.test.ts b/src/renderer/entities/proxy/model/__tests__/proxy-model.test.ts index 26f1cbacdf..fb2ffed38f 100644 --- a/src/renderer/entities/proxy/model/__tests__/proxy-model.test.ts +++ b/src/renderer/entities/proxy/model/__tests__/proxy-model.test.ts @@ -2,7 +2,6 @@ import { allSettled, fork } from 'effector'; import { storageService } from '@/shared/api/storage'; import { type AccountId, type HexString, type ProxyAccount, type ProxyGroup } from '@/shared/core'; -import { ProxyType } from '@/shared/core'; import { proxyModel } from '../proxy-model'; const proxyMock = { @@ -10,7 +9,7 @@ const proxyMock = { chainId: '0x00' as HexString, accountId: '0x00' as AccountId, proxiedAccountId: '0x01' as AccountId, - proxyType: ProxyType.ANY, + proxyType: 'Any', delay: 0, } as ProxyAccount; @@ -19,7 +18,7 @@ const newProxyMock = { chainId: '0x11' as HexString, accountId: '0x11' as AccountId, proxiedAccountId: '0x01' as AccountId, - proxyType: ProxyType.STAKING, + proxyType: 'Staking', delay: 0, } as ProxyAccount; diff --git a/src/renderer/entities/proxy/ui/ProxyAccount/ProxyAccount.stories.tsx b/src/renderer/entities/proxy/ui/ProxyAccount/ProxyAccount.stories.tsx index d806c099f8..b2fe560b02 100644 --- a/src/renderer/entities/proxy/ui/ProxyAccount/ProxyAccount.stories.tsx +++ b/src/renderer/entities/proxy/ui/ProxyAccount/ProxyAccount.stories.tsx @@ -1,6 +1,5 @@ import { type Meta, type StoryFn } from '@storybook/react'; -import { ProxyType } from '@/shared/core'; import { TEST_ACCOUNTS } from '@/shared/lib/utils'; import { ProxyAccount } from './ProxyAccount'; @@ -16,13 +15,13 @@ const Template: StoryFn = (args) => ({ describe('ui/AccountAddress', () => { test('should render component', () => { - render(); + render(); const addressValue = screen.getByText(TEST_ADDRESS); expect(addressValue).toBeInTheDocument(); }); test('should render short component', () => { - render(); + render(); const shortAddress = TEST_ADDRESS.slice(0, 8) + '...' + TEST_ADDRESS.slice(TEST_ADDRESS.length - 8); diff --git a/src/renderer/entities/transaction/lib/transactionBuilder.ts b/src/renderer/entities/transaction/lib/transactionBuilder.ts index 4a13f8eb9f..dee232258f 100644 --- a/src/renderer/entities/transaction/lib/transactionBuilder.ts +++ b/src/renderer/entities/transaction/lib/transactionBuilder.ts @@ -3,16 +3,19 @@ import { type ApiPromise } from '@polkadot/api'; import { type ClaimAction } from '@/shared/api/governance'; import { type MultisigTransactionDS } from '@/shared/api/storage'; import { + type Account, type AccountId, type Address, type Asset, type Chain, type ChainId, type Conviction, + type MultisigAccount, type ReferendumId, type TrackId, type Transaction, TransactionType, + WrapperKind, } from '@/shared/core'; import { TEST_ACCOUNTS, formatAmount, getAssetId, toAccountId, toAddress } from '@/shared/lib/utils'; import { type RevoteTransaction, type TransactionVote, type VoteTransaction } from '@/entities/governance'; @@ -38,6 +41,8 @@ export const transactionBuilder = { buildRemoveVote, buildRemoveVotes, buildRejectMultisigTx, + buildCreatePureProxy, + buildCreateFlexibleMultisig, buildBatchAll, splitBatchAll, @@ -480,7 +485,6 @@ function buildRemoveVotes({ chain, accountId, votes }: RemoveVotesParams): Trans return buildBatchAll({ chain, accountId, transactions }); } - type RejectTxParams = { chain: Chain; signerAddress: Address; @@ -504,3 +508,79 @@ function buildRejectMultisigTx({ chain, signerAddress, threshold, otherSignatori }, }; } + +type CreateProxyPureParams = { + chain: Chain; + accountId: AccountId; +}; + +function buildCreatePureProxy({ chain, accountId }: CreateProxyPureParams): Transaction { + return { + chainId: chain.chainId, + address: toAddress(accountId, { prefix: chain.addressPrefix }), + type: TransactionType.CREATE_PURE_PROXY, + args: { proxyType: 'Any', delay: 0, index: 0 }, + }; +} + +type CreateFlexibleMultisigParams = { + chain: Chain; + signer: Account; + api: ApiPromise; + multisigAccountId: AccountId; + threshold: number; + proxyDeposit: string; + signatories: { + accountId: AccountId; + address: Address; + }[]; +}; + +function buildCreateFlexibleMultisig({ + api, + chain, + multisigAccountId, + threshold, + signatories, + signer, + proxyDeposit, +}: CreateFlexibleMultisigParams): Transaction { + const proxyTransaction = transactionBuilder.buildCreatePureProxy({ + chain: chain, + accountId: signer.accountId, + }); + + const wrappedTransaction = transactionService.getWrappedTransaction({ + api: api, + addressPrefix: chain.addressPrefix, + transaction: proxyTransaction, + txWrappers: [ + { + kind: WrapperKind.MULTISIG, + multisigAccount: { + accountId: multisigAccountId, + signatories, + threshold, + } as MultisigAccount, + signatories: signatories.map((s) => ({ + accountId: toAccountId(s.address), + })) as Account[], + signer, + }, + ], + }); + + const transferTransaction = { + chainId: chain.chainId, + address: toAddress(signer.accountId, { prefix: chain.addressPrefix }), + type: TransactionType.TRANSFER, + args: { + dest: toAddress(multisigAccountId, { prefix: chain.addressPrefix }), + value: proxyDeposit, + }, + }; + + const transactions = [wrappedTransaction.wrappedTx, transferTransaction]; + + return buildBatchAll({ chain, accountId: signer.accountId, transactions }); +} diff --git a/src/renderer/entities/transaction/lib/transactionService.ts b/src/renderer/entities/transaction/lib/transactionService.ts index dbabb96b6d..7fa46d19b8 100644 --- a/src/renderer/entities/transaction/lib/transactionService.ts +++ b/src/renderer/entities/transaction/lib/transactionService.ts @@ -177,7 +177,7 @@ type TxWrappersParams = { * @returns {Array} */ function getTxWrappers({ wallet, ...params }: TxWrappersParams): TxWrapper[] { - if (walletUtils.isMultisig(wallet)) { + if (walletUtils.isRegularMultisig(wallet)) { return getMultisigWrapper(params); } diff --git a/src/renderer/entities/transaction/ui/Fee/Fee.tsx b/src/renderer/entities/transaction/ui/Fee/Fee.tsx index b02738faa1..7ff0041da9 100644 --- a/src/renderer/entities/transaction/ui/Fee/Fee.tsx +++ b/src/renderer/entities/transaction/ui/Fee/Fee.tsx @@ -13,7 +13,7 @@ type Props = { api: ApiPromise | null; multiply?: number; asset: Asset; - transaction?: Transaction; + transaction?: Transaction | null; className?: string; onFeeChange?: (fee: string) => void; onFeeLoading?: (loading: boolean) => void; diff --git a/src/renderer/entities/wallet/lib/__tests__/mocks/permission-mock.ts b/src/renderer/entities/wallet/lib/__tests__/mocks/permission-mock.ts index 7895a0520c..f145c61a8d 100644 --- a/src/renderer/entities/wallet/lib/__tests__/mocks/permission-mock.ts +++ b/src/renderer/entities/wallet/lib/__tests__/mocks/permission-mock.ts @@ -6,7 +6,6 @@ import { type PolkadotVaultWallet, type ProxiedAccount, type ProxiedWallet, - ProxyType, type SingleShardWallet, type WalletConnectWallet, WalletType, @@ -59,42 +58,42 @@ const proxiedWallet = { const anyProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.ANY, + proxyType: 'Any', } as ProxiedAccount; const nonTransferProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.NON_TRANSFER, + proxyType: 'NonTransfer', } as ProxiedAccount; const stakingProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.STAKING, + proxyType: 'Staking', } as ProxiedAccount; const auctionProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.AUCTION, + proxyType: 'Auction', } as ProxiedAccount; const cancelProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.CANCEL_PROXY, + proxyType: 'CancelProxy', } as ProxiedAccount; const governanceProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.GOVERNANCE, + proxyType: 'Governance', } as ProxiedAccount; const identityJudgementProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.IDENTITY_JUDGEMENT, + proxyType: 'IdentityJudgement', } as ProxiedAccount; const nominationPoolsProxyAccount = { type: AccountType.PROXIED, - proxyType: ProxyType.NOMINATION_POOLS, + proxyType: 'NominationPools', } as ProxiedAccount; export const permissionMocks = { diff --git a/src/renderer/entities/wallet/lib/__tests__/wallet-utils.test.ts b/src/renderer/entities/wallet/lib/__tests__/wallet-utils.test.ts index bfc5e8c02d..c54bfe268e 100644 --- a/src/renderer/entities/wallet/lib/__tests__/wallet-utils.test.ts +++ b/src/renderer/entities/wallet/lib/__tests__/wallet-utils.test.ts @@ -29,13 +29,25 @@ describe('entities/wallet/lib/wallet-utils', () => { test('isMultisig should return true when wallet type is Multisig', () => { const wallet = { type: WalletType.MULTISIG } as Wallet; + expect(walletUtils.isRegularMultisig(wallet)).toEqual(true); + }); + + test('isFlexibleMultisig should return true when wallet type is Flexible Multisig', () => { + const wallet = { type: WalletType.FLEXIBLE_MULTISIG } as Wallet; + + expect(walletUtils.isFlexibleMultisig(wallet)).toEqual(true); + }); + + test('isMultisig should return true when wallet type is Flexible Multisig', () => { + const wallet = { type: WalletType.FLEXIBLE_MULTISIG } as Wallet; + expect(walletUtils.isMultisig(wallet)).toEqual(true); }); test('isMultisig should return false when wallet type is not Multisig', () => { const wallet = { type: WalletType.NOVA_WALLET } as Wallet; - expect(walletUtils.isMultisig(wallet)).toEqual(false); + expect(walletUtils.isRegularMultisig(wallet)).toEqual(false); }); test('isNovaWallet should return true when wallet type is NovaWallet', () => { @@ -47,7 +59,7 @@ describe('entities/wallet/lib/wallet-utils', () => { test('isNovaWallet should return false when wallet type is not NovaWallet', () => { const wallet = { type: WalletType.POLKADOT_VAULT } as Wallet; - expect(walletUtils.isMultisig(wallet)).toEqual(false); + expect(walletUtils.isRegularMultisig(wallet)).toEqual(false); }); test('isProxied should return true when wallet type is Proxied', () => { diff --git a/src/renderer/entities/wallet/lib/account-utils.ts b/src/renderer/entities/wallet/lib/account-utils.ts index 8d52f4e7b9..b0adfee2b2 100644 --- a/src/renderer/entities/wallet/lib/account-utils.ts +++ b/src/renderer/entities/wallet/lib/account-utils.ts @@ -10,6 +10,7 @@ import { type Chain, type ChainAccount, type ChainId, + type FlexibleMultisigAccount, type ID, type MultisigAccount, type MultisigThreshold, @@ -18,7 +19,7 @@ import { type Wallet, type WcAccount, } from '@/shared/core'; -import { AccountType, ChainType, CryptoType, ProxyType, ProxyVariant } from '@/shared/core'; +import { AccountType, ChainType, CryptoType, ProxyVariant } from '@/shared/core'; import { toAddress } from '@/shared/lib/utils'; import { networkUtils } from '@/entities/network'; @@ -27,6 +28,8 @@ import { walletUtils } from './wallet-utils'; export const accountUtils = { isBaseAccount, isChainAccount, + isRegularMultisigAccount, + isFlexibleMultisigAccount, isMultisigAccount, isWcAccount, isProxiedAccount, @@ -72,10 +75,18 @@ function isShardAccount(account: Partial): account is ShardAccount { return account.type === AccountType.SHARD; } -function isMultisigAccount(account: Partial): account is MultisigAccount { +function isRegularMultisigAccount(account: Partial): account is MultisigAccount { return account.type === AccountType.MULTISIG; } +function isFlexibleMultisigAccount(account: Partial): account is FlexibleMultisigAccount { + return account.type === AccountType.FLEXIBLE_MULTISIG; +} + +function isMultisigAccount(account: Partial): account is MultisigAccount | FlexibleMultisigAccount { + return isFlexibleMultisigAccount(account) || isRegularMultisigAccount(account); +} + function isProxiedAccount(account: Partial): account is ProxiedAccount { return account.type === AccountType.PROXIED; } @@ -184,19 +195,19 @@ function getDerivationPath(data: DerivationPathLike | DerivationPathLike[]): str // Proxied accounts function isAnyProxyType(account: ProxiedAccount): boolean { - return account.proxyType === ProxyType.ANY; + return account.proxyType === 'Any'; } function isNonTransferProxyType(account: ProxiedAccount): boolean { - return account.proxyType === ProxyType.NON_TRANSFER; + return account.proxyType === 'NonTransfer'; } function isStakingProxyType(account: ProxiedAccount): boolean { - return account.proxyType === ProxyType.STAKING; + return account.proxyType === 'Staking'; } function isGovernanceProxyType(account: ProxiedAccount): boolean { - return account.proxyType === ProxyType.GOVERNANCE; + return account.proxyType === 'Governance'; } function isNonBaseVaultAccount(account: Account, wallet: Wallet): boolean { diff --git a/src/renderer/entities/wallet/lib/wallet-utils.ts b/src/renderer/entities/wallet/lib/wallet-utils.ts index 3e560f82fc..b13d292b56 100644 --- a/src/renderer/entities/wallet/lib/wallet-utils.ts +++ b/src/renderer/entities/wallet/lib/wallet-utils.ts @@ -1,5 +1,6 @@ import { type Account, + type FlexibleMultisigWallet, type ID, type MultiShardWallet, type MultisigWallet, @@ -20,6 +21,8 @@ export const walletUtils = { isMultiShard, isSingleShard, isMultisig, + isFlexibleMultisig, + isRegularMultisig, isWatchOnly, isNovaWallet, isWalletConnect, @@ -33,6 +36,7 @@ export const walletUtils = { getAccountBy, getAccountsBy, + getAllAccounts, getWalletFilteredAccounts, getWalletsFilteredAccounts, }; @@ -51,10 +55,18 @@ function isSingleShard(wallet?: Wallet): wallet is SingleShardWallet { return wallet?.type === WalletType.SINGLE_PARITY_SIGNER; } -function isMultisig(wallet?: Wallet): wallet is MultisigWallet { +function isFlexibleMultisig(wallet?: Wallet): wallet is FlexibleMultisigWallet { + return wallet?.type === WalletType.FLEXIBLE_MULTISIG; +} + +function isRegularMultisig(wallet?: Wallet): wallet is FlexibleMultisigWallet { return wallet?.type === WalletType.MULTISIG; } +function isMultisig(wallet?: Wallet): wallet is MultisigWallet | FlexibleMultisigWallet { + return isFlexibleMultisig(wallet) || isRegularMultisig(wallet); +} + function isWatchOnly(wallet?: Wallet): wallet is WatchOnlyWallet { return wallet?.type === WalletType.WATCH_ONLY; } @@ -113,6 +125,10 @@ function getAccountsBy(wallets: Wallet[], accountFn: (account: Account, wallet: }, []); } +function getAllAccounts(wallets: Wallet[]): Account[] { + return wallets.reduce((acc, wallet) => acc.concat(wallet.accounts), []); +} + function getAccountBy(wallets: Wallet[], accountFn: (account: Account, wallet: Wallet) => boolean): Account | null { for (const wallet of wallets) { const account = wallet.accounts.find((account) => accountFn(account, wallet)); diff --git a/src/renderer/entities/wallet/model/wallet-model.ts b/src/renderer/entities/wallet/model/wallet-model.ts index 50bdfd9190..e4c1a722b0 100644 --- a/src/renderer/entities/wallet/model/wallet-model.ts +++ b/src/renderer/entities/wallet/model/wallet-model.ts @@ -7,6 +7,7 @@ import { type Account, type BaseAccount, type ChainAccount, + type FlexibleMultisigAccount, type ID, type MultisigAccount, type NoID, @@ -20,7 +21,7 @@ import { modelUtils } from '../lib/model-utils'; type DbWallet = Omit; -type CreateParams = { +export type CreateParams = { wallet: Omit, 'isActive' | 'accounts'>; accounts: Omit, 'walletId'>[]; // external means wallet was created by someone else and discovered later @@ -33,8 +34,9 @@ const watchOnlyCreated = createEvent>(); const multishardCreated = createEvent>(); const singleshardCreated = createEvent>(); const multisigCreated = createEvent>(); -const proxiedCreated = createEvent>(); +const flexibleMultisigCreated = createEvent>(); const walletConnectCreated = createEvent>(); +const proxiedCreated = createEvent>(); const walletRestored = createEvent(); const walletHidden = createEvent(); @@ -231,7 +233,14 @@ const walletCreationFail = sample({ }).filter({ fn: nonNullable }); sample({ - clock: [walletConnectCreated, watchOnlyCreated, multisigCreated, singleshardCreated, proxiedCreated], + clock: [ + walletConnectCreated, + watchOnlyCreated, + multisigCreated, + flexibleMultisigCreated, + singleshardCreated, + proxiedCreated, + ], target: walletCreatedFx, }); @@ -377,6 +386,7 @@ export const walletModel = { multishardCreated, singleshardCreated, multisigCreated, + flexibleMultisigCreated, walletConnectCreated, proxiedCreated, walletCreatedDone, @@ -395,5 +405,6 @@ export const walletModel = { _test: { $allWallets, + walletCreatedFx, }, }; diff --git a/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx b/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx index 6ee62c9167..e8ca0d3753 100644 --- a/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx +++ b/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx @@ -3,7 +3,7 @@ import { type ReactNode } from 'react'; import { type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { cnTw } from '@/shared/lib/utils'; -import { FootnoteText, StatusLabel } from '@/shared/ui'; +import { BodyText, FootnoteText, StatusLabel } from '@/shared/ui'; import { walletUtils } from '../../lib/wallet-utils'; import { WalletIcon } from '../WalletIcon/WalletIcon'; @@ -33,7 +33,7 @@ export const WalletCardLg = ({ wallet, description, full, className }: Props) => )}
- {wallet.name} + {wallet.name} {typeof description === 'string' ? ( {description} ) : ( diff --git a/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx b/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx index a1d2d0dae5..cee06e2f0c 100644 --- a/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx +++ b/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx @@ -1,8 +1,8 @@ -import { type MouseEvent, type ReactNode } from 'react'; +import { type MouseEvent, type PropsWithChildren, type ReactNode } from 'react'; import { type Wallet } from '@/shared/core'; -import { cnTw } from '@/shared/lib/utils'; -import { FootnoteText, IconButton } from '@/shared/ui'; +import { cnTw, nonNullable, nullable } from '@/shared/lib/utils'; +import { BodyText, FootnoteText } from '@/shared/ui'; import { walletUtils } from '../../lib/wallet-utils'; import { WalletIcon } from '../WalletIcon/WalletIcon'; @@ -11,12 +11,17 @@ type Props = { description?: string | ReactNode; prefix?: ReactNode; hideIcon?: boolean; - className?: string; onClick?: () => void; - onInfoClick?: () => void; }; -export const WalletCardMd = ({ wallet, description, prefix, hideIcon, className, onClick, onInfoClick }: Props) => { +export const WalletCardMd = ({ + wallet, + description, + prefix, + hideIcon, + children, + onClick, +}: PropsWithChildren) => { const isWalletConnect = walletUtils.isWalletConnectGroup(wallet); const handleClick = (fn?: () => void) => { @@ -33,19 +38,28 @@ export const WalletCardMd = ({ wallet, description, prefix, hideIcon, className, className={cnTw( 'group relative flex w-full items-center rounded transition-colors', 'focus-within:bg-action-background-hover hover:bg-action-background-hover', - className, )} > - {onInfoClick && ( - - )} +
+ {children} +
); }; diff --git a/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx b/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx index c38c06329e..81fc942d4c 100644 --- a/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx +++ b/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx @@ -42,6 +42,7 @@ export const WalletCardSm = ({ wallet, className, iconSize = 16, onClick, onInfo {wallet.name} + {/* TODO: do the same as in WalletCardMd */} ); diff --git a/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx b/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx index 6272a93ee7..02cda65337 100644 --- a/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx +++ b/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx @@ -1,67 +1,87 @@ -import { type MouseEvent } from 'react'; +import { type PropsWithChildren } from 'react'; import { type AccountId, type Address, type KeyType } from '@/shared/core'; -import { cnTw, toAddress } from '@/shared/lib/utils'; -import { BodyText, HelpText, Icon, IconButton, Identicon } from '@/shared/ui'; +import { cnTw, nonNullable, toAddress } from '@/shared/lib/utils'; +import { BodyText, HelpText, Icon, Identicon } from '@/shared/ui'; +import { Hash } from '@/shared/ui-entities'; import { KeyIcon } from '../../lib/constants'; -type Props = { +type Props = PropsWithChildren<{ name?: string; address: Address | AccountId; addressPrefix?: number; keyType?: KeyType; - size?: number; + iconSize?: number; className?: string; hideAddress?: boolean; - onInfoClick?: () => void; -}; +}>; + export const ContactItem = ({ name, address, addressPrefix, - size = 20, + iconSize = 20, hideAddress = false, keyType, - className, - onInfoClick, + children, }: Props) => { const formattedAddress = toAddress(address, { prefix: addressPrefix }); - const handleClick = (event: MouseEvent) => { - event.stopPropagation(); - }; - return ( -
-
+
+
- + {keyType && ( )}
-
- {name && ( +
+ {name ? ( {name} + ) : ( + + + + )} + + {nonNullable(name) && !hideAddress && ( + {formattedAddress} )} - {!hideAddress && {formattedAddress}}
- +
+ {children} +
); }; diff --git a/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx b/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx index 4a58bb4fbe..e151e472d3 100644 --- a/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx +++ b/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx @@ -61,6 +61,10 @@ const PopoverGroup = ({ title, active = true, children }: PropsWithChildren diff --git a/src/renderer/entities/wallet/ui/WalletIcon/WalletIcon.tsx b/src/renderer/entities/wallet/ui/WalletIcon/WalletIcon.tsx index b89ad84bdc..eeef056f46 100644 --- a/src/renderer/entities/wallet/ui/WalletIcon/WalletIcon.tsx +++ b/src/renderer/entities/wallet/ui/WalletIcon/WalletIcon.tsx @@ -7,6 +7,7 @@ const WalletIconNames: Record = { [WalletType.SINGLE_PARITY_SIGNER]: 'vaultBackground', [WalletType.WATCH_ONLY]: 'watchOnlyBackground', [WalletType.MULTISIG]: 'multisigBackground', + [WalletType.FLEXIBLE_MULTISIG]: 'flexibleMultisigBackground', [WalletType.MULTISHARD_PARITY_SIGNER]: 'vaultBackground', [WalletType.WALLET_CONNECT]: 'walletConnectBackground', [WalletType.NOVA_WALLET]: 'novaWalletBackground', diff --git a/src/renderer/features/assets-balances/index.ts b/src/renderer/features/assets-balances/index.ts new file mode 100644 index 0000000000..eff0e4a580 --- /dev/null +++ b/src/renderer/features/assets-balances/index.ts @@ -0,0 +1,2 @@ +export { balanceSubModel } from './subscription'; +export { AmountInput } from './components/AmountInput'; diff --git a/src/renderer/features/balances/subscription/index.ts b/src/renderer/features/assets-balances/subscription/index.ts similarity index 100% rename from src/renderer/features/balances/subscription/index.ts rename to src/renderer/features/assets-balances/subscription/index.ts diff --git a/src/renderer/features/balances/subscription/lib/balance-sub-utils.ts b/src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts similarity index 98% rename from src/renderer/features/balances/subscription/lib/balance-sub-utils.ts rename to src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts index fe50a46242..89f6129c8d 100644 --- a/src/renderer/features/balances/subscription/lib/balance-sub-utils.ts +++ b/src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts @@ -13,7 +13,7 @@ export const balanceSubUtils = { function getSiblingAccounts(wallet: Wallet, wallets: Wallet[], chains: Record): Account[] { if (walletUtils.isMultisig(wallet)) { - const signatoriesMap = dictionary(wallet.accounts[0].signatories, 'accountId'); + const signatoriesMap = dictionary(wallet.accounts[0].signatories, 'accountId', true); const signatories = walletUtils.getAccountsBy(wallets, (account) => signatoriesMap[account.accountId]); return wallet.accounts.concat(uniqBy(signatories, 'accountId') as MultisigAccount[]); diff --git a/src/renderer/features/balances/subscription/lib/types.ts b/src/renderer/features/assets-balances/subscription/lib/types.ts similarity index 100% rename from src/renderer/features/balances/subscription/lib/types.ts rename to src/renderer/features/assets-balances/subscription/lib/types.ts diff --git a/src/renderer/features/balances/subscription/model/__tests__/balance-sub-model.test.ts b/src/renderer/features/assets-balances/subscription/model/__tests__/balance-sub-model.test.ts similarity index 100% rename from src/renderer/features/balances/subscription/model/__tests__/balance-sub-model.test.ts rename to src/renderer/features/assets-balances/subscription/model/__tests__/balance-sub-model.test.ts diff --git a/src/renderer/features/balances/subscription/model/__tests__/mocks/balance-sub-mock.ts b/src/renderer/features/assets-balances/subscription/model/__tests__/mocks/balance-sub-mock.ts similarity index 100% rename from src/renderer/features/balances/subscription/model/__tests__/mocks/balance-sub-mock.ts rename to src/renderer/features/assets-balances/subscription/model/__tests__/mocks/balance-sub-mock.ts diff --git a/src/renderer/features/balances/subscription/model/balance-sub-model.ts b/src/renderer/features/assets-balances/subscription/model/balance-sub-model.ts similarity index 100% rename from src/renderer/features/balances/subscription/model/balance-sub-model.ts rename to src/renderer/features/assets-balances/subscription/model/balance-sub-model.ts diff --git a/src/renderer/features/assets-navigation/index.ts b/src/renderer/features/assets-navigation/index.ts index 3aa727aa35..0afded795e 100644 --- a/src/renderer/features/assets-navigation/index.ts +++ b/src/renderer/features/assets-navigation/index.ts @@ -6,7 +6,7 @@ import { Paths } from '@/shared/routes'; import { navigationTopLinksPipeline } from '@/features/app-shell'; export const assetsNavigationFeature = createFeature({ - name: 'Assets navigation', + name: 'assets/navigation', input: createStore({}), enable: $features.map(({ assets }) => assets), }); diff --git a/src/renderer/features/assets/AssetsSearch/ui/AssetsSearch.tsx b/src/renderer/features/assets/AssetsSearch/ui/AssetsSearch.tsx index d4fda811ca..2df99b58d4 100644 --- a/src/renderer/features/assets/AssetsSearch/ui/AssetsSearch.tsx +++ b/src/renderer/features/assets/AssetsSearch/ui/AssetsSearch.tsx @@ -1,7 +1,7 @@ import { useUnit } from 'effector-react'; import { useI18n } from '@/shared/i18n'; -import { SearchInput } from '@/shared/ui'; +import { Box, SearchInput } from '@/shared/ui-kit'; import { assetsSearchModel } from '../model/assets-search-model'; export const AssetsSearch = () => { @@ -10,11 +10,12 @@ export const AssetsSearch = () => { const query = useUnit(assetsSearchModel.$query); return ( - + + + ); }; diff --git a/src/renderer/features/balances/index.ts b/src/renderer/features/balances/index.ts deleted file mode 100644 index e924628eb8..0000000000 --- a/src/renderer/features/balances/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { balanceSubModel } from './subscription'; diff --git a/src/renderer/features/contacts-navigation/index.ts b/src/renderer/features/contacts-navigation/index.ts index 158a89d4b2..45f0e61949 100644 --- a/src/renderer/features/contacts-navigation/index.ts +++ b/src/renderer/features/contacts-navigation/index.ts @@ -4,7 +4,7 @@ import { Paths } from '@/shared/routes'; import { navigationTopLinksPipeline } from '@/features/app-shell'; export const contactsNavigationFeature = createFeature({ - name: 'Contacts navigation', + name: 'contacts/navigation', enable: $features.map(({ contacts }) => contacts), }); diff --git a/src/renderer/features/contacts/ContactFilter/model/contact-filter.ts b/src/renderer/features/contacts/ContactFilter/model/contact-filter.ts index a80b197fe6..bb2c022566 100644 --- a/src/renderer/features/contacts/ContactFilter/model/contact-filter.ts +++ b/src/renderer/features/contacts/ContactFilter/model/contact-filter.ts @@ -1,27 +1,27 @@ -import { combine, createEvent, createStore, sample } from 'effector'; +import { combine, createEvent, restore, sample } from 'effector'; import { includes } from '@/shared/lib/utils'; import { contactModel } from '@/entities/contact'; const formInitiated = createEvent(); - -const $filterQuery = createStore(''); const queryChanged = createEvent(); +const $query = restore(queryChanged, ''); + sample({ clock: formInitiated, - target: $filterQuery.reinit, + target: $query.reinit, }); sample({ clock: queryChanged, - target: $filterQuery, + target: $query, }); const $contactsFiltered = combine( { contacts: contactModel.$contacts, - query: $filterQuery, + query: $query, }, ({ contacts, query }) => { return contacts @@ -36,6 +36,7 @@ const $contactsFiltered = combine( ); export const filterModel = { + $query, $contactsFiltered, events: { diff --git a/src/renderer/features/contacts/ContactFilter/ui/ContactFilter.tsx b/src/renderer/features/contacts/ContactFilter/ui/ContactFilter.tsx index a3a413850e..37c0801b4a 100644 --- a/src/renderer/features/contacts/ContactFilter/ui/ContactFilter.tsx +++ b/src/renderer/features/contacts/ContactFilter/ui/ContactFilter.tsx @@ -1,21 +1,26 @@ +import { useUnit } from 'effector-react'; import { useEffect } from 'react'; import { useI18n } from '@/shared/i18n'; -import { SearchInput } from '@/shared/ui'; +import { Box, SearchInput } from '@/shared/ui-kit'; import { filterModel } from '../model/contact-filter'; export const ContactFilter = () => { const { t } = useI18n(); + const query = useUnit(filterModel.$query); + useEffect(() => { filterModel.events.formInitiated(); }, []); return ( - + + + ); }; diff --git a/src/renderer/features/contacts/CreateContactForm/ui/CreateContactForm.tsx b/src/renderer/features/contacts/CreateContactForm/ui/CreateContactForm.tsx index 6c01193497..b1033a2a55 100644 --- a/src/renderer/features/contacts/CreateContactForm/ui/CreateContactForm.tsx +++ b/src/renderer/features/contacts/CreateContactForm/ui/CreateContactForm.tsx @@ -3,7 +3,8 @@ import { useUnit } from 'effector-react'; import { type FormEvent, useEffect } from 'react'; import { useI18n } from '@/shared/i18n'; -import { Button, Icon, Identicon, Input, InputHint } from '@/shared/ui'; +import { Button, Icon, Identicon, InputHint } from '@/shared/ui'; +import { Field, Input } from '@/shared/ui-kit'; import { type Callbacks, createFormModel } from '../model/contact-form'; type Props = Callbacks; @@ -35,12 +36,9 @@ export const CreateContactForm = ({ onSubmit }: Props) => { return (
-
+ { {t(name.errorText())} -
+ -
+ { {t(address.errorText())} -
+ + +
+
+ + ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/FlexibleMultisigWallet.tsx b/src/renderer/features/flexible-multisig-create/components/FlexibleMultisigWallet.tsx new file mode 100644 index 0000000000..a8f68d7539 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/FlexibleMultisigWallet.tsx @@ -0,0 +1,69 @@ +import { useForm } from 'effector-forms'; +import { useGate, useUnit } from 'effector-react'; + +import { useI18n } from '@/shared/i18n'; +import { Step, isStep } from '@/shared/lib/utils'; +import { Modal } from '@/shared/ui-kit'; +import { ChainTitle } from '@/entities/chain'; +import { OperationSign, OperationSubmit } from '@/features/operations'; +import { flexibleMultisigModel } from '../model/flexible-multisig-create'; +import { formModel } from '../model/form-model'; +import { flexibleMultisigFeature } from '../model/status'; + +import { ConfirmationStep } from './ConfirmationStep'; +import { NameNetworkSelection } from './NameNetworkSelection'; +import { SelectSignatoriesThreshold } from './SelectThreshold/SelectSignatoriesThreshold'; +import { SignerSelection } from './SignerSelection'; + +type Props = { + isOpen: boolean; + onClose: () => void; + onGoBack: () => void; +}; + +export const FlexibleMultisigWallet = ({ isOpen, onClose, onGoBack }: Props) => { + const { t } = useI18n(); + useGate(flexibleMultisigFeature.gate); + + const activeStep = useUnit(flexibleMultisigModel.$step); + const { + fields: { chainId }, + } = useForm(formModel.$createMultisigForm); + + if (isStep(activeStep, Step.SUBMIT)) { + return ; + } + + const modalTitle = ( +
+ {isStep(activeStep, Step.SIGNER_SELECTION) + ? t('createMultisigAccount.selectSigner') + : t('createMultisigAccount.flexibleMultisig.title')} + {!isStep(activeStep, Step.NAME_NETWORK) && !isStep(activeStep, Step.SIGNER_SELECTION) && ( + <> + {t('createMultisigAccount.titleOn')} + + + )} +
+ ); + + return ( + <> + {modalTitle} + + {isStep(activeStep, Step.NAME_NETWORK) && } + {isStep(activeStep, Step.SIGNATORIES_THRESHOLD) && } + {isStep(activeStep, Step.SIGNER_SELECTION) && } + {isStep(activeStep, Step.CONFIRM) && } + {isStep(activeStep, Step.SIGN) && ( + flexibleMultisigModel.events.stepChanged(Step.CONFIRM)} /> + )} + + + ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/MultisigFees.tsx b/src/renderer/features/flexible-multisig-create/components/MultisigFees.tsx new file mode 100644 index 0000000000..d53c5d4d4d --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/MultisigFees.tsx @@ -0,0 +1,72 @@ +import { useUnit } from 'effector-react'; +import { memo } from 'react'; + +import { type Asset } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { DetailRow, FootnoteText, IconButton } from '@/shared/ui'; +import { Tooltip } from '@/shared/ui-kit'; +import { AssetBalance } from '@/entities/asset'; +import { FeeLoader } from '@/entities/transaction'; +import { flexibleMultisigModel } from '../model/flexible-multisig-create'; + +type Props = { + asset: Asset; +}; + +export const MultisigFees = memo(({ asset }: Props) => { + const { t } = useI18n(); + + const fee = useUnit(flexibleMultisigModel.$fee); + const multisigDeposit = useUnit(flexibleMultisigModel.$multisigDeposit); + const proxyDeposit = useUnit(flexibleMultisigModel.$proxyDeposit); + const isLoading = useUnit(flexibleMultisigModel.$isLoading); + + const totalFee = multisigDeposit.add(fee).add(proxyDeposit); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + {t('createMultisigAccount.multisigCreationFeeLabel')} + + + + + + +
+
+ {t('createMultisigAccount.flexibleMultisig.proxyDeposit')} + +
+
+ {t('createMultisigAccount.multisigDeposit')} + +
+
+ {t('createMultisigAccount.networkFee')} + +
+
+
+
+ + } + className="text-text-primary" + wrapperClassName="w-auto mx-4" + > +
+ +
+
+ ); +}); diff --git a/src/renderer/features/flexible-multisig-create/components/NameNetworkSelection.tsx b/src/renderer/features/flexible-multisig-create/components/NameNetworkSelection.tsx new file mode 100644 index 0000000000..1a71ca6dfb --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/NameNetworkSelection.tsx @@ -0,0 +1,95 @@ +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; + +import { type ChainId } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { Step } from '@/shared/lib/utils'; +import { Button, FootnoteText, InputHint, SmallTitleText } from '@/shared/ui'; +import { Box, Field, Input, Select } from '@/shared/ui-kit'; +import { ChainTitle } from '@/entities/chain'; +import { networkModel, networkUtils } from '@/entities/network'; +import { flexibleMultisigModel } from '../model/flexible-multisig-create'; +import { formModel } from '../model/form-model'; + +import { MultisigFees } from './MultisigFees'; + +interface Props { + onGoBack: () => void; +} + +export const NameNetworkSelection = ({ onGoBack }: Props) => { + const { t } = useI18n(); + + const chains = useUnit(networkModel.$chains); + const chain = useUnit(formModel.$chain); + + const { + fields: { name, chainId }, + } = useForm(formModel.$createMultisigForm); + + const isNameError = name.isTouched && !name.value; + const asset = chain?.assets.at(0); + + return ( +
+ + {t('createMultisigAccount.multisigStep', { step: 1 })} {t('createMultisigAccount.nameNetworkDescription')} + + +
+ + + + + + + {t('createMultisigAccount.disabledError.emptyName')} + +
+
+ + + + + + + {t('createMultisigAccount.networkDescription')} + +
+
+ +
+ {asset ? : null} + +
+
+ +
+ ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatories.tsx b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatories.tsx new file mode 100644 index 0000000000..12469edc8a --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatories.tsx @@ -0,0 +1,46 @@ +import { useUnit } from 'effector-react'; + +import { useI18n } from '@/shared/i18n'; +import { Button, Icon } from '@/shared/ui'; +import { signatoryModel } from '../../model/signatory-model'; + +import { Signatory } from './Signatory'; + +export const SelectSignatories = () => { + const { t } = useI18n(); + + const signatories = useUnit(signatoryModel.$signatories); + + const onAddSignatoryClick = () => { + signatoryModel.events.addSignatory({ name: '', address: '', walletId: '' }); + }; + + return ( +
+
+ {signatories.map((value, index) => ( + + ))} +
+
+ +
+
+ ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatoriesThreshold.tsx b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatoriesThreshold.tsx new file mode 100644 index 0000000000..209e01d8ef --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatoriesThreshold.tsx @@ -0,0 +1,193 @@ +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; +import { type FormEvent, useState } from 'react'; + +import { useI18n } from '@/shared/i18n'; +import { Step, nonNullable } from '@/shared/lib/utils'; +import { Alert, Button, InputHint, SmallTitleText } from '@/shared/ui'; +import { Box, Select } from '@/shared/ui-kit'; +import { walletModel } from '@/entities/wallet'; +import { flexibleMultisigModel } from '../../model/flexible-multisig-create'; +import { formModel } from '../../model/form-model'; +import { signatoryModel } from '../../model/signatory-model'; +import { MultisigFees } from '../MultisigFees'; + +import { SelectSignatories } from './SelectSignatories'; + +const MIN_THRESHOLD = 2; + +export const SelectSignatoriesThreshold = () => { + const { t } = useI18n(); + + const [hasClickedNext, setHasClickedNext] = useState(false); + + const { + fields: { threshold }, + submit, + } = useForm(formModel.$createMultisigForm); + const chain = useUnit(formModel.$chain); + const signatories = useUnit(signatoryModel.$signatories); + const multisigAlreadyExists = useUnit(formModel.$multisigAlreadyExists); + const hiddenMultisig = useUnit(formModel.$hiddenMultisig); + const ownedSignatoriesWallets = useUnit(signatoryModel.$ownedSignatoriesWallets); + const hasDuplicateSignatories = useUnit(signatoryModel.$hasDuplicateSignatories); + const hasEmptySignatories = useUnit(signatoryModel.$hasEmptySignatories); + const hasEmptySignatoryName = useUnit(signatoryModel.$hasEmptySignatoryName); + + const hasOwnedSignatory = !!ownedSignatoriesWallets && ownedSignatoriesWallets?.length > 0; + const hasEnoughSignatories = signatories.length >= MIN_THRESHOLD; + + const isThresholdValid = threshold.value >= MIN_THRESHOLD && threshold.value <= signatories.length; + const canSubmit = + hasOwnedSignatory && + hasEnoughSignatories && + !multisigAlreadyExists && + !hasEmptySignatories && + isThresholdValid && + !hasEmptySignatoryName && + !hasDuplicateSignatories && + !hiddenMultisig; + + const onSubmit = (event: FormEvent) => { + if (!hasClickedNext) { + setHasClickedNext(true); + } + + if (!canSubmit || !ownedSignatoriesWallets[0]?.accounts[0]) return; + signatoryModel.events.getSignatoriesBalance(ownedSignatoriesWallets); + + if (ownedSignatoriesWallets.length > 1) { + flexibleMultisigModel.events.stepChanged(Step.SIGNER_SELECTION); + + return; + } + + flexibleMultisigModel.events.signerSelected(ownedSignatoriesWallets[0].accounts[0]); + event.preventDefault(); + submit(); + }; + + return ( +
+ + {t('createMultisigAccount.multisigStep', { step: 2 })}{' '} + {t('createMultisigAccount.flexibleMultisig.signatoryThresholdDescription')} + +
+ +
+ 0} + title={t('createMultisigAccount.noOwnSignatoryTitle')} + variant="error" + > + {t('createMultisigAccount.noOwnSignatory')} + + + + {t('createMultisigAccount.notEnoughSignatories')} + + + + {t('createMultisigAccount.notEmptySignatory')} + + + + {t('createMultisigAccount.notEmptySignatoryName')} + +
+
+ + + + + {t('createMultisigAccount.thresholdHint')} + +
+
+ + {t('createMultisigAccount.duplicateSignatoryErrorText')} + +
+
+ + {t('createMultisigAccount.multisigExistText')} + + + + + {t('createMultisigAccount.thresholdErrorDescription', { minThreshold: MIN_THRESHOLD })} + + + + + {t('createMultisigAccount.multisigHiddenExistText')} + + + + +
+ +
+ +
+ {chain?.assets?.[0] ? : null} + +
+
+
+
+ ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/SelectThreshold/Signatory.tsx b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/Signatory.tsx new file mode 100644 index 0000000000..8a8d48f985 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/Signatory.tsx @@ -0,0 +1,236 @@ +import { useUnit } from 'effector-react'; +import { useEffect, useMemo, useState } from 'react'; + +import { type WalletFamily } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { + performSearch, + toAccountId, + toAddress, + validateEthereumAddress, + validateSubstrateAddress, +} from '@/shared/lib/utils'; +import { CaptionText, Combobox, IconButton, Identicon } from '@/shared/ui'; +import { type ComboboxOption } from '@/shared/ui/types'; +import { Address } from '@/shared/ui-entities'; +import { Box, Field, Input } from '@/shared/ui-kit'; +import { contactModel } from '@/entities/contact'; +import { networkUtils } from '@/entities/network'; +import { WalletIcon, accountUtils, walletModel, walletUtils } from '@/entities/wallet'; +import { filterModel } from '@/features/contacts'; +import { walletSelectFeature } from '@/features/wallet-select'; +import { formModel } from '../../model/form-model'; +import { signatoryModel } from '../../model/signatory-model'; + +interface Props { + signatoryName: string; + signatoryAddress: string; + signatoryIndex: number; + selectedWalletId: string; + isOwnAccount?: boolean; + onDelete?: (index: number) => void; +} + +export const Signatory = ({ + signatoryIndex, + onDelete, + isOwnAccount = false, + signatoryName, + signatoryAddress, + selectedWalletId, +}: Props) => { + const { t } = useI18n(); + const [query, setQuery] = useState(''); + const [options, setOptions] = useState([]); + + const contacts = useUnit(contactModel.$contacts); + const wallets = useUnit(walletModel.$wallets); + const chain = useUnit(formModel.$chain); + + const contactsFiltered = useMemo(() => { + return performSearch({ + query, + records: contacts, + weights: { name: 1, address: 0.5 }, + }); + }, [query, contacts]); + const ownAccountName = + walletUtils.getWalletsFilteredAccounts(wallets, { + walletFn: (w) => walletUtils.isValidSignatory(w) && (!selectedWalletId || w.id.toString() === selectedWalletId), + accountFn: (a) => { + if (!chain) return false; + + const accountIdMatch = toAccountId(signatoryAddress) === a.accountId; + const chainIdMatch = accountUtils.isChainIdMatch(a, chain.chainId); + + return accountIdMatch && chainIdMatch; + }, + })?.[0]?.name || ''; + + const contactAccountName = + contacts.filter((contact) => toAccountId(contact.address) === toAccountId(signatoryAddress))?.[0]?.name || ''; + + const displayName = useMemo(() => { + const hasDuplicateName = !!ownAccountName && !!contactAccountName; + const shouldForceOwnAccountName = hasDuplicateName && isOwnAccount; + if (shouldForceOwnAccountName) return ownAccountName; + + if (hasDuplicateName && !isOwnAccount) return contactAccountName; + + return ownAccountName || contactAccountName || name; + }, [isOwnAccount, ownAccountName, contactAccountName, name]); + + // TODO: move it into a model + useEffect(() => { + if (!isOwnAccount || wallets.length === 0 || !chain) return; + + const walletByGroup = walletSelectFeature.services.walletSelect.getWalletByGroups(wallets, query); + const opts = Object.entries(walletByGroup).reduce((acc, [walletType, wallets], index) => { + if (wallets.length === 0) { + return acc; + } + + const accountOptions = wallets.reduce((acc, wallet) => { + if (!wallet.accounts.length || !walletUtils.isValidSignatory(wallet)) return acc; + + const accounts = wallet.accounts + .filter((account) => { + const isChainMatch = accountUtils.isChainAndCryptoMatch(account, chain); + const isCorrectAccount = accountUtils.isNonBaseVaultAccount(account, wallet); + + return isChainMatch && isCorrectAccount; + }) + .map((account) => { + const address = toAddress(account.accountId, { prefix: chain?.addressPrefix }); + + return { + id: account.walletId.toString(), + value: address, + element:
, + }; + }); + + return acc.concat(accounts); + }, [] as ComboboxOption[]); + + if (accountOptions.length === 0) { + return acc; + } + + return acc.concat([ + { + id: index.toString(), + element: ( +
+ + + {t(walletSelectFeature.constants.GROUP_LABELS[walletType as WalletFamily])} + +
+ ), + value: undefined, + disabled: true, + }, + ...accountOptions, + ]); + }, [] as ComboboxOption[]); + + setOptions(opts); + }, [query, wallets, isOwnAccount]); + + // initiate the query form in case of not own account + useEffect(() => { + if (isOwnAccount || contacts.length === 0) return; + filterModel.events.formInitiated(); + }, [isOwnAccount, filterModel, contacts]); + + // list of contacts in case of not own account + useEffect(() => { + if (isOwnAccount || contacts.length === 0) return; + + setOptions( + contactsFiltered.map(({ name, address }) => { + const displayAddress = toAddress(address, { prefix: chain?.addressPrefix }); + + return { + id: signatoryIndex.toString(), + element:
, + value: displayAddress, + }; + }), + ); + }, [query, isOwnAccount, contacts, contactsFiltered]); + + const onNameChange = (newName: string) => { + signatoryModel.events.changeSignatory({ + index: signatoryIndex, + name: newName, + address: signatoryAddress, + walletId: selectedWalletId, + }); + }; + + useEffect(() => { + if (displayName && displayName !== signatoryName) { + onNameChange(displayName); + } + }, [displayName]); + + const onAddressChange = (data: ComboboxOption) => { + if (!chain) return; + + const isEthereumChain = networkUtils.isEthereumBased(chain.options); + const validateFn = isEthereumChain ? validateEthereumAddress : validateSubstrateAddress; + + const validatedAddress = validateFn(data.value) ? data.value : ''; + const fixedAddress = toAddress(validatedAddress, { prefix: chain?.addressPrefix }); + + signatoryModel.events.changeSignatory({ + index: signatoryIndex, + walletId: data.id, + name: signatoryName, + address: fixedAddress, + }); + }; + + const handleQueryChange = (newQuery: string) => { + setQuery(newQuery); + }; + + const accountInputLabel = isOwnAccount + ? t('createMultisigAccount.ownAccountSelection') + : t('createMultisigAccount.signatoryAddress'); + + return ( +
+ + + +
+ + + } + onChange={onAddressChange} + onInput={handleQueryChange} + /> + + + {!isOwnAccount && onDelete && ( + onDelete(signatoryIndex)} /> + )} +
+
+ ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/SelectedSignatoriesModal.tsx b/src/renderer/features/flexible-multisig-create/components/SelectedSignatoriesModal.tsx new file mode 100644 index 0000000000..118345a32f --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectedSignatoriesModal.tsx @@ -0,0 +1,44 @@ +import { useI18n } from '@/shared/i18n'; +import { toAddress } from '@/shared/lib/utils'; +import { Address } from '@/shared/ui-entities'; +import { Modal } from '@/shared/ui-kit'; + +interface SignatoryInfo { + index: number; + name: string; + address: string; +} + +type Props = { + addressPrefix?: number; + signatories: Omit[]; + children: React.ReactNode; +}; + +export const SelectedSignatoriesModal = ({ signatories, addressPrefix, children }: Props) => { + const { t } = useI18n(); + + return ( + + {children} + {t('createMultisigAccount.selectedSignatoriesTitle')} + +
+
    + {signatories.map(({ address, name }) => ( +
  • +
    +
  • + ))} +
+
+
+
+ ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/Signer.tsx b/src/renderer/features/flexible-multisig-create/components/Signer.tsx new file mode 100644 index 0000000000..e6cd2a2510 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/Signer.tsx @@ -0,0 +1,49 @@ +import { BN_ZERO } from '@polkadot/util'; +import { type FormEvent } from 'react'; + +import { type Account, type Chain, type WalletType } from '@/shared/core'; +import { nonNullable, toAddress, transferableAmount } from '@/shared/lib/utils'; +import { truncate } from '@/shared/lib/utils/strings'; +import { BodyText, Icon } from '@/shared/ui'; +import { AssetBalance } from '@/entities/asset'; +import { useBalance } from '@/entities/balance'; +import { WalletIcon } from '@/entities/wallet'; + +interface Props { + onSubmit: (event: FormEvent, account: Account) => void; + account: Account; + walletType: WalletType; + walletName?: string; + chain: Chain; +} + +export const Signer = ({ account, walletName, walletType, onSubmit, chain }: Props) => { + const balance = useBalance({ + accountId: account.accountId, + chainId: chain.chainId, + assetId: chain.assets.at(0)?.assetId.toString() || '', + }); + + return ( +
  • onSubmit(e, account)} + > + +
    + {walletName && {walletName}} + + {truncate(toAddress(account.accountId, { prefix: chain.addressPrefix }), 6)} + +
    + {nonNullable(chain.assets[0]) && ( + + )} + +
  • + ); +}; diff --git a/src/renderer/features/flexible-multisig-create/components/SignerSelection.tsx b/src/renderer/features/flexible-multisig-create/components/SignerSelection.tsx new file mode 100644 index 0000000000..f484b010c3 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SignerSelection.tsx @@ -0,0 +1,65 @@ +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; +import { type FormEvent } from 'react'; + +import { type Account, AccountType, type ChainAccount } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { Step } from '@/shared/lib/utils'; +import { Button } from '@/shared/ui'; +import { flexibleMultisigModel } from '../model/flexible-multisig-create'; +import { formModel } from '../model/form-model'; +import { signatoryModel } from '../model/signatory-model'; + +import { Signer } from './Signer'; + +export const SignerSelection = () => { + const { t } = useI18n(); + + const { submit } = useForm(formModel.$createMultisigForm); + const ownedSignatoriesWallets = useUnit(signatoryModel.$ownedSignatoriesWallets); + const chain = useUnit(formModel.$chain); + + const onSubmit = (event: FormEvent, account: Account) => { + flexibleMultisigModel.events.signerSelected(account); + event.preventDefault(); + submit(); + }; + + return ( +
    +
      + {ownedSignatoriesWallets.map(({ accounts, type, name }) => { + if (!chain || !accounts[0]) return null; + + const account = + accounts[0].type === AccountType.BASE + ? accounts[0] + : accounts.find((account) => (account as ChainAccount).chainId === chain.chainId); + + if (!account) return null; + + return ( + + ); + })} +
    +
    + +
    +
    + ); +}; diff --git a/src/renderer/features/flexible-multisig-create/index.ts b/src/renderer/features/flexible-multisig-create/index.ts new file mode 100644 index 0000000000..2174023afe --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/index.ts @@ -0,0 +1,2 @@ +export { FlexibleMultisigWallet } from './components/FlexibleMultisigWallet'; +export { flexibleMultisigModel } from './model/flexible-multisig-create'; diff --git a/src/renderer/features/flexible-multisig-create/model/__tests__/confirm-model.test.ts b/src/renderer/features/flexible-multisig-create/model/__tests__/confirm-model.test.ts new file mode 100644 index 0000000000..31a9a8bf18 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/__tests__/confirm-model.test.ts @@ -0,0 +1,38 @@ +import { allSettled, fork } from 'effector'; + +import { type Account, type Chain } from '@/shared/core'; +import { networkModel } from '@/entities/network'; +import { walletModel } from '@/entities/wallet'; +import { confirmModel } from '../confirm-model'; + +import { initiatorWallet, signerWallet, testApi } from './mock'; + +describe('Create flexible multisig wallet confirm-model', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('should fill data for confirm model for multisig account', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet]), + }); + + const store = { + chain: { chainId: '0x00' } as unknown as Chain, + account: { walletId: signerWallet.id } as unknown as Account, + signer: { walletId: signerWallet.id } as unknown as Account, + threshold: 2, + name: 'multisig name', + fee: '', + multisigDeposit: '', + }; + + await allSettled(confirmModel.events.formInitiated, { scope, params: store }); + + expect(scope.getState(confirmModel.$api)).toEqual(testApi); + expect(scope.getState(confirmModel.$confirmStore)).toEqual(store); + expect(scope.getState(confirmModel.$signerWallet)).toEqual(signerWallet); + }); +}); diff --git a/src/renderer/features/flexible-multisig-create/model/__tests__/flexible-multisig-model.test.ts b/src/renderer/features/flexible-multisig-create/model/__tests__/flexible-multisig-model.test.ts new file mode 100644 index 0000000000..23f1fb0f4d --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/__tests__/flexible-multisig-model.test.ts @@ -0,0 +1,123 @@ +import { allSettled, fork } from 'effector'; + +import { type Account, type Chain, type ChainId, ConnectionStatus } from '@/shared/core'; +import { Step, toAddress } from '@/shared/lib/utils'; +import { networkModel } from '@/entities/network'; +import { walletModel } from '@/entities/wallet'; +import { signModel } from '@/features/operations/OperationSign/model/sign-model'; +import { submitModel } from '@/features/operations/OperationSubmit'; +import { ExtrinsicResult } from '@/features/operations/OperationSubmit/lib/types'; +import { confirmModel } from '../confirm-model'; +import { flexibleMultisigModel } from '../flexible-multisig-create'; +import { formModel } from '../form-model'; +import { signatoryModel } from '../signatory-model'; +import { flexibleMultisigFeature } from '../status'; + +import { initiatorWallet, signerWallet, testApi, testChain } from './mock'; + +jest.mock('@/entities/transaction/lib/extrinsicService', () => ({ + wrapAsMulti: jest.fn().mockResolvedValue({ + chainId: '0x00', + address: 'mockAddress', + type: 'multisig_as_multi', + args: { + threshold: 1, + otherSignatories: ['mockSignatory1', 'mockSignatory2'], + maybeTimepoint: null, + callData: 'mockCallData', + callHash: 'mockCallHash', + }, + }), +})); + +describe('Create flexible multisig wallet flexible-multisig', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + test('should go through the process of multisig creation', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(networkModel.$chains, { '0x00': testChain }) + .set(networkModel.$connectionStatuses, { '0x00': ConnectionStatus.CONNECTED }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet]), + }); + await allSettled(flexibleMultisigFeature.start, { scope }); + + expect(scope.getState(flexibleMultisigModel.$step)).toEqual(Step.NAME_NETWORK); + + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { + index: 0, + name: signerWallet.name, + address: toAddress(signerWallet.accounts[0].accountId), + walletId: '1', + }, + }); + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 1, name: 'Alice', address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', walletId: '1' }, + }); + await allSettled(flexibleMultisigModel.events.signerSelected, { scope, params: signerWallet.accounts[0] }); + + expect(scope.getState(flexibleMultisigModel.$step)).toEqual(Step.NAME_NETWORK); + await allSettled(formModel.$createMultisigForm.fields.chainId.onChange, { scope, params: testChain.chainId }); + await allSettled(formModel.$createMultisigForm.fields.name.onChange, { scope, params: 'some name' }); + await allSettled(formModel.$createMultisigForm.fields.threshold.onChange, { scope, params: 2 }); + + await allSettled(formModel.$createMultisigForm.submit, { scope }); + + const store = { + chain: { chainId: '0x00' } as unknown as Chain, + chainId: '0x00' as ChainId, + account: { walletId: signerWallet.id } as unknown as Account, + signer: { walletId: signerWallet.id } as unknown as Account, + threshold: 2, + name: 'multisig name', + fee: '', + multisigDeposit: '', + }; + + await allSettled(confirmModel.events.formInitiated, { scope, params: store }); + + expect(scope.getState(flexibleMultisigModel.$step)).toEqual(Step.CONFIRM); + + await allSettled(confirmModel.output.formSubmitted, { scope }); + + expect(scope.getState(flexibleMultisigModel.$step)).toEqual(Step.SIGN); + + await allSettled(signModel.output.formSubmitted, { + scope, + params: { + signatures: ['0x00'], + txPayloads: [{}] as unknown as Uint8Array[], + }, + }); + + expect(scope.getState(flexibleMultisigModel.$step)).toEqual(Step.SUBMIT); + + const action = allSettled(submitModel.output.formSubmitted, { + scope, + params: [ + { + id: 1, + result: ExtrinsicResult.SUCCESS, + params: { + timepoint: { + height: 1, + index: 1, + }, + extrinsicHash: '0x00', + isFinalApprove: true, + multisigError: '', + }, + }, + ], + }); + + await jest.runAllTimersAsync(); + await action; + }); +}); diff --git a/src/renderer/features/flexible-multisig-create/model/__tests__/form-model.test.ts b/src/renderer/features/flexible-multisig-create/model/__tests__/form-model.test.ts new file mode 100644 index 0000000000..81481b74e7 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/__tests__/form-model.test.ts @@ -0,0 +1,125 @@ +import { allSettled, fork } from 'effector'; + +import { ConnectionStatus } from '@/shared/core'; +import { toAddress } from '@/shared/lib/utils'; +import { networkModel } from '@/entities/network'; +import { walletModel } from '@/entities/wallet'; +import { formModel } from '../form-model'; +import { signatoryModel } from '../signatory-model'; + +import { + initiatorWallet, + multisigWallet, + signatoryWallet, + signerWallet, + testApi, + testChain, + wrongChainWallet, +} from './mock'; + +jest.mock('@/shared/lib/utils', () => ({ + ...jest.requireActual('@/shared/lib/utils'), + getProxyTypes: jest.fn().mockReturnValue(['Any', 'Staking']), +})); + +describe('Create flexible multisig wallet form-model', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('should error out for empty name', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(networkModel.$chains, { '0x00': testChain }) + .set(networkModel.$connectionStatuses, { '0x00': ConnectionStatus.CONNECTED }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet]), + }); + + await allSettled(formModel.$createMultisigForm.fields.name.onChange, { scope, params: '' }); + await allSettled(formModel.$createMultisigForm.submit, { scope }); + + expect(scope.getState(formModel.$createMultisigForm.fields.name.$errors)[0].rule).toEqual('notEmpty'); + }); + + test('should error out for low threshold', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(networkModel.$chains, { '0x00': testChain }) + .set(networkModel.$connectionStatuses, { '0x00': ConnectionStatus.CONNECTED }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet]), + }); + + await allSettled(formModel.$createMultisigForm.fields.threshold.onChange, { scope, params: 1 }); + await allSettled(formModel.$createMultisigForm.submit, { scope }); + + expect(scope.getState(formModel.$createMultisigForm.fields.threshold.$errors)[0].rule).toEqual('moreOrEqualToTwo'); + }); + + test('should have correct value for $multisigAccountId', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(networkModel.$chains, { '0x00': testChain }) + .set(networkModel.$connectionStatuses, { '0x00': ConnectionStatus.CONNECTED }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet, multisigWallet]) + .set(signatoryModel.$signatories, []), + }); + + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 0, name: 'test', address: toAddress(signerWallet.accounts[0].accountId), walletId: '1' }, + }); + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 1, name: 'Alice', address: toAddress(signatoryWallet.accounts[0].accountId), walletId: '1' }, + }); + + await allSettled(formModel.$createMultisigForm.fields.threshold.onChange, { scope, params: 2 }); + await allSettled(formModel.$createMultisigForm.fields.chainId.onChange, { scope, params: testChain.chainId }); + + expect(scope.getState(formModel.$multisigAccountId)).toEqual(multisigWallet.accounts[0].accountId); + }); + + test('should have correct value for $availableAccounts', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(networkModel.$chains, { '0x00': testChain }) + .set(networkModel.$connectionStatuses, { '0x00': ConnectionStatus.CONNECTED }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet, wrongChainWallet]), + }); + + await allSettled(formModel.$createMultisigForm.fields.chainId.onChange, { scope, params: testChain.chainId }); + + expect(scope.getState(formModel.$availableAccounts)).toEqual([ + ...initiatorWallet.accounts, + ...signerWallet.accounts, + ]); + }); + + test('should have correct value for $multisigAlreadyExists', async () => { + const scope = fork({ + values: new Map() + .set(networkModel.$apis, { '0x00': testApi }) + .set(networkModel.$chains, { '0x00': testChain }) + .set(networkModel.$connectionStatuses, { '0x00': ConnectionStatus.CONNECTED }) + .set(walletModel._test.$allWallets, [initiatorWallet, signerWallet, multisigWallet]) + .set(signatoryModel.$signatories, []), + }); + + await allSettled(formModel.$createMultisigForm.fields.chainId.onChange, { scope, params: testChain.chainId }); + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 0, name: 'test', address: toAddress(signerWallet.accounts[0].accountId), walletId: '1' }, + }); + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 1, name: 'Alice', address: toAddress(signatoryWallet.accounts[0].accountId), walletId: '1' }, + }); + await allSettled(formModel.$createMultisigForm.fields.threshold.onChange, { scope, params: 2 }); + + expect(scope.getState(formModel.$multisigAlreadyExists)).toEqual(true); + }); +}); diff --git a/src/renderer/features/flexible-multisig-create/model/__tests__/mock.ts b/src/renderer/features/flexible-multisig-create/model/__tests__/mock.ts new file mode 100644 index 0000000000..f096077682 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/__tests__/mock.ts @@ -0,0 +1,115 @@ +import { type ApiPromise } from '@polkadot/api'; + +import { + AccountType, + type Chain, + type ChainAccount, + ChainOptions, + ChainType, + type MultisigAccount, + SigningType, + type Wallet, + WalletType, +} from '@/shared/core'; + +export const testApi = { + key: 'test-api', +} as unknown as ApiPromise; + +export const testChain = { + name: 'test-chain', + chainId: '0x00', + options: [ChainOptions.MULTISIG], + assets: [{ assetId: 0 }], + type: ChainType.SUBSTRATE, +} as unknown as Chain; + +export const multisigWallet = { + id: 3, + name: 'multisig Wallet', + isActive: false, + type: WalletType.MULTISIG, + signingType: SigningType.MULTISIG, + accounts: [ + { + accountId: '0x7f7cc72b17ac5d762869e97af14ebcc561590b6cc9eeeac7a3cdadde646c95c3', + type: AccountType.MULTISIG, + } as unknown as MultisigAccount, + ], +} as Wallet; + +export const signerWallet = { + id: 2, + name: 'Signer Wallet', + isActive: true, + type: WalletType.WALLET_CONNECT, + signingType: SigningType.WALLET_CONNECT, + accounts: [ + { + id: 2, + walletId: 2, + name: 'account 2', + type: AccountType.WALLET_CONNECT, + accountId: '0x04dd9807d3f7008abfcbffc8cb96e8e26a71a839c7c18d471b0eea782c1b8521', + chainType: ChainType.SUBSTRATE, + chainId: '0x00', + } as unknown as ChainAccount, + ], +} as Wallet; + +export const signatoryWallet = { + id: 5, + name: 'Signer Wallet', + isActive: true, + type: WalletType.WALLET_CONNECT, + signingType: SigningType.WALLET_CONNECT, + accounts: [ + { + id: 5, + walletId: 5, + name: 'account 5', + type: AccountType.WALLET_CONNECT, + accountId: '0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d', + chainType: ChainType.SUBSTRATE, + chainId: '0x00', + } as unknown as ChainAccount, + ], +} as Wallet; + +export const initiatorWallet = { + id: 1, + name: 'Wallet', + isActive: true, + type: WalletType.POLKADOT_VAULT, + signingType: SigningType.POLKADOT_VAULT, + accounts: [ + { + id: 1, + walletId: 1, + name: 'account 1', + type: AccountType.WALLET_CONNECT, + accountId: '0x960d75eab8e58bffcedf1fa51d85e2acb37d107e9bd7009a3473d3809122493c', + chainType: ChainType.SUBSTRATE, + chainId: '0x00', + } as unknown as ChainAccount, + ], +} as Wallet; + +export const wrongChainWallet = { + id: 4, + name: 'Wallet Wrong Chain', + isActive: true, + type: WalletType.POLKADOT_VAULT, + signingType: SigningType.POLKADOT_VAULT, + accounts: [ + { + id: 4, + walletId: 4, + name: 'account 4', + type: AccountType.WALLET_CONNECT, + accountId: '0x00', + chainType: ChainType.SUBSTRATE, + chainId: '0x01', + } as unknown as ChainAccount, + ], +} as Wallet; diff --git a/src/renderer/features/flexible-multisig-create/model/__tests__/signatory-model.test.ts b/src/renderer/features/flexible-multisig-create/model/__tests__/signatory-model.test.ts new file mode 100644 index 0000000000..0aaeb41180 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/__tests__/signatory-model.test.ts @@ -0,0 +1,74 @@ +import { allSettled, fork } from 'effector'; + +import { toAddress } from '@/shared/lib/utils'; +import { walletModel } from '@/entities/wallet'; +import { signatoryModel } from '../signatory-model'; + +import { initiatorWallet, signatoryWallet, signerWallet } from './mock'; + +describe('Create flexible multisig wallet signatory-model', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('should correctly add signatories', async () => { + const scope = fork({ + values: new Map().set(signatoryModel.$signatories, []), + }); + + expect(scope.getState(signatoryModel.$signatories).length).toEqual(0); + + await allSettled(signatoryModel.events.addSignatory, { + scope, + params: { name: 'Alice', address: toAddress(signerWallet.accounts[0].accountId), walletId: '1' }, + }); + + await allSettled(signatoryModel.events.addSignatory, { + scope, + params: { name: 'test', address: toAddress(signerWallet.accounts[0].accountId), walletId: '1' }, + }); + + expect(scope.getState(signatoryModel.$signatories).length).toEqual(2); + }); + + test('should correctly delete signatories', async () => { + const scope = fork({ + values: new Map().set(signatoryModel.$signatories, []), + }); + + expect(scope.getState(signatoryModel.$signatories).length).toEqual(0); + + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 0, name: 'test', address: toAddress(signerWallet.accounts[0].accountId), walletId: '1' }, + }); + + expect(scope.getState(signatoryModel.$signatories).length).toEqual(1); + + await allSettled(signatoryModel.events.deleteSignatory, { + scope, + params: 0, + }); + + expect(scope.getState(signatoryModel.$signatories).length).toEqual(0); + }); + + test('should have correct value for $ownSignatoryWallets', async () => { + const scope = fork({ + values: new Map().set(walletModel._test.$allWallets, [initiatorWallet, signerWallet]), + }); + + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 1, name: 'Alice', address: toAddress(signatoryWallet.accounts[0].accountId), walletId: '1' }, + }); + + expect(scope.getState(signatoryModel.$ownedSignatoriesWallets)?.length).toEqual(0); + + await allSettled(signatoryModel.events.changeSignatory, { + scope, + params: { index: 0, name: 'test', address: toAddress(signerWallet.accounts[0].accountId), walletId: '1' }, + }); + expect(scope.getState(signatoryModel.$ownedSignatoriesWallets)?.length).toEqual(1); + }); +}); diff --git a/src/renderer/features/flexible-multisig-create/model/confirm-model.ts b/src/renderer/features/flexible-multisig-create/model/confirm-model.ts new file mode 100644 index 0000000000..82b52b874d --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/confirm-model.ts @@ -0,0 +1,54 @@ +import { combine, createEvent, restore } from 'effector'; + +import { type Account, type Chain } from '@/shared/core'; +import { networkModel } from '@/entities/network'; +import { walletModel, walletUtils } from '@/entities/wallet'; + +type FormSubmitEvent = { + signer: Account; + fee: string; + multisigDeposit: string; + threshold: number; + chain: Chain; + name: string; +}; + +const formInitiated = createEvent(); +const formSubmitted = createEvent(); + +const $confirmStore = restore(formInitiated, null).reset(formSubmitted); + +const $api = combine( + { + apis: networkModel.$apis, + store: $confirmStore, + }, + ({ apis, store }) => { + return store?.chain ? apis[store.chain.chainId] : null; + }, +); + +const $signerWallet = combine( + { + store: $confirmStore, + wallets: walletModel.$wallets, + }, + ({ store, wallets }) => { + if (!store) return null; + + return walletUtils.getWalletById(wallets, store.signer.walletId); + }, + { skipVoid: false }, +); + +export const confirmModel = { + $confirmStore, + $signerWallet, + $api, + events: { + formInitiated, + }, + output: { + formSubmitted, + }, +}; diff --git a/src/renderer/features/flexible-multisig-create/model/flexible-multisig-create.ts b/src/renderer/features/flexible-multisig-create/model/flexible-multisig-create.ts new file mode 100644 index 0000000000..a559fda2cb --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/flexible-multisig-create.ts @@ -0,0 +1,552 @@ +import { type ApiPromise } from '@polkadot/api'; +import { BN, BN_ZERO } from '@polkadot/util'; +import { combine, createEffect, createEvent, createStore, restore, sample } from 'effector'; +import sortBy from 'lodash/sortBy'; +import { delay, or, spread } from 'patronum'; + +import { balanceService } from '@/shared/api/balances'; +import { proxyService } from '@/shared/api/proxy'; +import { + type Account, + AccountType, + type Asset, + type Chain, + ChainType, + type Contact, + CryptoType, + type FlexibleMultisigAccount, + type MultisigAccount, + type NoID, + SigningType, + type Transaction, + TransactionType, + WalletType, +} from '@/shared/core'; +import { + SS58_DEFAULT_PREFIX, + Step, + TEST_ACCOUNTS, + isStep, + nonNullable, + toAccountId, + toAddress, + withdrawableAmountBN, +} from '@/shared/lib/utils'; +import { createDepositCalculator, createFeeCalculator } from '@/shared/transactions'; +import { balanceModel, balanceUtils } from '@/entities/balance'; +import { contactModel } from '@/entities/contact'; +import { networkModel, networkUtils } from '@/entities/network'; +import { getExtrinsic, transactionBuilder } from '@/entities/transaction'; +import { walletModel, walletUtils } from '@/entities/wallet'; +import { signModel } from '@/features/operations/OperationSign/model/sign-model'; +import { submitModel, submitUtils } from '@/features/operations/OperationSubmit'; +import { walletPairingModel } from '@/features/wallets'; + +import { confirmModel } from './confirm-model'; +import { formModel } from './form-model'; +import { signatoryModel } from './signatory-model'; +import { flexibleMultisigFeature } from './status'; +import { walletProviderModel } from './wallet-provider-model'; + +type FormSubmitEvent = { + transactions: { + wrappedTx: Transaction; + multisigTx?: Transaction; + coreTx: Transaction; + }; + formData: { + signer: Account; + fee: string; + multisigDeposit: string; + threshold: number; + chain: Chain; + name: string; + }; +}; + +export type AddMultisigStore = FormSubmitEvent['formData']; + +const stepChanged = createEvent(); +const formSubmitted = createEvent(); +const flowFinished = createEvent(); +const signerSelected = createEvent(); +const walletCreated = createEvent<{ + name: string; + threshold: number; +}>(); + +const $step = restore(stepChanged, Step.NAME_NETWORK).reset(flowFinished); + +const $proxyDeposit = createStore(BN_ZERO).reset(flowFinished); +const $error = createStore('').reset(flowFinished); +const $wrappedTx = createStore(null).reset(flowFinished); +const $coreTx = createStore(null).reset(flowFinished); +const $multisigTx = createStore(null).reset(flowFinished); +const $addMultisigStore = createStore(null).reset(flowFinished); +const $signer = restore(signerSelected, null).reset(flowFinished); + +const $signerWallet = combine({ signer: $signer, wallets: walletModel.$wallets }, ({ signer, wallets }) => { + return walletUtils.getWalletFilteredAccounts(wallets, { + accountFn: (a) => a.accountId === signer?.accountId, + walletFn: (w) => walletUtils.isValidSignatory(w) && w.id === signer?.walletId, + }); +}); + +const $isChainConnected = combine( + { + chainId: formModel.$createMultisigForm.fields.chainId.$value, + statuses: networkModel.$connectionStatuses, + }, + ({ chainId, statuses }) => { + return networkUtils.isConnectedStatus(statuses[chainId]); + }, +); + +const $api = combine(flexibleMultisigFeature.state, (state) => { + if (state.status !== 'running') return null; + + return state.data.api; +}); + +const $transaction = combine( + { + api: $api, + form: formModel.$createMultisigForm.$values, + chain: formModel.$chain, + signatories: signatoryModel.$signatories, + signer: $signer, + multisigAccountId: formModel.$multisigAccountId, + isConnected: $isChainConnected, + proxyDeposit: $proxyDeposit, + }, + ({ api, form, chain, isConnected, signatories, signer, proxyDeposit, multisigAccountId }) => { + if (!isConnected || !chain || !api || !multisigAccountId || !form.threshold || !signer) { + return null; + } + + const signatoriesWrapped = signatories.map((s) => ({ accountId: toAccountId(s.address), address: s.address })); + + return transactionBuilder.buildCreateFlexibleMultisig({ + api, + chain, + signer: signer, + signatories: signatoriesWrapped, + multisigAccountId, + threshold: form.threshold, + proxyDeposit: proxyDeposit.toString(), + }); + }, +); + +const $fakeTx = combine( + { + chain: formModel.$chain, + isConnected: $isChainConnected, + api: $api, + transaction: $transaction, + }, + ({ isConnected, chain, api, transaction }): Transaction | null => { + if (!chain || !isConnected || !api) return null; + if (transaction) return transaction; + + const proxyTransaction = transactionBuilder.buildCreatePureProxy({ + chain: chain, + accountId: TEST_ACCOUNTS[0], + }); + + const extrinsic = getExtrinsic[proxyTransaction.type](proxyTransaction.args, api); + const callData = extrinsic.method.toHex(); + const callHash = extrinsic.method.hash.toHex(); + + return { + chainId: chain.chainId, + address: toAddress(TEST_ACCOUNTS[0], { prefix: SS58_DEFAULT_PREFIX }), + type: TransactionType.MULTISIG_AS_MULTI, + args: { + threshold: 2, + otherSignatories: [], + callData, + callHash, + }, + }; + }, +); + +const { $: $fee, $pending: $pendingFee } = createFeeCalculator({ + $api: $api, + $transaction: $fakeTx, +}); + +const { $deposit: $multisigDeposit, $pending: $pendingDeposit } = createDepositCalculator({ + $api: $api, + $threshold: formModel.$createMultisigForm.fields.threshold.$value, +}); + +type GetDepositParams = { + api: ApiPromise; + asset: Asset; +}; + +const getProxyDepositFx = createEffect(async ({ api, asset }: GetDepositParams): Promise => { + const minDeposit = await balanceService.getExistentialDeposit(api, asset); + const proxyDeposit = new BN(proxyService.getProxyDeposit(api, '0', 1)); + + return BN.max(minDeposit, proxyDeposit); +}); + +sample({ + clock: $api, + source: formModel.$chain, + filter: (chain, api) => nonNullable(api) && nonNullable(chain) && nonNullable(chain.assets?.[0]), + fn: (chain, api) => ({ api: api!, asset: chain!.assets[0] }), + target: getProxyDepositFx, +}); + +sample({ + clock: getProxyDepositFx.doneData, + target: $proxyDeposit, +}); + +const $isEnoughBalance = combine( + { + signer: $signer, + fee: $fee, + multisigDeposit: $multisigDeposit, + proxyDeposit: $proxyDeposit, + balances: balanceModel.$balances, + chain: formModel.$chain, + }, + ({ signer, fee, multisigDeposit, balances, proxyDeposit, chain }) => { + if (!signer || !fee || !chain || !chain.assets?.[0]) return false; + + const balance = balanceUtils.getBalance( + balances, + signer.accountId, + chain.chainId, + chain.assets[0].assetId.toString(), + ); + + return fee + .add(multisigDeposit) + .add(new BN(proxyDeposit)) + .lte(new BN(withdrawableAmountBN(balance))); + }, +); + +// Submit + +sample({ + clock: formModel.$createMultisigForm.formValidated, + source: { + signer: $signer, + transaction: $transaction, + fee: $fee, + multisigDeposit: $multisigDeposit, + chain: formModel.$chain, + }, + filter: ({ transaction, signer, chain }) => { + return !!transaction && !!signer && !!chain; + }, + fn: ({ multisigDeposit, signer, transaction, fee, chain }, formData) => { + const coreTx = transactionBuilder.buildCreatePureProxy({ + chain: chain!, + accountId: signer!.accountId, + }); + + return { + transactions: { + wrappedTx: transaction!, + multisigTx: transaction!.args.transactions[0], + coreTx, + }, + formData: { + ...formData, + chain: chain!, + signer: signer!, + fee: fee.toString(), + account: signer, + multisigDeposit: multisigDeposit.toString(), + }, + }; + }, + target: formSubmitted, +}); + +sample({ + clock: formSubmitted, + fn: ({ transactions, formData }) => ({ + wrappedTx: transactions.wrappedTx, + multisigTx: transactions.multisigTx || null, + coreTx: transactions.coreTx, + store: formData, + }), + target: spread({ + wrappedTx: $wrappedTx, + multisigTx: $multisigTx, + coreTx: $coreTx, + store: $addMultisigStore, + }), +}); + +sample({ + clock: formSubmitted, + fn: ({ formData, transactions }) => ({ + event: { ...formData, transaction: transactions.wrappedTx }, + step: Step.CONFIRM, + }), + target: spread({ + event: confirmModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: confirmModel.output.formSubmitted, + source: { + addMultisigStore: $addMultisigStore, + wrappedTx: $wrappedTx, + signer: $signer, + }, + filter: ({ addMultisigStore, wrappedTx, signer }) => + Boolean(addMultisigStore) && Boolean(wrappedTx) && Boolean(signer), + fn: ({ addMultisigStore, wrappedTx, signer }) => ({ + event: { + signingPayloads: [ + { + chain: addMultisigStore!.chain, + account: signer!, + transaction: wrappedTx!, + signatory: null, + }, + ], + }, + step: Step.SIGN, + }), + target: spread({ + event: signModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: signModel.output.formSubmitted, + source: { + addMultisigStore: $addMultisigStore, + coreTx: $coreTx, + wrappedTx: $wrappedTx, + multisigTx: $multisigTx, + multisigAccountId: formModel.$multisigAccountId, + signatories: signatoryModel.$signatories, + }, + filter: ({ addMultisigStore, coreTx, wrappedTx, multisigAccountId }) => { + return !!addMultisigStore && !!wrappedTx && !!coreTx && !!multisigAccountId; + }, + fn: ({ addMultisigStore, coreTx, wrappedTx, multisigTx, multisigAccountId, signatories }, signParams) => { + const isEthereumChain = networkUtils.isEthereumBased(addMultisigStore!.chain.options); + const signatoriesWrapped = signatories.map((s) => ({ accountId: toAccountId(s.address), address: s.address })); + + return { + event: { + ...signParams, + chain: addMultisigStore!.chain, + account: { + signatories: signatoriesWrapped, + chainId: addMultisigStore!.chain.chainId, + name: addMultisigStore!.name, + accountId: multisigAccountId!, + threshold: addMultisigStore!.threshold, + cryptoType: isEthereumChain ? CryptoType.ETHEREUM : CryptoType.SR25519, + chainType: isEthereumChain ? ChainType.ETHEREUM : ChainType.SUBSTRATE, + type: AccountType.MULTISIG, + } as MultisigAccount, + coreTxs: [coreTx!], + wrappedTxs: [wrappedTx!], + multisigTxs: multisigTx ? [multisigTx] : [], + }, + step: Step.SUBMIT, + }; + }, + target: spread({ + event: submitModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: signModel.output.formSubmitted, + source: { + signatories: signatoryModel.$signatories, + contacts: contactModel.$contacts, + }, + fn: ({ signatories, contacts }) => { + const signatoriesWithoutSigner = signatories.slice(1); + const contactMap = new Map(contacts.map((c) => [c.accountId, c])); + const updatedContacts: Contact[] = []; + + for (const { address, name } of signatoriesWithoutSigner) { + const contact = contactMap.get(toAccountId(address)); + + if (!contact) continue; + + updatedContacts.push({ + ...contact, + name, + }); + } + + return updatedContacts; + }, + target: contactModel.effects.updateContactsFx, +}); + +sample({ + clock: signModel.output.formSubmitted, + source: { + signatories: signatoryModel.$signatories, + contacts: contactModel.$contacts, + }, + fn: ({ signatories, contacts }) => { + const contactsSet = new Set(contacts.map((c) => c.accountId)); + + return signatories + .slice(1) + .filter((signatory) => !contactsSet.has(toAccountId(signatory.address))) + .map( + ({ address, name }) => + ({ + address: address, + name: name, + accountId: toAccountId(address), + }) as Contact, + ); + }, + target: contactModel.effects.createContactsFx, +}); + +// Create wallet + +sample({ + clock: submitModel.output.formSubmitted, + source: { + name: formModel.$createMultisigForm.fields.name.$value, + threshold: formModel.$createMultisigForm.fields.threshold.$value, + signatories: signatoryModel.$signatories, + chain: formModel.$chain, + step: $step, + multisigAccoutId: formModel.$multisigAccountId, + }, + filter: ({ step, chain, multisigAccoutId }, results) => { + const isSubmitStep = isStep(Step.SUBMIT, step); + const isSuccessResult = results.some(({ result }) => submitUtils.isSuccessResult(result)); + + return nonNullable(chain) && isSubmitStep && isSuccessResult && nonNullable(multisigAccoutId); + }, + fn: ({ signatories, chain, name, threshold, multisigAccoutId }) => { + const sortedSignatories = sortBy( + signatories.map((a) => ({ address: a.address, accountId: toAccountId(a.address) })), + 'accountId', + ); + + const isEthereumChain = networkUtils.isEthereumBased(chain!.options); + const account: Omit, 'walletId'> = { + signatories: sortedSignatories, + chainId: chain!.chainId, + name: name.trim(), + accountId: multisigAccoutId!, + threshold: threshold, + cryptoType: isEthereumChain ? CryptoType.ETHEREUM : CryptoType.SR25519, + chainType: isEthereumChain ? ChainType.ETHEREUM : ChainType.SUBSTRATE, + type: AccountType.FLEXIBLE_MULTISIG, + }; + + return { + wallet: { + name, + type: WalletType.FLEXIBLE_MULTISIG, + signingType: SigningType.MULTISIG, + }, + accounts: [account], + external: false, + }; + }, + target: walletModel.events.flexibleMultisigCreated, +}); + +sample({ + clock: walletModel.events.walletCreationFail, + fn: ({ error }) => error.message, + target: $error, +}); + +sample({ + clock: walletModel.events.walletCreatedDone, + filter: ({ wallet, external }) => wallet.type === WalletType.FLEXIBLE_MULTISIG && !external, + fn: ({ wallet }) => wallet.id, + target: walletProviderModel.events.completed, +}); + +sample({ + clock: delay(submitModel.output.formSubmitted, 2000), + source: $step, + filter: (step) => isStep(step, Step.SUBMIT), + target: flowFinished, +}); + +sample({ + clock: walletModel.events.walletRestoredSuccess, + target: walletProviderModel.events.completed, +}); + +sample({ + clock: walletModel.events.walletRestoredSuccess, + target: flowFinished, +}); + +sample({ + clock: flexibleMultisigFeature.stopped, + target: formModel.$createMultisigForm.reset, +}); + +sample({ + clock: flowFinished, + target: walletPairingModel.events.walletTypeCleared, +}); + +sample({ + clock: delay(flowFinished, 2000), + fn: () => Step.NAME_NETWORK, + target: stepChanged, +}); + +sample({ + clock: flexibleMultisigFeature.stopped, + target: signatoryModel.$signatories.reinit, +}); + +export const flexibleMultisigModel = { + $error, + $step, + $api, + $signer, + $signerWallet, + $transaction, + + $fee, + $proxyDeposit, + $multisigDeposit, + $isLoading: or($pendingFee, $pendingDeposit, getProxyDepositFx.pending), + $isEnoughBalance, + + events: { + signerSelected, + walletCreated, + stepChanged, + + _test: { + formSubmitted, + }, + }, + output: { + flowFinished, + }, +}; diff --git a/src/renderer/features/flexible-multisig-create/model/form-model.ts b/src/renderer/features/flexible-multisig-create/model/form-model.ts new file mode 100644 index 0000000000..cf66ba87c5 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/form-model.ts @@ -0,0 +1,156 @@ +import { combine, sample } from 'effector'; +import { createForm } from 'effector-forms'; + +import { type Chain, type ChainId, CryptoType } from '@/shared/core'; +import { nonNullable, toAccountId } from '@/shared/lib/utils'; +import { networkModel, networkUtils } from '@/entities/network'; +import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; + +import { signatoryModel } from './signatory-model'; + +const DEFAULT_CHAIN: ChainId = '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3'; // Polkadot + +type FormParams = { + threshold: number; + chainId: ChainId; + name: string; +}; + +const $createMultisigForm = createForm({ + fields: { + threshold: { + init: 0, + rules: [ + { + name: 'moreOrEqualToTwo', + validator: (threshold) => threshold >= 2, + }, + ], + }, + chainId: { + init: DEFAULT_CHAIN, + }, + name: { + init: '', + rules: [ + { + name: 'notEmpty', + validator: (name) => name !== '', + }, + ], + }, + }, + validateOn: ['submit'], +}); + +const $chain = combine( + { + formValues: $createMultisigForm.$values, + chains: networkModel.$chains, + }, + ({ formValues, chains }): Chain | null => { + return chains[formValues.chainId] ?? null; + }, +); + +const $multisigAccountId = combine( + { + formValues: $createMultisigForm.$values, + signatories: signatoryModel.$signatories, + chain: $chain, + }, + ({ formValues: { threshold }, chain, signatories }) => { + if (!threshold || !chain) return null; + + const cryptoType = networkUtils.isEthereumBased(chain.options) ? CryptoType.ETHEREUM : CryptoType.SR25519; + + return accountUtils.getMultisigAccountId( + signatories.map((s) => toAccountId(s.address)), + threshold, + cryptoType, + ); + }, +); + +const $multisigAlreadyExists = combine( + { + wallets: walletModel.$wallets, + multisigAccountId: $multisigAccountId, + formValues: $createMultisigForm.$values, + }, + ({ multisigAccountId, wallets, formValues: { chainId } }) => { + const multisigWallet = walletUtils.getWalletFilteredAccounts(wallets, { + walletFn: walletUtils.isMultisig, + accountFn: (multisigAccount) => { + if (!accountUtils.isMultisigAccount(multisigAccount)) return false; + + const isSameAccountId = multisigAccount.accountId === multisigAccountId; + const isSameChainId = !multisigAccount.chainId || multisigAccount.chainId === chainId; + + return isSameAccountId && isSameChainId; + }, + }); + + return nonNullable(multisigWallet); + }, +); + +const $hiddenMultisig = combine( + { + hiddenWallets: walletModel.$hiddenWallets, + multisigAccountId: $multisigAccountId, + formValues: $createMultisigForm.$values, + }, + ({ multisigAccountId, hiddenWallets, formValues: { chainId } }) => { + return walletUtils.getWalletFilteredAccounts(hiddenWallets, { + walletFn: walletUtils.isMultisig, + accountFn: (multisigAccount) => { + if (!accountUtils.isMultisigAccount(multisigAccount)) return false; + + const isSameAccountId = multisigAccount.accountId === multisigAccountId; + const isSameChainId = !multisigAccount.chainId || multisigAccount.chainId === chainId; + + return isSameAccountId && isSameChainId; + }, + }); + }, +); + +const $availableAccounts = combine( + { + chain: $chain, + wallets: walletModel.$wallets, + }, + ({ chain, wallets }) => { + if (!chain) return []; + + const filteredAccounts = walletUtils.getAccountsBy(wallets, (a, w) => { + const isValidWallet = !walletUtils.isWatchOnly(w) && !walletUtils.isProxied(w) && !walletUtils.isMultisig(w); + const isChainMatch = accountUtils.isChainAndCryptoMatch(a, chain); + + return isValidWallet && isChainMatch; + }); + + const baseAccounts = filteredAccounts.filter((a) => accountUtils.isBaseAccount(a) && a.name); + + return [...filteredAccounts, ...baseAccounts]; + }, +); + +sample({ + clock: signatoryModel.events.deleteSignatory, + target: $createMultisigForm.fields.threshold.reset, +}); + +export const formModel = { + $createMultisigForm, + $multisigAccountId, + $multisigAlreadyExists, + $hiddenMultisig, + $availableAccounts, + $chain, + + output: { + formSubmitted: $createMultisigForm.formValidated, + }, +}; diff --git a/src/renderer/features/flexible-multisig-create/model/signatory-model.ts b/src/renderer/features/flexible-multisig-create/model/signatory-model.ts new file mode 100644 index 0000000000..6a54f288f5 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/signatory-model.ts @@ -0,0 +1,123 @@ +import { combine, createEffect, createEvent, createStore, sample } from 'effector'; +import { produce } from 'immer'; + +import { type Address, type Wallet } from '@/shared/core'; +import { toAccountId } from '@/shared/lib/utils'; +import { walletModel, walletUtils } from '@/entities/wallet'; +import { balanceSubModel } from '@/features/assets-balances'; + +interface SignatoryInfo { + index: number; + name: string; + address: string; + walletId: string; +} + +const addSignatory = createEvent>(); +const changeSignatory = createEvent(); +const deleteSignatory = createEvent(); +const getSignatoriesBalance = createEvent(); + +const $signatories = createStore[]>([{ name: '', address: '', walletId: '' }]); +const $hasDuplicateSignatories = combine($signatories, (signatories) => { + const existingKeys: Set
    = new Set(); + + for (const signatory of signatories) { + if (signatory.address.length === 0) { + continue; + } + + if (existingKeys.has(signatory.address)) { + return true; + } + + existingKeys.add(signatory.address); + } + + return false; +}); + +const $hasEmptySignatories = combine($signatories, (signatories) => { + return signatories.map(({ address }) => address).includes(''); +}); + +const $hasEmptySignatoryName = combine($signatories, (signatories) => { + return signatories.map(({ name }) => name).includes(''); +}); + +const $ownedSignatoriesWallets = combine( + { + wallets: walletModel.$wallets, + signatories: $signatories, + }, + ({ wallets, signatories }) => { + const matchWallets = walletUtils.getWalletsFilteredAccounts(wallets, { + walletFn: (w) => walletUtils.isValidSignatory(w), + accountFn: (a) => signatories.some((s) => toAccountId(s.address) === a.accountId), + }); + + return matchWallets || []; + }, +); +const populateBalanceFx = createEffect((wallets: Wallet[]) => { + for (const wallet of wallets) { + balanceSubModel.events.walletToSubSet(wallet); + } +}); + +sample({ + clock: getSignatoriesBalance, + target: populateBalanceFx, +}); + +sample({ + clock: addSignatory, + source: $signatories, + fn: (signatories, { name, address, walletId }) => { + return produce(signatories, (draft) => { + draft.push({ name, address, walletId }); + }); + }, + target: $signatories, +}); + +sample({ + clock: changeSignatory, + source: $signatories, + fn: (signatories, { index, name, address, walletId }) => { + return produce(signatories, (draft) => { + if (index >= draft.length) { + draft.push({ name, address, walletId }); + } else { + draft[index] = { name, address, walletId }; + } + }); + }, + target: $signatories, +}); + +sample({ + clock: deleteSignatory, + source: $signatories, + filter: (signatories, index) => signatories.length > index, + fn: (signatories, index) => { + return produce(signatories, (draft) => { + draft.splice(index, 1); + }); + }, + target: $signatories, +}); + +export const signatoryModel = { + $signatories, + $ownedSignatoriesWallets, + $hasDuplicateSignatories, + $hasEmptySignatories, + $hasEmptySignatoryName, + events: { + addSignatory, + changeSignatory, + deleteSignatory, + getSignatoriesBalance, + }, +}; diff --git a/src/renderer/features/flexible-multisig-create/model/status.ts b/src/renderer/features/flexible-multisig-create/model/status.ts new file mode 100644 index 0000000000..7d5366521e --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/status.ts @@ -0,0 +1,20 @@ +import { combine } from 'effector'; + +import { createFeature } from '@/shared/effector'; +import { nullable } from '@/shared/lib/utils'; +import { networkModel } from '@/entities/network'; + +import { formModel } from './form-model'; + +const $input = combine(formModel.$createMultisigForm.fields.chainId.$value, networkModel.$apis, (chainId, apis) => { + if (nullable(apis[chainId])) return null; + + return { + api: apis[chainId], + }; +}); + +export const flexibleMultisigFeature = createFeature({ + name: 'flexible multisig/create', + input: $input, +}); diff --git a/src/renderer/features/flexible-multisig-create/model/wallet-provider-model.ts b/src/renderer/features/flexible-multisig-create/model/wallet-provider-model.ts new file mode 100644 index 0000000000..686e9ee91c --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/wallet-provider-model.ts @@ -0,0 +1,40 @@ +import { createApi, createEffect, createEvent, createStore, sample } from 'effector'; +import { type NavigateFunction } from 'react-router-dom'; + +import { walletPairingModel } from '@/features/wallets'; + +const completed = createEvent(); +const rejected = createEvent(); + +type Navigation = { + redirectPath: string; + navigate: NavigateFunction; +}; +const $navigation = createStore(null); +const navigationApi = createApi($navigation, { + navigateApiChanged: (state, { navigate, redirectPath }) => ({ ...state, navigate, redirectPath }), +}); + +const navigateFx = createEffect(({ navigate, redirectPath }: Navigation) => { + navigate(redirectPath); +}); + +sample({ + clock: completed, + source: $navigation, + filter: (state): state is Navigation => Boolean(state?.redirectPath) && Boolean(state?.navigate), + target: navigateFx, +}); + +sample({ + clock: navigateFx.doneData, + target: walletPairingModel.events.walletTypeCleared, +}); + +export const walletProviderModel = { + events: { + completed, + rejected, + navigateApiChanged: navigationApi.navigateApiChanged, + }, +}; diff --git a/src/renderer/features/governance-navigation/index.ts b/src/renderer/features/governance-navigation/index.ts index 08374be209..844eabb708 100644 --- a/src/renderer/features/governance-navigation/index.ts +++ b/src/renderer/features/governance-navigation/index.ts @@ -4,7 +4,7 @@ import { Paths } from '@/shared/routes'; import { navigationTopLinksPipeline } from '@/features/app-shell'; export const governanceNavigationFeature = createFeature({ - name: 'Governance navigation', + name: 'governance/navigation', enable: $features.map(({ governance }) => governance), }); diff --git a/src/renderer/features/governance/components/AccountsMultiSelector/AccountsMultiSelector.tsx b/src/renderer/features/governance/components/AccountsMultiSelector/AccountsMultiSelector.tsx index 5659582ff2..98cd94bf62 100644 --- a/src/renderer/features/governance/components/AccountsMultiSelector/AccountsMultiSelector.tsx +++ b/src/renderer/features/governance/components/AccountsMultiSelector/AccountsMultiSelector.tsx @@ -13,7 +13,6 @@ import { ViewClass, } from '@/shared/ui/Dropdowns/common/constants'; import { type DropdownResult, type Position, type Theme } from '@/shared/ui/Dropdowns/common/types'; -import { CommonInputStyles, CommonInputStylesTheme } from '@/shared/ui/Inputs/common/styles'; import { Checkbox } from '@/shared/ui-kit'; type DropdownOption = { @@ -117,9 +116,9 @@ export const AccountsMultiSelector = ({ !open && !invalid && SelectButtonStyle[theme].closed, invalid && SelectButtonStyle[theme].invalid, SelectButtonStyle[theme].disabled, - CommonInputStyles, - CommonInputStylesTheme[theme], - 'inline-flex w-full items-center justify-between gap-x-2 gap-y-2 py-2 pr-2', + 'bg-input-background text-text-primary', + 'rounded border text-footnote outline-offset-1', + 'inline-flex w-full items-center justify-between gap-2 px-2 py-2', )} tabIndex={tabIndex} > diff --git a/src/renderer/features/governance/components/Delegations/TotalDelegation.tsx b/src/renderer/features/governance/components/Delegations/TotalDelegation.tsx index d63d481e95..758f70e20c 100644 --- a/src/renderer/features/governance/components/Delegations/TotalDelegation.tsx +++ b/src/renderer/features/governance/components/Delegations/TotalDelegation.tsx @@ -1,5 +1,5 @@ import { useUnit } from 'effector-react'; -import { type ReactNode } from 'react'; +import { type ReactNode, useState } from 'react'; import { useI18n } from '@/shared/i18n'; import { useConfirmContext } from '@/shared/providers'; @@ -7,9 +7,13 @@ import { FootnoteText, Icon, Plate, Shimmering, SmallTitleText } from '@/shared/ import { AssetBalance } from '@/entities/asset'; import { walletModel, walletUtils } from '@/entities/wallet'; import { EmptyAccountMessage } from '@/features/emptyList'; -import { walletSelectModel } from '@/features/wallets'; +import { walletDetailsFeature } from '@/features/wallet-details'; import { delegationAggregate } from '../../aggregates/delegation'; +const { + views: { WalletDetails }, +} = walletDetailsFeature; + type Props = { onClick: () => void; }; @@ -26,6 +30,8 @@ export const TotalDelegation = ({ onClick }: Props) => { const activeWallet = useUnit(walletModel.$activeWallet); + const [showWalletDetails, setShowWalletDetails] = useState(false); + const handleClick = () => { if (hasAccount && canDelegate) { onClick(); @@ -54,33 +60,41 @@ export const TotalDelegation = ({ onClick }: Props) => { confirmText: walletUtils.isPolkadotVault(activeWallet) ? t('emptyState.addAccountButton') : undefined, }).then((result) => { if (result && activeWallet) { - walletSelectModel.events.walletIdSet(activeWallet.id); + setShowWalletDetails(true); } }); }; return ( - + + )} +
    + + + + + + setShowWalletDetails(false)} + /> + ); }; diff --git a/src/renderer/features/governance/components/ReferendumFilter/Search.tsx b/src/renderer/features/governance/components/ReferendumFilter/Search.tsx index 928b97f771..88b7dadfb6 100644 --- a/src/renderer/features/governance/components/ReferendumFilter/Search.tsx +++ b/src/renderer/features/governance/components/ReferendumFilter/Search.tsx @@ -1,18 +1,18 @@ import { useUnit } from 'effector-react'; import { useI18n } from '@/shared/i18n'; -import { SearchInput } from '@/shared/ui'; +import { SearchInput } from '@/shared/ui-kit'; import { filterModel } from '../../model/filter'; export const Search = () => { const { t } = useI18n(); + const query = useUnit(filterModel.$query); return ( ); diff --git a/src/renderer/features/governance/components/VotingHistory/VotingHistoryList.tsx b/src/renderer/features/governance/components/VotingHistory/VotingHistoryList.tsx index f1a18b070e..05701bf5c0 100644 --- a/src/renderer/features/governance/components/VotingHistory/VotingHistoryList.tsx +++ b/src/renderer/features/governance/components/VotingHistory/VotingHistoryList.tsx @@ -4,8 +4,9 @@ import { type Asset, type Chain } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useDeferredList } from '@/shared/lib/hooks'; import { formatAsset, formatBalance, performSearch, toAccountId } from '@/shared/lib/utils'; -import { BodyText, FootnoteText, SearchInput } from '@/shared/ui'; +import { BodyText, FootnoteText } from '@/shared/ui'; import { AccountExplorers, Address } from '@/shared/ui-entities'; +import { SearchInput } from '@/shared/ui-kit'; import { type AggregatedVoteHistory } from '../../types/structs'; import { VotingHistoryListEmptyState } from './VotingHistoryListEmptyState'; diff --git a/src/renderer/features/governance/lib/createFeeCalculator.ts b/src/renderer/features/governance/lib/createFeeCalculator.ts deleted file mode 100644 index 4471740605..0000000000 --- a/src/renderer/features/governance/lib/createFeeCalculator.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type ApiPromise } from '@polkadot/api'; -import { type SignerOptions } from '@polkadot/api/types/submittable'; -import { BN, BN_ZERO } from '@polkadot/util'; -import { type Store, combine, createEffect, createStore, sample } from 'effector'; - -import { type Transaction } from '@/shared/core'; -import { nonNullable, nullable } from '@/shared/lib/utils'; -import { transactionService } from '@/entities/transaction'; - -type Params = { - $transaction: Store; - $api: Store; -}; - -// TODO discuss api for factories -export const createFeeCalculator = ({ $transaction, $api }: Params) => { - type RequestParams = { - api: ApiPromise; - transaction: Transaction; - signerOptions?: Partial; - }; - - const $source = combine({ transaction: $transaction, api: $api }, ({ transaction, api }) => { - if (nullable(transaction) || nullable(api)) return null; - - return { transaction, api }; - }); - - const $fee = createStore(BN_ZERO); - - const fetchFeeFx = createEffect(({ api, transaction, signerOptions }: RequestParams) => { - return transactionService.getTransactionFee(transaction, api, signerOptions).then((x) => new BN(x)); - }); - - sample({ - clock: $source, - filter: nullable, - fn: () => BN_ZERO, - target: $fee, - }); - - sample({ - clock: $source, - filter: nonNullable, - target: fetchFeeFx, - }); - - sample({ - clock: fetchFeeFx.doneData, - source: $transaction, - filter: nonNullable, - fn: (_, fee) => fee, - target: $fee, - }); - - return { $: $fee, $pending: fetchFeeFx.pending }; -}; diff --git a/src/renderer/features/governance/lib/createMultipleTxStore.ts b/src/renderer/features/governance/lib/createMultipleTxStore.ts index 39f95e624d..90c8bfcd70 100644 --- a/src/renderer/features/governance/lib/createMultipleTxStore.ts +++ b/src/renderer/features/governance/lib/createMultipleTxStore.ts @@ -3,11 +3,10 @@ import { type Store, combine, createStore } from 'effector'; import { type Account, type Chain, type Transaction, type Wallet } from '@/shared/core'; import { nullable } from '@/shared/lib/utils'; +import { createFeeCalculator } from '@/shared/transactions'; import { transactionService } from '@/entities/transaction'; import { accountUtils, walletUtils } from '@/entities/wallet'; -import { createFeeCalculator } from './createFeeCalculator'; - type Params = { $api: Store; $chain: Store; diff --git a/src/renderer/features/governance/lib/createTxStore.ts b/src/renderer/features/governance/lib/createTxStore.ts index 73324629e8..cc99ad6aaa 100644 --- a/src/renderer/features/governance/lib/createTxStore.ts +++ b/src/renderer/features/governance/lib/createTxStore.ts @@ -3,11 +3,10 @@ import { type Store, combine, createStore } from 'effector'; import { type Account, type Chain, type Transaction, type Wallet } from '@/shared/core'; import { nullable } from '@/shared/lib/utils'; +import { createFeeCalculator } from '@/shared/transactions'; import { transactionService } from '@/entities/transaction'; import { accountUtils, walletUtils } from '@/entities/wallet'; -import { createFeeCalculator } from './createFeeCalculator'; - type Params = { $api: Store; $chain: Store; diff --git a/src/renderer/features/multisig-operations/components/Operation.tsx b/src/renderer/features/multisig-operations/components/Operation.tsx new file mode 100644 index 0000000000..56f5c67a18 --- /dev/null +++ b/src/renderer/features/multisig-operations/components/Operation.tsx @@ -0,0 +1,26 @@ +import { type MultisigTransaction } from '@/shared/core'; +import { createSlot, useSlot } from '@/shared/di'; + +type Props = { + operation: MultisigTransaction; +}; + +type SlotProps = { + operation: MultisigTransaction; +}; + +export const operationDetailsSlot = createSlot(); +export const operationTitleSlot = createSlot(); + +// TODO: Temp solution +export const Operation = ({ operation }: Props) => { + const operationDetails = useSlot(operationDetailsSlot, { props: { operation } }); + const operationTitle = useSlot(operationTitleSlot, { props: { operation } }); + + return ( + + ); +}; diff --git a/src/renderer/features/multisig-operations/components/OperationsList.tsx b/src/renderer/features/multisig-operations/components/OperationsList.tsx new file mode 100644 index 0000000000..3b34b4ee43 --- /dev/null +++ b/src/renderer/features/multisig-operations/components/OperationsList.tsx @@ -0,0 +1,21 @@ +import { memo } from 'react'; + +import { type MultisigTransaction } from '@/shared/core'; + +import { Operation } from './Operation'; + +type Props = { + operations?: MultisigTransaction[]; +}; + +export const OperationList = memo(({ operations }: Props) => { + return ( + + ); +}); 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..6e9ff91eca --- /dev/null +++ b/src/renderer/features/multisig-operations/index.ts @@ -0,0 +1,13 @@ +import { operationDetailsSlot, operationTitleSlot } from './components/Operation'; +import { operationsModel } from './model/list'; + +export const multisigOperationsFeature = { + model: { + operations: operationsModel, + }, + views: {}, + slots: { + operationDetails: operationDetailsSlot, + operationTitle: operationTitleSlot, + }, +}; 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..7c4317faaa --- /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: 'multisig/operations', + input: $input, +}); + +multisigOperationsFeatureStatus.start(); + +sample({ + clock: walletModel.$activeWallet, + filter: walletUtils.isMultisig, + target: multisigOperationsFeatureStatus.restore, +}); diff --git a/src/renderer/features/navigation/index.ts b/src/renderer/features/navigation/index.ts index ea07128af2..7b1dbf64e5 100644 --- a/src/renderer/features/navigation/index.ts +++ b/src/renderer/features/navigation/index.ts @@ -1,2 +1 @@ -export { Navigation } from './ui/Navigation'; export { navigationModel } from './model/navigation-model'; diff --git a/src/renderer/features/navigation/ui/NavItem.tsx b/src/renderer/features/navigation/ui/NavItem.tsx deleted file mode 100644 index 7170cc407b..0000000000 --- a/src/renderer/features/navigation/ui/NavItem.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type ReactNode } from 'react'; -import { NavLink } from 'react-router-dom'; - -import { useI18n } from '@/shared/i18n'; -import { cnTw } from '@/shared/lib/utils'; -import { BodyText, Icon } from '@/shared/ui'; -import { type IconNames } from '@/shared/ui/Icon/data'; - -export type Props = { - title: string; - link: string; - icon: IconNames; - badge?: ReactNode; -}; - -export const NavItem = ({ title, link, icon, badge }: Props) => { - const { t } = useI18n(); - - return ( - - cnTw( - 'flex cursor-pointer select-none items-center rounded-md px-3.5 py-2.5 outline-offset-reduced hover:bg-tab-background', - isActive && 'bg-tab-background', - ) - } - > - {({ isActive }) => ( - <> - - - {t(title)} - - {badge} - - )} - - ); -}; diff --git a/src/renderer/features/navigation/ui/Navigation.tsx b/src/renderer/features/navigation/ui/Navigation.tsx deleted file mode 100644 index 6a58d45007..0000000000 --- a/src/renderer/features/navigation/ui/Navigation.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useUnit } from 'effector-react'; -import { memo } from 'react'; - -import { MultisigTxInitStatus } from '@/shared/core'; -import { useI18n } from '@/shared/i18n'; -import { Paths } from '@/shared/routes'; -import { BodyText } from '@/shared/ui'; -import { basketModel } from '@/entities/basket'; -import { useMultisigTx } from '@/entities/multisig'; -import { networkModel } from '@/entities/network'; -import { walletModel, walletUtils } from '@/entities/wallet'; -import { basketUtils } from '@/features/operations/OperationsConfirm'; - -import { NavItem, type Props as NavItemProps } from './NavItem'; - -export const Navigation = memo(() => { - const { t } = useI18n(); - - const chains = useUnit(networkModel.$chains); - const wallet = useUnit(walletModel.$activeWallet); - const basket = useUnit(basketModel.$basket); - - const { getLiveAccountMultisigTxs } = useMultisigTx({}); - - const txs = getLiveAccountMultisigTxs(walletUtils.isMultisig(wallet) ? [wallet.accounts[0].accountId] : []).filter( - (tx) => tx.status === MultisigTxInitStatus.SIGNING && chains[tx.chainId], - ); - - const NavItems: NavItemProps[] = [ - { icon: 'asset', title: 'navigation.balancesLabel', link: Paths.ASSETS }, - { icon: 'staking', title: 'navigation.stakingLabel', link: Paths.STAKING }, - { icon: 'governance', title: 'navigation.governance', link: Paths.GOVERNANCE }, - { icon: 'fellowshipNav', title: 'navigation.fellowship', link: Paths.FELLOWSHIP }, - { - icon: 'operations', - title: 'navigation.mstOperationLabel', - link: Paths.OPERATIONS, - badge: Boolean(txs.length) && {txs.length}, - }, - { icon: 'addressBook', title: 'navigation.addressBookLabel', link: Paths.ADDRESS_BOOK }, - ]; - - return ( - - ); -}); diff --git a/src/renderer/features/network/ManageCustomRpcNode/ui/AddCustomRpcModal.tsx b/src/renderer/features/network/ManageCustomRpcNode/ui/AddCustomRpcModal.tsx index c09e07127c..8290d63104 100644 --- a/src/renderer/features/network/ManageCustomRpcNode/ui/AddCustomRpcModal.tsx +++ b/src/renderer/features/network/ManageCustomRpcNode/ui/AddCustomRpcModal.tsx @@ -4,7 +4,8 @@ import { type FormEvent } from 'react'; import { useI18n } from '@/shared/i18n'; import { useModalClose } from '@/shared/lib/hooks'; -import { Alert, BaseModal, Button, Input, InputHint } from '@/shared/ui'; +import { Alert, BaseModal, Button, InputHint } from '@/shared/ui'; +import { Field, Input } from '@/shared/ui-kit'; import { OperationTitle } from '@/entities/chain'; import { customRpcUtils } from '../lib/custom-rpc-utils'; import { addCustomRpcModel } from '../model/add-custom-rpc-model'; @@ -59,9 +60,8 @@ const NameInput = () => { const isLoading = useUnit(addCustomRpcModel.$isLoading); return ( -
    + { {t(name.errorText())} -
    + ); }; @@ -85,9 +85,8 @@ const UrlInput = () => { const isLoading = useUnit(addCustomRpcModel.$isLoading); return ( -
    + { {t('settings.networks.addressHint')} -
    + ); }; diff --git a/src/renderer/features/network/ManageCustomRpcNode/ui/EditCustomRpcModal.tsx b/src/renderer/features/network/ManageCustomRpcNode/ui/EditCustomRpcModal.tsx index 36d77e7ada..f1394ad9d3 100644 --- a/src/renderer/features/network/ManageCustomRpcNode/ui/EditCustomRpcModal.tsx +++ b/src/renderer/features/network/ManageCustomRpcNode/ui/EditCustomRpcModal.tsx @@ -4,7 +4,8 @@ import { type FormEvent } from 'react'; import { useI18n } from '@/shared/i18n'; import { useModalClose } from '@/shared/lib/hooks'; -import { Alert, BaseModal, Button, Input, InputHint } from '@/shared/ui'; +import { Alert, BaseModal, Button, InputHint } from '@/shared/ui'; +import { Field, Input } from '@/shared/ui-kit'; import { OperationTitle } from '@/entities/chain'; import { customRpcUtils } from '../lib/custom-rpc-utils'; import { editCustomRpcModel } from '../model/edit-custom-rpc-model'; @@ -59,9 +60,8 @@ const NameInput = () => { const isLoading = useUnit(editCustomRpcModel.$isLoading); return ( -
    + { {t(name.errorText())} -
    + ); }; @@ -85,9 +85,8 @@ const UrlInput = () => { const isLoading = useUnit(editCustomRpcModel.$isLoading); return ( -
    + { {t('settings.networks.addressHint')} -
    + ); }; diff --git a/src/renderer/features/network/NetworkSelector/ui/NetworkSelector.tsx b/src/renderer/features/network/NetworkSelector/ui/NetworkSelector.tsx index 35d53e8f5c..a38bfd0e29 100644 --- a/src/renderer/features/network/NetworkSelector/ui/NetworkSelector.tsx +++ b/src/renderer/features/network/NetworkSelector/ui/NetworkSelector.tsx @@ -9,7 +9,6 @@ import { useScrollTo } from '@/shared/lib/hooks'; import { cnTw } from '@/shared/lib/utils'; import { Button, FootnoteText, HelpText, Icon, IconButton } from '@/shared/ui'; import { OptionStyle, SelectButtonStyle } from '@/shared/ui/Dropdowns/common/constants'; -import { CommonInputStyles, CommonInputStylesTheme } from '@/shared/ui/Inputs/common/styles'; import { type Theme } from '@/shared/ui/types'; import { type ConnectionItem, type SelectorPayload } from '../lib/types'; @@ -52,8 +51,8 @@ export const NetworkSelector = ({ className={cnTw( open && SelectButtonStyle[theme].open, SelectButtonStyle[theme].disabled, - CommonInputStyles, - CommonInputStylesTheme[theme], + 'bg-input-background text-text-primary', + 'rounded border px-3 py-[7px] text-footnote outline-offset-1', 'flex w-[248px] items-center justify-between gap-x-2', )} onClick={scroll} diff --git a/src/renderer/features/network/NetworksFilter/ui/NetworksFilter.tsx b/src/renderer/features/network/NetworksFilter/ui/NetworksFilter.tsx index e1afefea25..12d3e3be3a 100644 --- a/src/renderer/features/network/NetworksFilter/ui/NetworksFilter.tsx +++ b/src/renderer/features/network/NetworksFilter/ui/NetworksFilter.tsx @@ -2,14 +2,10 @@ import { useUnit } from 'effector-react'; import { useEffect } from 'react'; import { useI18n } from '@/shared/i18n'; -import { SearchInput } from '@/shared/ui'; +import { SearchInput } from '@/shared/ui-kit'; import { networksFilterModel } from '../model/networks-filter-model'; -type Props = { - className?: string; -}; - -export const NetworksFilter = ({ className }: Props) => { +export const NetworksFilter = () => { const { t } = useI18n(); const filterQuery = useUnit(networksFilterModel.$filterQuery); @@ -20,9 +16,8 @@ export const NetworksFilter = ({ className }: Props) => { return ( ); diff --git a/src/renderer/features/notifications-navigation/index.ts b/src/renderer/features/notifications-navigation/index.ts index 82243e1e96..df1d5da4c6 100644 --- a/src/renderer/features/notifications-navigation/index.ts +++ b/src/renderer/features/notifications-navigation/index.ts @@ -4,7 +4,7 @@ import { Paths } from '@/shared/routes'; import { navigationBottomLinksPipeline } from '@/features/app-shell'; export const notificationsNavigationFeature = createFeature({ - name: 'Notifications navigation', + name: 'notifications/navigation', enable: $features.map(({ notifications }) => notifications), }); diff --git a/src/renderer/features/notifications/NotificationsList/lib/constants.ts b/src/renderer/features/notifications/NotificationsList/lib/constants.ts index a4205b6caa..3e05b0b936 100644 --- a/src/renderer/features/notifications/NotificationsList/lib/constants.ts +++ b/src/renderer/features/notifications/NotificationsList/lib/constants.ts @@ -1,12 +1,12 @@ -import { ProxyType } from '@/shared/core'; +import { type ProxyType } from '@/shared/core'; export const ProxyTypeOperation: Record = { - [ProxyType.ANY]: 'proxy.operations.any', - [ProxyType.NON_TRANSFER]: 'proxy.operations.nonTransfer', - [ProxyType.STAKING]: 'proxy.operations.staking', - [ProxyType.AUCTION]: 'proxy.operations.auction', - [ProxyType.CANCEL_PROXY]: 'proxy.operations.cancelProxy', - [ProxyType.GOVERNANCE]: 'proxy.operations.governance', - [ProxyType.IDENTITY_JUDGEMENT]: 'proxy.operations.identityJudgement', - [ProxyType.NOMINATION_POOLS]: 'proxy.operations.nominationPools', + ['Any']: 'proxy.operations.any', + ['NonTransfer']: 'proxy.operations.nonTransfer', + ['Staking']: 'proxy.operations.staking', + ['Auction']: 'proxy.operations.auction', + ['CancelProxy']: 'proxy.operations.cancelProxy', + ['Governance']: 'proxy.operations.governance', + ['IdentityJudgement']: 'proxy.operations.identityJudgement', + ['NominationPools']: 'proxy.operations.nominationPools', }; diff --git a/src/renderer/features/notifications/NotificationsList/ui/NotificationRow.tsx b/src/renderer/features/notifications/NotificationsList/ui/NotificationRow.tsx index 6dde271b1f..9af06173fd 100644 --- a/src/renderer/features/notifications/NotificationsList/ui/NotificationRow.tsx +++ b/src/renderer/features/notifications/NotificationsList/ui/NotificationRow.tsx @@ -1,16 +1,20 @@ import { type ReactNode } from 'react'; -import { type MultisigCreated, type Notification, type ProxyAction } from '@/shared/core'; +import { type FlexibleMultisigCreated, type MultisigCreated, type Notification, type ProxyAction } from '@/shared/core'; import { NotificationType } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { FootnoteText } from '@/shared/ui'; +import { FlexibleMultisigCreatedNotification } from './notifies/FlexibleMultisigCreatedNotification'; import { MultisigCreatedNotification } from './notifies/MultisigCreatedNotification'; import { ProxyCreatedNotification } from './notifies/ProxyCreatedNotification'; import { ProxyRemovedNotification } from './notifies/ProxyRemovedNotification'; const Notifications: Record ReactNode> = { [NotificationType.MULTISIG_CREATED]: (n) => , + [NotificationType.FLEXIBLE_MULTISIG_CREATED]: (n) => ( + + ), [NotificationType.MULTISIG_APPROVED]: () => null, [NotificationType.MULTISIG_CANCELLED]: () => null, [NotificationType.MULTISIG_EXECUTED]: () => null, diff --git a/src/renderer/features/notifications/NotificationsList/ui/notifies/FlexibleMultisigCreatedNotification.tsx b/src/renderer/features/notifications/NotificationsList/ui/notifies/FlexibleMultisigCreatedNotification.tsx new file mode 100644 index 0000000000..ee41b0bd5a --- /dev/null +++ b/src/renderer/features/notifications/NotificationsList/ui/notifies/FlexibleMultisigCreatedNotification.tsx @@ -0,0 +1,56 @@ +import { Trans } from 'react-i18next'; + +import { type FlexibleMultisigCreated } from '@/shared/core'; +import { WalletType } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { BodyText, Button } from '@/shared/ui'; +import { Box } from '@/shared/ui-kit'; +import { ChainTitle } from '@/entities/chain'; +import { WalletIcon, walletModel } from '@/entities/wallet'; + +type Props = { + notification: FlexibleMultisigCreated; +}; + +export const FlexibleMultisigCreatedNotification = ({ + notification: { threshold, signatories, multisigAccountName, chainId, walletId }, +}: Props) => { + const { t } = useI18n(); + + const switchWallet = () => { + walletModel.events.selectWallet(walletId); + }; + + return ( + +
    + +
    +
    + + + + {t('notifications.details.multisigCreatedTitle')} + + , + }} + /> + + + + + + + ); +}; diff --git a/src/renderer/features/operation-details/governance-operation-details/components/GovernanceOperationTitle.tsx b/src/renderer/features/operation-details/governance-operation-details/components/GovernanceOperationTitle.tsx new file mode 100644 index 0000000000..38e064e9c8 --- /dev/null +++ b/src/renderer/features/operation-details/governance-operation-details/components/GovernanceOperationTitle.tsx @@ -0,0 +1,30 @@ +import { chainsService } from '@/shared/api/network'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { getAssetById } from '@/shared/lib/utils'; +import { AssetBalance } from '@/entities/asset'; +import { ChainTitle } from '@/entities/chain'; +import { TransactionTitle, getTransactionAmount } from '@/entities/transaction'; + +type Props = { + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; +}; + +export const GovernanceOperationTitle = ({ tx }: Props) => { + const asset = + tx.transaction && getAssetById(tx.transaction.args.asset, chainsService.getChainById(tx.chainId)?.assets); + const amount = tx.transaction && getTransactionAmount(tx.transaction); + + return ( + <> + + + {asset && amount && ( +
    + +
    + )} + + + + ); +}; diff --git a/src/renderer/features/operation-details/governance-operation-details/governance-operation-details-model.tsx b/src/renderer/features/operation-details/governance-operation-details/governance-operation-details-model.tsx new file mode 100644 index 0000000000..051bb923de --- /dev/null +++ b/src/renderer/features/operation-details/governance-operation-details/governance-operation-details-model.tsx @@ -0,0 +1,215 @@ +import { useUnit } from 'effector-react'; +import { useEffect, useState } from 'react'; +import { Trans } from 'react-i18next'; + +import { type Address, TransactionType } from '@/shared/core'; +import { createFeature } from '@/shared/effector'; +import { useI18n } from '@/shared/i18n'; +import { toAccountId } from '@/shared/lib/utils'; +import { DetailRow, FootnoteText } from '@/shared/ui'; +import { Account } from '@/shared/ui-entities'; +import { Skeleton } from '@/shared/ui-kit'; +import { AssetBalance } from '@/entities/asset'; +import { TracksDetails, voteTransactionService } from '@/entities/governance'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { networkModel } from '@/entities/network'; +import { operationDetailsUtils } from '@/entities/operations'; +import { isUndelegateTransaction } from '@/entities/transaction'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; + +import { GovernanceOperationTitle } from './components/GovernanceOperationTitle'; + +export const governanceOperationDetailFeature = createFeature({ + name: 'governance/operation-details', +}); + +governanceOperationDetailFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const { t } = useI18n(); + const transaction = getTransactionFromMultisigTx(operation); + + const chains = useUnit(networkModel.$chains); + const apis = useUnit(networkModel.$apis); + + const chain = chains[operation.chainId]; + const api = apis[operation.chainId]; + + const defaultAsset = chain?.assets[0]; + + const [isUndelegationLoading, setIsUndelegationLoading] = useState(false); + const [undelegationVotes, setUndelegationVotes] = useState(); + const [undelegationTarget, setUndelegationTarget] = useState
    (); + + const result = []; + + useEffect(() => { + if (isUndelegateTransaction(transaction)) { + setIsUndelegationLoading(true); + } + + if (!api) return; + + operationDetailsUtils.getUndelegationData(api, operation).then(({ votes, target }) => { + setUndelegationVotes(votes); + setUndelegationTarget(target); + setIsUndelegationLoading(false); + }); + }, [api, operation]); + + if ( + transaction?.type && + ![ + TransactionType.UNLOCK, + TransactionType.VOTE, + TransactionType.REVOTE, + TransactionType.REMOVE_VOTE, + TransactionType.DELEGATE, + TransactionType.UNDELEGATE, + TransactionType.EDIT_DELEGATION, + ].includes(transaction.type) + ) { + return null; + } + + const delegationTarget = operationDetailsUtils.getDelegationTarget(operation); + const delegationTracks = operationDetailsUtils.getDelegationTracks(operation); + const delegationVotes = operationDetailsUtils.getDelegationVotes(operation); + + const referendumId = operationDetailsUtils.getReferendumId(operation); + const vote = operationDetailsUtils.getVote(operation); + + if (referendumId) { + result.push( + + #{referendumId} + , + ); + } + + if (vote) { + result.push( + + + <> + + {t(`governance.referendum.${voteTransactionService.getDecision(vote)}`)} + + :{' '} + + ), + }} + /> + + + , + ); + } + + if (isUndelegationLoading) { + result.push( + <> + + + + + + + + , + ); + } + + if (delegationTarget) { + result.push( + + + , + ); + } + + if (!delegationTarget && undelegationTarget) { + result.push( + + + , + ); + } + + if (delegationVotes) { + result.push( + + + + + , + ); + } + + if (!delegationVotes && undelegationVotes) { + result.push( + + + + + , + ); + } + + if (delegationTracks) { + result.push( + +
    + +
    +
    , + ); + } + + return <>{result.map((e) => e)}; + }, + order: 1, +}); + +governanceOperationDetailFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if ( + transaction?.type && + [ + TransactionType.UNLOCK, + TransactionType.VOTE, + TransactionType.REVOTE, + TransactionType.REMOVE_VOTE, + TransactionType.DELEGATE, + TransactionType.UNDELEGATE, + TransactionType.EDIT_DELEGATION, + ].includes(transaction.type) + ) { + return ; + } + + return null; + }, + order: 1, +}); diff --git a/src/renderer/features/operation-details/governance-operation-details/index.ts b/src/renderer/features/operation-details/governance-operation-details/index.ts new file mode 100644 index 0000000000..e0fe287d90 --- /dev/null +++ b/src/renderer/features/operation-details/governance-operation-details/index.ts @@ -0,0 +1 @@ +export { governanceOperationDetailFeature } from './governance-operation-details-model'; diff --git a/src/renderer/features/operation-details/index.ts b/src/renderer/features/operation-details/index.ts new file mode 100644 index 0000000000..3b6913a0fc --- /dev/null +++ b/src/renderer/features/operation-details/index.ts @@ -0,0 +1,5 @@ +export { stakingOperationDetailFeature } from './staking-operation-details'; +export { transferOperationDetailFeature } from './transfer-operation-details'; +export { proxyOperationDetailFeature } from './proxy-operation-details'; +export { governanceOperationDetailFeature } from './governance-operation-details'; +export { multisigOperationDetailsFeature } from './multisig-operation-details'; diff --git a/src/renderer/features/operation-details/multisig-operation-details/index.ts b/src/renderer/features/operation-details/multisig-operation-details/index.ts new file mode 100644 index 0000000000..97985377ea --- /dev/null +++ b/src/renderer/features/operation-details/multisig-operation-details/index.ts @@ -0,0 +1 @@ +export { multisigOperationDetailsFeature } from './multisig-operation-details-model'; diff --git a/src/renderer/features/operation-details/multisig-operation-details/multisig-operation-details-model.tsx b/src/renderer/features/operation-details/multisig-operation-details/multisig-operation-details-model.tsx new file mode 100644 index 0000000000..da87ad9961 --- /dev/null +++ b/src/renderer/features/operation-details/multisig-operation-details/multisig-operation-details-model.tsx @@ -0,0 +1,273 @@ +import { useStoreMap, useUnit } from 'effector-react'; + +import { createFeature } from '@/shared/effector'; +import { useI18n } from '@/shared/i18n'; +import { useToggle } from '@/shared/lib/hooks'; +import { cnTw, copyToClipboard, truncate } from '@/shared/lib/utils'; +import { Button, DetailRow, FootnoteText, Icon } from '@/shared/ui'; +import { AccountExplorers } from '@/shared/ui-entities'; +import { Box } from '@/shared/ui-kit'; +import { AssetBalance } from '@/entities/asset'; +import { ChainTitle } from '@/entities/chain'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { networkModel } from '@/entities/network'; +import { AddressStyle, InteractionStyle, Status, operationDetailsUtils, operationsModel } from '@/entities/operations'; +import { signatoryUtils } from '@/entities/signatory'; +import { TransactionTitle } from '@/entities/transaction'; +import { + AddressWithExplorers, + ExplorersPopover, + WalletCardSm, + WalletIcon, + accountUtils, + walletModel, +} from '@/entities/wallet'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; + +export const multisigOperationDetailsFeature = createFeature({ + name: 'multisig/operation details', +}); + +multisigOperationDetailsFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const { t } = useI18n(); + const chains = useUnit(networkModel.$chains); + const activeWallet = useUnit(walletModel.$activeWallet); + + if (!activeWallet) return null; + + const accountId = activeWallet.accounts[0].accountId; + const chain = chains[operation.chainId]; + + return ( + + + + {activeWallet.name} + + + + ); + }, + order: 0, +}); + +multisigOperationDetailsFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const { formatDate } = useI18n(); + + const events = useStoreMap({ + store: operationsModel.$multisigEvents, + keys: [operation], + fn: (events, [operation]) => { + return events.filter( + (e) => + e.txAccountId === operation.accountId && + e.txChainId === operation.chainId && + e.txCallHash === operation.callHash && + e.txBlock === operation.blockCreated && + e.txIndex === operation.indexCreated, + ); + }, + }); + const approvals = events?.filter((e) => e.status === 'SIGNED') || []; + const initEvent = approvals.find((e) => e.accountId === operation.depositor); + const date = new Date(operation.dateCreated || initEvent?.dateCreated || Date.now()); + + return ( +
    + + {formatDate(date, 'p')} + +
    + ); + }, + order: 0, +}); + +multisigOperationDetailsFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if (!transaction) { + return ( + <> + + + + + ); + } + + return null; + }, + order: 1, +}); + +multisigOperationDetailsFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const events = useStoreMap({ + store: operationsModel.$multisigEvents, + keys: [operation], + fn: (events, [operation]) => { + return events.filter( + (e) => + e.txAccountId === operation.accountId && + e.txChainId === operation.chainId && + e.txCallHash === operation.callHash && + e.txBlock === operation.blockCreated && + e.txIndex === operation.indexCreated, + ); + }, + }); + + const approvals = events?.filter((e) => e.status === 'SIGNED') || []; + const activeWallet = useUnit(walletModel.$activeWallet); + const account = activeWallet?.accounts.find(accountUtils.isMultisigAccount); + + return ( +
    + +
    + ); + }, + order: 2, +}); + +multisigOperationDetailsFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const { t } = useI18n(); + + const wallets = useUnit(walletModel.$wallets); + const activeWallet = useUnit(walletModel.$activeWallet); + const chains = useUnit(networkModel.$chains); + const chain = chains[operation.chainId]; + const account = activeWallet?.accounts.find(accountUtils.isMultisigAccount); + + const defaultAsset = chain?.assets[0]; + const addressPrefix = chain?.addressPrefix; + const explorers = chain?.explorers; + + const [isAdvancedShown, toggleAdvanced] = useToggle(); + + const { indexCreated, blockCreated, deposit, depositor, callHash, callData } = operation; + + const depositorSignatory = account?.signatories.find((s) => s.accountId === depositor); + const extrinsicLink = operationDetailsUtils.getMultisigExtrinsicLink( + callHash, + indexCreated, + blockCreated, + explorers, + ); + + const valueClass = 'text-text-secondary'; + const depositorWallet = + depositorSignatory && signatoryUtils.getSignatoryWallet(wallets, depositorSignatory.accountId); + + return ( + <> + + + {isAdvancedShown && ( + <> + {callHash && ( + + + + )} + + {callData && ( + + + + )} + + {deposit && defaultAsset && depositorSignatory &&
    } + + {depositorSignatory && ( + +
    + {depositorWallet ? ( + } + address={depositorSignatory.accountId} + explorers={explorers} + addressPrefix={addressPrefix} + /> + ) : ( + + )} +
    +
    + )} + + {deposit && defaultAsset && ( + + + + )} + + {deposit && defaultAsset && depositorSignatory &&
    } + + {indexCreated && blockCreated && ( + + {extrinsicLink ? ( + + + {blockCreated}-{indexCreated} + + + + ) : ( + `${blockCreated}-${indexCreated}` + )} + + )} + + )} + + ); + }, + order: 999, +}); diff --git a/src/renderer/features/operation-details/proxy-operation-details/components/ProxyOperationTitle.tsx b/src/renderer/features/operation-details/proxy-operation-details/components/ProxyOperationTitle.tsx new file mode 100644 index 0000000000..14a9b96ebd --- /dev/null +++ b/src/renderer/features/operation-details/proxy-operation-details/components/ProxyOperationTitle.tsx @@ -0,0 +1,30 @@ +import { chainsService } from '@/shared/api/network'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { getAssetById } from '@/shared/lib/utils'; +import { AssetBalance } from '@/entities/asset'; +import { ChainTitle } from '@/entities/chain'; +import { TransactionTitle, getTransactionAmount } from '@/entities/transaction'; + +type Props = { + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; +}; + +export const ProxyOperationTitle = ({ tx }: Props) => { + const asset = + tx.transaction && getAssetById(tx.transaction.args.asset, chainsService.getChainById(tx.chainId)?.assets); + const amount = tx.transaction && getTransactionAmount(tx.transaction); + + return ( + <> + + + {asset && amount && ( +
    + +
    + )} + + + + ); +}; diff --git a/src/renderer/features/operation-details/proxy-operation-details/index.ts b/src/renderer/features/operation-details/proxy-operation-details/index.ts new file mode 100644 index 0000000000..24d2b860ed --- /dev/null +++ b/src/renderer/features/operation-details/proxy-operation-details/index.ts @@ -0,0 +1 @@ +export { proxyOperationDetailFeature } from './proxy-operation-details-model'; diff --git a/src/renderer/features/operation-details/proxy-operation-details/proxy-operation-details-model.tsx b/src/renderer/features/operation-details/proxy-operation-details/proxy-operation-details-model.tsx new file mode 100644 index 0000000000..bfeac7bdd2 --- /dev/null +++ b/src/renderer/features/operation-details/proxy-operation-details/proxy-operation-details-model.tsx @@ -0,0 +1,96 @@ +import { useUnit } from 'effector-react'; + +import { TransactionType } from '@/shared/core'; +import { createFeature } from '@/shared/effector'; +import { useI18n } from '@/shared/i18n'; +import { toAccountId } from '@/shared/lib/utils'; +import { DetailRow, FootnoteText } from '@/shared/ui'; +import { Account } from '@/shared/ui-entities'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { networkModel } from '@/entities/network'; +import { operationDetailsUtils } from '@/entities/operations'; +import { proxyUtils } from '@/entities/proxy'; +import { + isAddProxyTransaction, + isManageProxyTransaction, + isRemoveProxyTransaction, + isRemovePureProxyTransaction, +} from '@/entities/transaction'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; + +import { ProxyOperationTitle } from './components/ProxyOperationTitle'; + +export const proxyOperationDetailFeature = createFeature({ + name: 'proxy/operation-details', +}); + +proxyOperationDetailFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const { t } = useI18n(); + const transaction = getTransactionFromMultisigTx(operation); + const chains = useUnit(networkModel.$chains); + const chain = chains[operation.chainId]; + + const result = []; + + const delegate = operationDetailsUtils.getDelegate(operation); + const sender = operationDetailsUtils.getSender(operation); + const proxyType = operationDetailsUtils.getProxyType(operation); + + if (isAddProxyTransaction(transaction) && delegate) { + result.push( + + + , + ); + } + + if (isRemoveProxyTransaction(transaction) && delegate) { + result.push( + + + , + ); + } + + if (isRemovePureProxyTransaction(transaction) && sender) { + result.push( + + + , + ); + } + + if (isManageProxyTransaction(transaction) && proxyType) { + result.push( + + {t(proxyUtils.getProxyTypeName(proxyType))} + , + ); + } + + return <>{result.map((e) => e)}; + }, + order: 1, +}); + +proxyOperationDetailFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if ( + transaction?.type && + [ + TransactionType.ADD_PROXY, + TransactionType.REMOVE_PROXY, + TransactionType.CREATE_PURE_PROXY, + TransactionType.REMOVE_PURE_PROXY, + ].includes(transaction.type) + ) { + return ; + } + + return null; + }, + order: 1, +}); diff --git a/src/renderer/features/operation-details/staking-operation-details/components/PayeeOperationDetails.tsx b/src/renderer/features/operation-details/staking-operation-details/components/PayeeOperationDetails.tsx new file mode 100644 index 0000000000..28cc2d07ab --- /dev/null +++ b/src/renderer/features/operation-details/staking-operation-details/components/PayeeOperationDetails.tsx @@ -0,0 +1,40 @@ +import { useUnit } from 'effector-react'; + +import { type AccountId, type MultisigTransaction } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { cnTw } from '@/shared/lib/utils'; +import { DetailRow } from '@/shared/ui'; +import { Account } from '@/shared/ui-entities'; +import { networkModel } from '@/entities/network'; +import { operationDetailsUtils } from '@/entities/operations'; + +type Props = { + operation: MultisigTransaction; +}; + +export const PayeeOperationDetails = ({ operation }: Props) => { + const { t } = useI18n(); + + const chains = useUnit(networkModel.$chains); + + const result = []; + + const payee = operationDetailsUtils.getPayee(operation); + + if (payee) { + result.push( + + {typeof payee === 'string' ? ( + t('staking.confirmation.restakeRewards') + ) : ( + + )} + , + ); + } + + return <>{result.map((e) => e)}; +}; diff --git a/src/renderer/features/operation-details/staking-operation-details/components/StakingOperationTitle.tsx b/src/renderer/features/operation-details/staking-operation-details/components/StakingOperationTitle.tsx new file mode 100644 index 0000000000..fb16d76b77 --- /dev/null +++ b/src/renderer/features/operation-details/staking-operation-details/components/StakingOperationTitle.tsx @@ -0,0 +1,30 @@ +import { chainsService } from '@/shared/api/network'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { getAssetById } from '@/shared/lib/utils'; +import { AssetBalance } from '@/entities/asset'; +import { ChainTitle } from '@/entities/chain'; +import { TransactionTitle, getTransactionAmount } from '@/entities/transaction'; + +type Props = { + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; +}; + +export const StakingOperationTitle = ({ tx }: Props) => { + const asset = + tx.transaction && getAssetById(tx.transaction.args.asset, chainsService.getChainById(tx.chainId)?.assets); + const amount = tx.transaction && getTransactionAmount(tx.transaction); + + return ( + <> + + + {asset && amount && ( +
    + +
    + )} + + + + ); +}; diff --git a/src/renderer/features/operation-details/staking-operation-details/components/ValidatorsOperationDetails.tsx b/src/renderer/features/operation-details/staking-operation-details/components/ValidatorsOperationDetails.tsx new file mode 100644 index 0000000000..90f2f98a17 --- /dev/null +++ b/src/renderer/features/operation-details/staking-operation-details/components/ValidatorsOperationDetails.tsx @@ -0,0 +1,88 @@ +import { useUnit } from 'effector-react'; + +import { chainsService } from '@/shared/api/network'; +import { + type Address, + type MultisigTransaction, + type Transaction, + TransactionType, + type Validator, +} from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { useToggle } from '@/shared/lib/hooks'; +import { cnTw, getAssetById } from '@/shared/lib/utils'; +import { DetailRow, FootnoteText, Icon } from '@/shared/ui'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { networkModel, networkUtils } from '@/entities/network'; +import { ValidatorsModal, useValidatorsMap } from '@/entities/staking'; + +type Props = { + operation: MultisigTransaction; +}; + +export const ValidatorsOperationDetails = ({ operation }: Props) => { + const { t } = useI18n(); + + const [isValidatorsOpen, toggleValidators] = useToggle(); + + const chains = useUnit(networkModel.$chains); + const apis = useUnit(networkModel.$apis); + const connections = useUnit(networkModel.$connections); + + const api = apis[operation.chainId]; + const connection = connections[operation.chainId]; + const chain = chains[operation.chainId]; + const defaultAsset = chain?.assets[0]; + + const result = []; + + const transaction = getTransactionFromMultisigTx(operation); + const validatorsMap = useValidatorsMap(api, connection && networkUtils.isLightClientConnection(connection)); + + const allValidators = Object.values(validatorsMap); + + const startStakingValidators: Address[] = + (transaction?.type === TransactionType.BATCH_ALL && + transaction.args.transactions.find((tx: Transaction) => tx.type === TransactionType.NOMINATE)?.args?.targets) || + []; + + const selectedValidators: Validator[] = + allValidators.filter((v) => (transaction?.args.targets || startStakingValidators).includes(v.address)) || []; + const selectedValidatorsAddress = selectedValidators.map((validator) => validator.address); + const notSelectedValidators = allValidators.filter((v) => !selectedValidatorsAddress.includes(v.address)); + const validatorsAsset = + transaction && getAssetById(transaction.args.asset, chainsService.getChainById(operation.chainId)?.assets); + + if (Boolean(selectedValidators?.length) && defaultAsset) { + result.push( + <> + + + + + + , + ); + } + + return <>{result.map((e) => e)}; +}; diff --git a/src/renderer/features/operation-details/staking-operation-details/index.ts b/src/renderer/features/operation-details/staking-operation-details/index.ts new file mode 100644 index 0000000000..81770e5f43 --- /dev/null +++ b/src/renderer/features/operation-details/staking-operation-details/index.ts @@ -0,0 +1 @@ +export { stakingOperationDetailFeature } from './staking-operation-details-model'; diff --git a/src/renderer/features/operation-details/staking-operation-details/staking-operation-details-model.tsx b/src/renderer/features/operation-details/staking-operation-details/staking-operation-details-model.tsx new file mode 100644 index 0000000000..1531ec210c --- /dev/null +++ b/src/renderer/features/operation-details/staking-operation-details/staking-operation-details-model.tsx @@ -0,0 +1,71 @@ +import { TransactionType } from '@/shared/core'; +import { createFeature } from '@/shared/effector'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; + +import { PayeeOperationDetails } from './components/PayeeOperationDetails'; +import { StakingOperationTitle } from './components/StakingOperationTitle'; +import { ValidatorsOperationDetails } from './components/ValidatorsOperationDetails'; + +export const stakingOperationDetailFeature = createFeature({ + name: 'staking/operation-details', +}); + +stakingOperationDetailFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if ( + transaction?.type && + [ + TransactionType.BOND, + TransactionType.STAKE_MORE, + TransactionType.UNSTAKE, + TransactionType.RESTAKE, + TransactionType.REDEEM, + ].includes(transaction.type) + ) { + return ; + } + + return null; + }, + order: 1, +}); + +stakingOperationDetailFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if (transaction?.type && [TransactionType.BOND, TransactionType.NOMINATE].includes(transaction.type)) { + return ; + } + + return null; + }, + order: 2, +}); + +stakingOperationDetailFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if ( + transaction?.type && + [ + TransactionType.BOND, + TransactionType.STAKE_MORE, + TransactionType.UNSTAKE, + TransactionType.RESTAKE, + TransactionType.REDEEM, + TransactionType.NOMINATE, + TransactionType.DESTINATION, + ].includes(transaction.type) + ) { + return ; + } + + return null; + }, + order: 1, +}); diff --git a/src/renderer/features/operation-details/transfer-operation-details/components/TransferOperationDetails.tsx b/src/renderer/features/operation-details/transfer-operation-details/components/TransferOperationDetails.tsx new file mode 100644 index 0000000000..0b1be1f463 --- /dev/null +++ b/src/renderer/features/operation-details/transfer-operation-details/components/TransferOperationDetails.tsx @@ -0,0 +1,64 @@ +import { useUnit } from 'effector-react'; + +import { type MultisigTransaction } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { toAccountId } from '@/shared/lib/utils'; +import { DetailRow } from '@/shared/ui'; +import { Account } from '@/shared/ui-entities'; +import { ChainTitle } from '@/entities/chain'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { networkModel } from '@/entities/network'; +import { operationDetailsUtils } from '@/entities/operations'; +import { isXcmTransaction } from '@/entities/transaction'; + +type Props = { + operation: MultisigTransaction; +}; + +export const TransferOperationDetails = ({ operation }: Props) => { + const { t } = useI18n(); + const chains = useUnit(networkModel.$chains); + + const transaction = getTransactionFromMultisigTx(operation); + + const result = []; + + const destination = operationDetailsUtils.getDestinationAccountId(operation); + + if (destination) { + result.push( + + + , + ); + } + + const sender = operationDetailsUtils.getSender(operation); + const destinationChain = operationDetailsUtils.getDestinationChain(operation); + + if (isXcmTransaction(transaction) && sender) { + result.push( + + + , + ); + } + + if (isXcmTransaction(transaction)) { + result.push( + + + , + ); + } + + if (isXcmTransaction(transaction) && destinationChain) { + result.push( + + + , + ); + } + + return <>{result.map((e) => e)}; +}; diff --git a/src/renderer/features/operation-details/transfer-operation-details/components/TransferOperationTitle.tsx b/src/renderer/features/operation-details/transfer-operation-details/components/TransferOperationTitle.tsx new file mode 100644 index 0000000000..5e93f39e7a --- /dev/null +++ b/src/renderer/features/operation-details/transfer-operation-details/components/TransferOperationTitle.tsx @@ -0,0 +1,31 @@ +import { chainsService } from '@/shared/api/network'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { getAssetById } from '@/shared/lib/utils'; +import { AssetBalance } from '@/entities/asset'; +import { ChainTitle } from '@/entities/chain'; +import { TransactionTitle, getTransactionAmount } from '@/entities/transaction'; + +type Props = { + operation: MultisigTransactionDS | FlexibleMultisigTransactionDS; +}; + +export const TransferOperationTitle = ({ operation }: Props) => { + const asset = + operation.transaction && + getAssetById(operation.transaction.args.asset, chainsService.getChainById(operation.chainId)?.assets); + const amount = operation.transaction && getTransactionAmount(operation.transaction); + + return ( + <> + + + {asset && amount && ( +
    + +
    + )} + + + + ); +}; diff --git a/src/renderer/features/operation-details/transfer-operation-details/components/XcmTransferOperationTitle.tsx b/src/renderer/features/operation-details/transfer-operation-details/components/XcmTransferOperationTitle.tsx new file mode 100644 index 0000000000..c8d4f77e9c --- /dev/null +++ b/src/renderer/features/operation-details/transfer-operation-details/components/XcmTransferOperationTitle.tsx @@ -0,0 +1,35 @@ +import { chainsService } from '@/shared/api/network'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { getAssetById } from '@/shared/lib/utils'; +import { AssetBalance } from '@/entities/asset'; +import { XcmChains } from '@/entities/chain'; +import { TransactionTitle, getTransactionAmount } from '@/entities/transaction'; + +type Props = { + operation: MultisigTransactionDS | FlexibleMultisigTransactionDS; +}; + +export const XcmTransferOperationTitle = ({ operation }: Props) => { + const asset = + operation.transaction && + getAssetById(operation.transaction.args.asset, chainsService.getChainById(operation.chainId)?.assets); + const amount = operation.transaction && getTransactionAmount(operation.transaction); + + return ( + <> + + + {asset && amount && ( +
    + +
    + )} + + + + ); +}; diff --git a/src/renderer/features/operation-details/transfer-operation-details/index.ts b/src/renderer/features/operation-details/transfer-operation-details/index.ts new file mode 100644 index 0000000000..bd37c6cfc9 --- /dev/null +++ b/src/renderer/features/operation-details/transfer-operation-details/index.ts @@ -0,0 +1 @@ +export { transferOperationDetailFeature } from './transfer-operation-details-model'; diff --git a/src/renderer/features/operation-details/transfer-operation-details/transfer-operation-details-model.tsx b/src/renderer/features/operation-details/transfer-operation-details/transfer-operation-details-model.tsx new file mode 100644 index 0000000000..3f78c62bf6 --- /dev/null +++ b/src/renderer/features/operation-details/transfer-operation-details/transfer-operation-details-model.tsx @@ -0,0 +1,42 @@ +import { createFeature } from '@/shared/effector'; +import { getTransactionFromMultisigTx } from '@/entities/multisig'; +import { isTransferTransaction, isXcmTransaction } from '@/entities/transaction'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; + +import { TransferOperationDetails } from './components/TransferOperationDetails'; +import { TransferOperationTitle } from './components/TransferOperationTitle'; +import { XcmTransferOperationTitle } from './components/XcmTransferOperationTitle'; + +export const transferOperationDetailFeature = createFeature({ + name: 'transfer/operations', +}); + +transferOperationDetailFeature.inject(multisigOperationsFeature.slots.operationDetails, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if (isTransferTransaction(transaction) || isXcmTransaction(transaction)) { + return ; + } + + return null; + }, + order: 1, +}); + +transferOperationDetailFeature.inject(multisigOperationsFeature.slots.operationTitle, { + render: ({ operation }) => { + const transaction = getTransactionFromMultisigTx(operation); + + if (isTransferTransaction(transaction)) { + return ; + } + + if (isXcmTransaction(transaction)) { + return ; + } + + return null; + }, + order: 1, +}); diff --git a/src/renderer/features/operations-navigation/index.tsx b/src/renderer/features/operations-navigation/index.tsx index 0d3297bc79..322c442fd9 100644 --- a/src/renderer/features/operations-navigation/index.tsx +++ b/src/renderer/features/operations-navigation/index.tsx @@ -11,7 +11,7 @@ import { walletModel, walletUtils } from '@/entities/wallet'; import { navigationTopLinksPipeline } from '@/features/app-shell'; export const operationsNavigationFeature = createFeature({ - name: 'Operations navigation', + name: 'operations/navigation', enable: $features.map(({ operations }) => operations), }); diff --git a/src/renderer/features/operations/OperationsConfirm/Delegate/ui/Confirmation.tsx b/src/renderer/features/operations/OperationsConfirm/Delegate/ui/Confirmation.tsx index 95bb8e5179..b1ffe9137f 100644 --- a/src/renderer/features/operations/OperationsConfirm/Delegate/ui/Confirmation.tsx +++ b/src/renderer/features/operations/OperationsConfirm/Delegate/ui/Confirmation.tsx @@ -161,7 +161,7 @@ export const Confirmation = ({
    - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( - {t(proxyUtils.getProxyTypeName(ProxyType.ANY))} + {/* eslint-disable-next-line i18next/no-literal-string */} + {t(proxyUtils.getProxyTypeName('Any'))} diff --git a/src/renderer/features/operations/OperationsConfirm/Restake/ui/Confirmation.tsx b/src/renderer/features/operations/OperationsConfirm/Restake/ui/Confirmation.tsx index 6f3792c0b1..6e43baa728 100644 --- a/src/renderer/features/operations/OperationsConfirm/Restake/ui/Confirmation.tsx +++ b/src/renderer/features/operations/OperationsConfirm/Restake/ui/Confirmation.tsx @@ -75,7 +75,7 @@ export const Confirmation = ({ id = 0, onGoBack, secondaryActionButton, hideSign signatory={confirmStore.signatory} proxied={confirmStore.proxiedAccount} > - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( - {accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( + {confirmStore.shards?.[0] && accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( { chainId: '0x01' as HexString, accountId: '0x02' as AccountId, proxiedAccountId: '0x01' as AccountId, - proxyType: ProxyType.GOVERNANCE, + proxyType: 'Governance', delay: 0, } as ProxyAccount; diff --git a/src/renderer/features/proxies/model/proxies-model.ts b/src/renderer/features/proxies/model/proxies-model.ts index f81315e788..c84c794ce3 100644 --- a/src/renderer/features/proxies/model/proxies-model.ts +++ b/src/renderer/features/proxies/model/proxies-model.ts @@ -51,7 +51,7 @@ const $endpoint = createStore | null>(null); const $deposits = createStore([]); const startWorkerFx = createEffect(() => { - const worker = new Worker(new URL('@/features/proxies/workers/proxy-worker', import.meta.url)); + const worker = new Worker(new URL('../workers/proxy-worker', import.meta.url)); return createEndpoint(worker, { callable: ['initConnection', 'getProxies', 'disconnect'], @@ -285,22 +285,21 @@ sample({ groups: proxyModel.$proxyGroups, deposits: $deposits, }, - filter: ({ deposits }) => Boolean(deposits), + filter: ({ deposits }) => deposits.length > 0, fn: ({ groups, deposits }, wallets) => { - return deposits.reduce( - (acc, deposit) => { - const { toAdd, toUpdate } = proxyUtils.createProxyGroups(wallets, groups, deposit); - - return { - toAdd: acc.toAdd.concat(toAdd), - toUpdate: acc.toUpdate.concat(toUpdate), - }; - }, - { - toAdd: [] as NoID[], - toUpdate: [] as NoID[], - }, - ); + const initial: { toAdd: NoID[]; toUpdate: NoID[] } = { + toAdd: [], + toUpdate: [], + }; + + return deposits.reduce((acc, deposit) => { + const { toAdd, toUpdate } = proxyUtils.createProxyGroups(wallets, groups, deposit); + + return { + toAdd: acc.toAdd.concat(toAdd), + toUpdate: acc.toUpdate.concat(toUpdate), + }; + }, initial); }, target: spread({ toAdd: proxyModel.events.proxyGroupsAdded, diff --git a/src/renderer/features/proxies/workers/__tests__/proxy-worker.test.ts b/src/renderer/features/proxies/workers/__tests__/proxy-worker.test.ts index 6e6c6ae25f..ccc37a1c92 100644 --- a/src/renderer/features/proxies/workers/__tests__/proxy-worker.test.ts +++ b/src/renderer/features/proxies/workers/__tests__/proxy-worker.test.ts @@ -112,76 +112,6 @@ describe('features/proxies/workers/proxy-worker', () => { }); }); - test('should return array with account and deposit object ', async () => { - const newProxy = { - accountId: '0x02', - chainId: '0x01', - delay: 0, - proxiedAccountId: '0x01', - proxyType: 'Governance', - }; - - set(state.apis, '0x01.query.proxy.proxies.entries', () => [ - [ - { - args: [ - { - toHex: () => newProxy.proxiedAccountId, - }, - ], - }, - { - toHuman: () => [ - [ - { - delegate: newProxy.accountId, - proxyType: newProxy.proxyType, - delay: newProxy.delay, - }, - ], - '1,002,050,000,000', - ], - }, - ], - ]); - - const chainId = '0x01'; - const accountsForProxy = { - '0x01': { - id: 1, - walletId: 1, - name: 'Account 1', - type: AccountType.BASE, - accountId: '0x01', - chainType: ChainType.SUBSTRATE, - cryptoType: CryptoType.SR25519, - } as BaseAccount, - }; - const accountsForProxied = {}; - - const proxiedAccounts = [] as ProxiedAccount[]; - const proxies = [] as ProxyAccount[]; - - const result = await proxyWorker.getProxies({ - chainId, - accountsForProxy, - accountsForProxied, - proxiedAccounts, - proxies, - }); - - expect(result.proxiesToAdd).toEqual([newProxy]); - expect(result.proxiesToRemove).toEqual([]); - expect(result.proxiedAccountsToAdd).toEqual([]); - expect(result.proxiedAccountsToRemove).toEqual([]); - expect(result.deposits).toEqual({ - chainId: '0x01', - deposits: { - '0x01': '1,002,050,000,000', - }, - }); - }); - test('should return array with account to remove ', async () => { const mockProxy = { id: 1, @@ -312,82 +242,4 @@ describe('features/proxies/workers/proxy-worker', () => { deposits: {}, }); }); - - test('should return array with proxied account to add ', async () => { - const mockProxied = { - accountId: '0x02', - proxiedAccountId: '0x02', - proxyAccountId: '0x01', - chainId: '0x01', - delay: 0, - proxyType: 'Governance', - proxyVariant: ProxyVariant.NONE, - }; - - set(state.apis, '0x01.query.proxy.proxies.entries', () => [ - [ - { - args: [ - { - toHex: () => '0x02', - }, - ], - }, - { - toHuman: () => [ - [ - { - delegate: '0x01', - proxyType: 'Governance', - delay: 0, - }, - ], - '1,002,050,000,000', - ], - }, - ], - ]); - - const chainId = '0x01'; - const accountsForProxy = {}; - const accountsForProxied = { - '0x01': { - id: 1, - walletId: 1, - name: 'Account 1', - type: AccountType.BASE, - accountId: '0x01', - chainType: ChainType.SUBSTRATE, - cryptoType: CryptoType.SR25519, - } as BaseAccount, - }; - - const proxiedAccounts = [] as ProxiedAccount[]; - const proxies = [] as ProxyAccount[]; - - const result = await proxyWorker.getProxies({ - chainId, - accountsForProxy, - accountsForProxied, - proxiedAccounts, - proxies, - }); - - expect(result.proxiesToAdd).toEqual([ - { - accountId: '0x01', - chainId: '0x01', - delay: 0, - proxiedAccountId: '0x02', - proxyType: 'Governance', - }, - ]); - expect(result.proxiesToRemove).toEqual([]); - expect(result.proxiedAccountsToAdd).toEqual([mockProxied]); - expect(result.proxiedAccountsToRemove).toEqual([]); - expect(result.deposits).toEqual({ - chainId: '0x01', - deposits: { '0x02': '1,002,050,000,000' }, - }); - }); }); diff --git a/src/renderer/features/proxies/workers/proxy-worker.ts b/src/renderer/features/proxies/workers/proxy-worker.ts index 5dff40ed5c..765ebea8ff 100644 --- a/src/renderer/features/proxies/workers/proxy-worker.ts +++ b/src/renderer/features/proxies/workers/proxy-worker.ts @@ -16,8 +16,10 @@ import { type ProxiedAccount, type ProxyAccount, type ProxyDeposits, + type ProxyType, ProxyVariant, } from '@/shared/core'; +import { proxyPallet } from '@/shared/pallet/proxy'; import { proxyWorkerUtils } from '../lib/worker-utils'; export const proxyWorker = { @@ -26,8 +28,8 @@ export const proxyWorker = { disconnect, }; -export const state = { - apis: {} as Record, +export const state: { apis: Record } = { + apis: {}, }; const InitConnectionsResult = { @@ -110,53 +112,50 @@ async function getProxies({ }: GetProxiesParams) { const api = state.apis[chainId]; - const existingProxies = [] as NoID[]; - const proxiesToAdd = [] as NoID[]; + const existingProxies: NoID[] = []; + const proxiesToAdd: NoID[] = []; - const existingProxiedAccounts = [] as PartialProxiedAccount[]; - const proxiedAccountsToAdd = [] as PartialProxiedAccount[]; + const existingProxiedAccounts: PartialProxiedAccount[] = []; + const proxiedAccountsToAdd: PartialProxiedAccount[] = []; - const deposits = { + const deposits: ProxyDeposits = { chainId: chainId, deposits: {}, - } as ProxyDeposits; + }; if (!api || !api.query.proxy) { return { proxiesToAdd, proxiesToRemove: [], proxiedAccountsToAdd, proxiedAccountsToRemove: [], deposits }; } try { - const entries = await api.query.proxy.proxies.entries(); + const entries = await proxyPallet.storage.proxies(api); - for (const [key, value] of entries) { + for (const { account, value } of entries) { try { - const proxyData = value.toHuman() as any; - const proxiedAccountId = key.args[0].toHex(); - - const accounts = proxyData[0]; - if (!accounts) { + if (value.accounts.length === 0) { continue; } - for (const account of accounts) { + for (const delegatedAccount of value.accounts) { const newProxy: NoID = { chainId, - proxiedAccountId, - accountId: proxyWorkerUtils.toAccountId(account?.delegate), - proxyType: account.proxyType, - delay: Number(account.delay), + proxiedAccountId: account, + accountId: delegatedAccount.delegate, + // TODO support all proxy types + proxyType: delegatedAccount.proxyType as ProxyType, + delay: delegatedAccount.delay, }; const needToAddProxiedAccount = accountsForProxied[newProxy.accountId] && !proxyWorkerUtils.isDelayedProxy(newProxy); if (needToAddProxiedAccount) { - const proxiedAccount = { + const proxiedAccount: PartialProxiedAccount = { ...newProxy, proxyAccountId: newProxy.accountId, accountId: newProxy.proxiedAccountId, proxyVariant: ProxyVariant.NONE, - } as PartialProxiedAccount; + }; const doesProxiedAccountExist = proxiedAccounts.some((oldProxy) => proxyWorkerUtils.isSameProxied(oldProxy, proxiedAccount), @@ -170,21 +169,22 @@ async function getProxies({ } if (needToAddProxiedAccount) { - deposits.deposits[proxiedAccountId] = proxyData[1]; + deposits.deposits[account] = value.deposit.toString(); } } - for (const account of accounts) { + for (const delegatedAccount of value.accounts) { const newProxy: NoID = { chainId, - proxiedAccountId, - accountId: proxyWorkerUtils.toAccountId(account?.delegate), - proxyType: account.proxyType, - delay: Number(account.delay), + proxiedAccountId: account, + accountId: delegatedAccount.delegate, + // TODO support all proxy types + proxyType: delegatedAccount.proxyType as ProxyType, + delay: delegatedAccount.delay, }; const needToAddProxyAccount = - accountsForProxy[proxiedAccountId] || proxiedAccountsToAdd.some((p) => p.accountId === proxiedAccountId); + accountsForProxy[account] || proxiedAccountsToAdd.some((p) => p.accountId === account); const doesProxyExist = proxies.some((oldProxy) => proxyWorkerUtils.isSameProxy(oldProxy, newProxy)); if (needToAddProxyAccount) { @@ -196,7 +196,7 @@ async function getProxies({ } if (needToAddProxyAccount) { - deposits.deposits[proxiedAccountId] = proxyData[1]; + deposits.deposits[account] = value.deposit.toString(); } } } catch (e) { diff --git a/src/renderer/features/proxy-add-pure/index.ts b/src/renderer/features/proxy-add-pure/index.ts new file mode 100644 index 0000000000..74655391ea --- /dev/null +++ b/src/renderer/features/proxy-add-pure/index.ts @@ -0,0 +1,11 @@ +import { addPureProxiedModel } from './model/add-pure-proxied-model'; +import { AddPureProxied } from './ui/AddPureProxied'; + +export const proxyAddPureFeature = { + views: { + AddPureProxied, + }, + models: { + addPureProxied: addPureProxiedModel, + }, +}; diff --git a/src/renderer/widgets/AddPureProxiedModal/lib/add-pure-proxied-utils.ts b/src/renderer/features/proxy-add-pure/lib/add-pure-proxied-utils.ts similarity index 100% rename from src/renderer/widgets/AddPureProxiedModal/lib/add-pure-proxied-utils.ts rename to src/renderer/features/proxy-add-pure/lib/add-pure-proxied-utils.ts diff --git a/src/renderer/widgets/AddPureProxiedModal/lib/types.ts b/src/renderer/features/proxy-add-pure/lib/types.ts similarity index 100% rename from src/renderer/widgets/AddPureProxiedModal/lib/types.ts rename to src/renderer/features/proxy-add-pure/lib/types.ts diff --git a/src/renderer/widgets/AddPureProxiedModal/model/__tests__/add-pure-proxied-model.test.ts b/src/renderer/features/proxy-add-pure/model/__tests__/add-pure-proxied-model.test.ts similarity index 100% rename from src/renderer/widgets/AddPureProxiedModal/model/__tests__/add-pure-proxied-model.test.ts rename to src/renderer/features/proxy-add-pure/model/__tests__/add-pure-proxied-model.test.ts diff --git a/src/renderer/widgets/AddPureProxiedModal/model/__tests__/form-model.test.ts b/src/renderer/features/proxy-add-pure/model/__tests__/form-model.test.ts similarity index 100% rename from src/renderer/widgets/AddPureProxiedModal/model/__tests__/form-model.test.ts rename to src/renderer/features/proxy-add-pure/model/__tests__/form-model.test.ts diff --git a/src/renderer/widgets/AddPureProxiedModal/model/__tests__/mock.ts b/src/renderer/features/proxy-add-pure/model/__tests__/mock.ts similarity index 100% rename from src/renderer/widgets/AddPureProxiedModal/model/__tests__/mock.ts rename to src/renderer/features/proxy-add-pure/model/__tests__/mock.ts diff --git a/src/renderer/widgets/AddPureProxiedModal/model/add-pure-proxied-model.ts b/src/renderer/features/proxy-add-pure/model/add-pure-proxied-model.ts similarity index 97% rename from src/renderer/widgets/AddPureProxiedModal/model/add-pure-proxied-model.ts rename to src/renderer/features/proxy-add-pure/model/add-pure-proxied-model.ts index abf9ac6b0c..df715ece97 100644 --- a/src/renderer/widgets/AddPureProxiedModal/model/add-pure-proxied-model.ts +++ b/src/renderer/features/proxy-add-pure/model/add-pure-proxied-model.ts @@ -10,7 +10,6 @@ import { type NoID, type PartialProxiedAccount, type ProxyGroup, - ProxyType, ProxyVariant, type Timepoint, type Transaction, @@ -24,13 +23,12 @@ import { networkModel } from '@/entities/network'; import { proxyModel, proxyUtils } from '@/entities/proxy'; import { type ExtrinsicResultParams, transactionService } from '@/entities/transaction'; import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; -import { balanceSubModel } from '@/features/balances'; +import { balanceSubModel } from '@/features/assets-balances'; import { navigationModel } from '@/features/navigation'; import { signModel } from '@/features/operations/OperationSign/model/sign-model'; import { submitModel, submitUtils } from '@/features/operations/OperationSubmit'; -import { addPureProxiedConfirmModel as confirmModel } from '@/features/operations/OperationsConfirm'; +import { addPureProxiedConfirmModel as confirmModel } from '@/features/operations/OperationsConfirm/AddPureProxied'; import { proxiesModel } from '@/features/proxies'; -import { walletSelectModel } from '@/features/wallets'; import { addPureProxiedUtils } from '../lib/add-pure-proxied-utils'; import { type AddPureProxiedStore, Step } from '../lib/types'; @@ -138,7 +136,7 @@ sample({ clock: flowStarted, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; @@ -273,7 +271,7 @@ sample({ accountId: addPureProxiedStore.account.accountId, proxiedAccountId: accountId, chainId: addPureProxiedStore.chain.chainId, - proxyType: ProxyType.ANY, + proxyType: 'Any' as const, delay: 0, }, ], @@ -292,7 +290,7 @@ sample({ chainId: chain.chainId, proxyAccountId: account.accountId, delay: 0, - proxyType: ProxyType.ANY, + proxyType: 'Any', proxyVariant: ProxyVariant.PURE, blockNumber, extrinsicIndex, @@ -388,7 +386,7 @@ sample({ clock: flowFinished, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; diff --git a/src/renderer/widgets/AddPureProxiedModal/model/form-model.ts b/src/renderer/features/proxy-add-pure/model/form-model.ts similarity index 95% rename from src/renderer/widgets/AddPureProxiedModal/model/form-model.ts rename to src/renderer/features/proxy-add-pure/model/form-model.ts index b0916a57b8..97e792dcba 100644 --- a/src/renderer/widgets/AddPureProxiedModal/model/form-model.ts +++ b/src/renderer/features/proxy-add-pure/model/form-model.ts @@ -1,6 +1,7 @@ import { BN } from '@polkadot/util'; import { combine, createEvent, createStore, restore, sample } from 'effector'; import { createForm } from 'effector-forms'; +import { createGate } from 'effector-react'; import { spread } from 'patronum'; import { @@ -9,9 +10,9 @@ import { type MultisigTxWrapper, type ProxiedAccount, type ProxyTxWrapper, - ProxyType, type Transaction, TransactionType, + type Wallet, } from '@/shared/core'; import { TEST_ACCOUNTS, @@ -30,7 +31,6 @@ import { operationsModel, operationsUtils } from '@/entities/operations'; import { transactionService } from '@/entities/transaction'; import { accountUtils, permissionUtils, walletModel, walletUtils } from '@/entities/wallet'; import { proxiesUtils } from '@/features/proxies'; -import { walletSelectModel } from '@/features/wallets'; type FormParams = { chain: Chain; @@ -53,6 +53,8 @@ type FormSubmitEvent = { }; }; +const flow = createGate<{ wallet: Wallet | null }>({ defaultState: { wallet: null } }); + const formInitiated = createEvent(); const formSubmitted = createEvent(); const proxyQueryChanged = createEvent(); @@ -63,6 +65,8 @@ const feeChanged = createEvent(); const isFeeLoadingChanged = createEvent(); const isProxyDepositLoadingChanged = createEvent(); +const $wallet = flow.state.map(({ wallet }) => wallet); + const $oldProxyDeposit = createStore(ZERO_BALANCE); const $fee = restore(feeChanged, ZERO_BALANCE); @@ -143,7 +147,7 @@ const $proxyForm = createForm({ const $txWrappers = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, account: $proxyForm.fields.account.$value, chain: $proxyForm.fields.chain.$value, @@ -204,7 +208,7 @@ const $proxyWallet = combine( const $proxyChains = combine( { chains: networkModel.$chains, - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, }, ({ chains, wallet }) => { if (!wallet) return []; @@ -224,7 +228,7 @@ const $proxyChains = combine( const $proxiedAccounts = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, chain: $proxyForm.fields.chain.$value, balances: balanceModel.$balances, }, @@ -253,7 +257,7 @@ const $proxiedAccounts = combine( const $signatories = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, account: $proxyForm.fields.account.$value, chain: $proxyForm.fields.chain.$value, @@ -319,9 +323,7 @@ const $proxyTypes = combine( ({ apis, statuses, chain }) => { if (!chain.chainId) return []; - return networkUtils.isConnectedStatus(statuses[chain.chainId]) - ? getProxyTypes(apis[chain.chainId]) - : [ProxyType.ANY]; + return networkUtils.isConnectedStatus(statuses[chain.chainId]) ? getProxyTypes(apis[chain.chainId]) : ['Any']; }, ); @@ -364,7 +366,7 @@ const $pureTx = combine( chainId: form.chain.chainId, address: toAddress(account.accountId, { prefix: form.chain.addressPrefix }), type: TransactionType.CREATE_PURE_PROXY, - args: { proxyType: ProxyType.ANY, delay: 0, index: 0 }, + args: { proxyType: 'Any', delay: 0, index: 0 }, }; }, { skipVoid: false }, @@ -402,7 +404,7 @@ const $fakeTx = combine( chainId: chain.chainId, address: toAddress(TEST_ACCOUNTS[0], { prefix: chain.addressPrefix }), type: TransactionType.CREATE_PURE_PROXY, - args: { proxyType: ProxyType.ANY, delay: 0, index: 0 }, + args: { proxyType: 'Any', delay: 0, index: 0 }, }; }, { skipVoid: false }, @@ -468,19 +470,19 @@ sample({ sample({ clock: $proxyForm.fields.account.onChange, source: { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, }, filter: (_, account) => Boolean(account), fn: ({ wallet, wallets }, account): Record => { if (!wallet) return { isMultisig: false, isProxy: false }; - if (walletUtils.isMultisig(wallet)) return { isMultisig: true, isProxy: false }; + if (walletUtils.isRegularMultisig(wallet)) return { isMultisig: true, isProxy: false }; if (!walletUtils.isProxied(wallet)) return { isMultisig: false, isProxy: false }; const accountWallet = walletUtils.getWalletById(wallets, account!.walletId); return { - isMultisig: walletUtils.isMultisig(accountWallet), + isMultisig: walletUtils.isRegularMultisig(accountWallet), isProxy: true, }; }, @@ -524,6 +526,7 @@ sample({ }); export const formModel = { + $wallet, $proxyForm, $proxyChains, $proxiedAccounts, @@ -546,6 +549,8 @@ export const formModel = { $canSubmit, $multisigAlreadyExists, + flow, + events: { formInitiated, proxyQueryChanged, diff --git a/src/renderer/widgets/AddPureProxiedModal/ui/AddPureProxied.tsx b/src/renderer/features/proxy-add-pure/ui/AddPureProxied.tsx similarity index 90% rename from src/renderer/widgets/AddPureProxiedModal/ui/AddPureProxied.tsx rename to src/renderer/features/proxy-add-pure/ui/AddPureProxied.tsx index 0933b4fa64..1fa7186359 100644 --- a/src/renderer/widgets/AddPureProxiedModal/ui/AddPureProxied.tsx +++ b/src/renderer/features/proxy-add-pure/ui/AddPureProxied.tsx @@ -1,6 +1,6 @@ -import { useUnit } from 'effector-react'; +import { useGate, useUnit } from 'effector-react'; -import { type Chain } from '@/shared/core'; +import { type Chain, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useModalClose } from '@/shared/lib/hooks'; import { BaseModal, Button } from '@/shared/ui'; @@ -11,10 +11,17 @@ import { AddPureProxiedConfirm, basketUtils } from '@/features/operations/Operat import { addPureProxiedUtils } from '../lib/add-pure-proxied-utils'; import { Step } from '../lib/types'; import { addPureProxiedModel } from '../model/add-pure-proxied-model'; +import { formModel } from '../model/form-model'; import { AddPureProxiedForm } from './AddPureProxiedForm'; -export const AddPureProxied = () => { +type Props = { + wallet: Wallet; +}; + +export const AddPureProxied = ({ wallet }: Props) => { + useGate(formModel.flow, { wallet }); + const { t } = useI18n(); const step = useUnit(addPureProxiedModel.$step); diff --git a/src/renderer/widgets/AddPureProxiedModal/ui/AddPureProxiedForm.tsx b/src/renderer/features/proxy-add-pure/ui/AddPureProxiedForm.tsx similarity index 100% rename from src/renderer/widgets/AddPureProxiedModal/ui/AddPureProxiedForm.tsx rename to src/renderer/features/proxy-add-pure/ui/AddPureProxiedForm.tsx diff --git a/src/renderer/features/proxy-add/index.ts b/src/renderer/features/proxy-add/index.ts new file mode 100644 index 0000000000..574d0689a7 --- /dev/null +++ b/src/renderer/features/proxy-add/index.ts @@ -0,0 +1,11 @@ +import { addProxyModel } from './model/add-proxy-model'; +import { AddProxy } from './ui/AddProxy'; + +export const proxyAddFeature = { + views: { + AddProxy, + }, + models: { + addProxy: addProxyModel, + }, +}; diff --git a/src/renderer/widgets/AddProxyModal/lib/add-proxy-utils.ts b/src/renderer/features/proxy-add/lib/add-proxy-utils.ts similarity index 100% rename from src/renderer/widgets/AddProxyModal/lib/add-proxy-utils.ts rename to src/renderer/features/proxy-add/lib/add-proxy-utils.ts diff --git a/src/renderer/widgets/AddProxyModal/lib/types.ts b/src/renderer/features/proxy-add/lib/types.ts similarity index 100% rename from src/renderer/widgets/AddProxyModal/lib/types.ts rename to src/renderer/features/proxy-add/lib/types.ts diff --git a/src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts b/src/renderer/features/proxy-add/model/__tests__/add-proxy-model.test.ts similarity index 96% rename from src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts rename to src/renderer/features/proxy-add/model/__tests__/add-proxy-model.test.ts index 53f532d348..7c4b35e9b1 100644 --- a/src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts +++ b/src/renderer/features/proxy-add/model/__tests__/add-proxy-model.test.ts @@ -1,7 +1,7 @@ import { allSettled, fork } from 'effector'; import { storageService } from '@/shared/api/storage'; -import { type BaseAccount, ConnectionStatus, ProxyType, type Transaction } from '@/shared/core'; +import { type BaseAccount, ConnectionStatus, type Transaction } from '@/shared/core'; import { networkModel } from '@/entities/network'; import { walletModel } from '@/entities/wallet'; import { signModel } from '@/features/operations/OperationSign/model/sign-model'; @@ -57,7 +57,7 @@ describe('widgets/AddProxyModal/model/add-proxy-model', () => { signatory: null, account: { accountId: '0x00' } as unknown as BaseAccount, delegate: '0x00', - proxyType: ProxyType.ANY, + proxyType: 'Any', proxyDeposit: '1', proxyNumber: 1, fee: '1', diff --git a/src/renderer/widgets/AddProxyModal/model/__tests__/form-model.test.ts b/src/renderer/features/proxy-add/model/__tests__/form-model.test.ts similarity index 100% rename from src/renderer/widgets/AddProxyModal/model/__tests__/form-model.test.ts rename to src/renderer/features/proxy-add/model/__tests__/form-model.test.ts diff --git a/src/renderer/widgets/AddProxyModal/model/__tests__/mock.ts b/src/renderer/features/proxy-add/model/__tests__/mock.ts similarity index 100% rename from src/renderer/widgets/AddProxyModal/model/__tests__/mock.ts rename to src/renderer/features/proxy-add/model/__tests__/mock.ts diff --git a/src/renderer/widgets/AddProxyModal/model/add-proxy-model.ts b/src/renderer/features/proxy-add/model/add-proxy-model.ts similarity index 96% rename from src/renderer/widgets/AddProxyModal/model/add-proxy-model.ts rename to src/renderer/features/proxy-add/model/add-proxy-model.ts index 32a8089145..5722f3018a 100644 --- a/src/renderer/widgets/AddProxyModal/model/add-proxy-model.ts +++ b/src/renderer/features/proxy-add/model/add-proxy-model.ts @@ -6,13 +6,12 @@ import { nonNullable } from '@/shared/lib/utils'; import { type PathType, Paths } from '@/shared/routes'; import { basketModel } from '@/entities/basket'; import { walletModel, walletUtils } from '@/entities/wallet'; -import { balanceSubModel } from '@/features/balances'; +import { balanceSubModel } from '@/features/assets-balances'; import { navigationModel } from '@/features/navigation'; import { signModel } from '@/features/operations/OperationSign/model/sign-model'; import { submitModel, submitUtils } from '@/features/operations/OperationSubmit'; -import { addProxyConfirmModel as confirmModel } from '@/features/operations/OperationsConfirm'; +import { addProxyConfirmModel as confirmModel } from '@/features/operations/OperationsConfirm/AddProxy'; import { proxiesModel } from '@/features/proxies'; -import { walletSelectModel } from '@/features/wallets'; import { type AddProxyStore, Step } from '../lib/types'; import { formModel } from './form-model'; @@ -57,7 +56,7 @@ sample({ clock: flowStarted, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; @@ -160,7 +159,7 @@ sample({ clock: flowFinished, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; diff --git a/src/renderer/widgets/AddProxyModal/model/form-model.ts b/src/renderer/features/proxy-add/model/form-model.ts similarity index 96% rename from src/renderer/widgets/AddProxyModal/model/form-model.ts rename to src/renderer/features/proxy-add/model/form-model.ts index 63eeeac11f..7f1a10f797 100644 --- a/src/renderer/widgets/AddProxyModal/model/form-model.ts +++ b/src/renderer/features/proxy-add/model/form-model.ts @@ -2,6 +2,7 @@ import { type ApiPromise } from '@polkadot/api'; import { BN } from '@polkadot/util'; import { combine, createEffect, createEvent, createStore, restore, sample } from 'effector'; import { createForm } from 'effector-forms'; +import { createGate } from 'effector-react'; import { spread } from 'patronum'; import { proxyService } from '@/shared/api/proxy'; @@ -13,9 +14,10 @@ import { type MultisigTxWrapper, type ProxiedAccount, type ProxyTxWrapper, - ProxyType, + type ProxyType, type Transaction, TransactionType, + type Wallet, } from '@/shared/core'; import { TEST_ACCOUNTS, @@ -35,7 +37,6 @@ import { operationsModel, operationsUtils } from '@/entities/operations'; import { transactionService } from '@/entities/transaction'; import { accountUtils, permissionUtils, walletModel, walletUtils } from '@/entities/wallet'; import { proxiesUtils } from '@/features/proxies'; -import { walletSelectModel } from '@/features/wallets'; type ProxyAccounts = { accounts: { @@ -69,6 +70,8 @@ type FormSubmitEvent = { }; }; +const flow = createGate<{ wallet: Wallet | null }>({ defaultState: { wallet: null } }); + const formInitiated = createEvent(); const formSubmitted = createEvent(); const proxyQueryChanged = createEvent(); @@ -79,6 +82,8 @@ const feeChanged = createEvent(); const isFeeLoadingChanged = createEvent(); const isProxyDepositLoadingChanged = createEvent(); +const $wallet = flow.state.map(({ wallet }) => wallet); + const $oldProxyDeposit = createStore('0'); const $fee = restore(feeChanged, ZERO_BALANCE); @@ -204,7 +209,7 @@ const $proxyForm = createForm({ const $txWrappers = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, account: $proxyForm.fields.account.$value, chain: $proxyForm.fields.chain.$value, @@ -265,7 +270,7 @@ const $proxyWallet = combine( const $proxyChains = combine( { chains: networkModel.$chains, - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, }, ({ chains, wallet }) => { if (!wallet) return []; @@ -285,7 +290,7 @@ const $proxyChains = combine( const $proxiedAccounts = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, chain: $proxyForm.fields.chain.$value, balances: balanceModel.$balances, }, @@ -314,7 +319,7 @@ const $proxiedAccounts = combine( const $signatories = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, account: $proxyForm.fields.account.$value, chain: $proxyForm.fields.chain.$value, @@ -379,10 +384,11 @@ const $proxyTypes = combine( }, ({ apis, statuses, chain }) => { if (!chain.chainId) return []; + if (networkUtils.isConnectedStatus(statuses[chain.chainId])) { + return getProxyTypes(apis[chain.chainId]); + } - return networkUtils.isConnectedStatus(statuses[chain.chainId]) - ? getProxyTypes(apis[chain.chainId]) - : [ProxyType.ANY]; + return ['Any'] as const; }, ); @@ -469,7 +475,7 @@ const $fakeTx = combine( type: TransactionType.ADD_PROXY, args: { delegate: toAddress(TEST_ACCOUNTS[0], { prefix: chain.addressPrefix }), - proxyType: ProxyType.ANY, + proxyType: 'Any', delay: 0, }, }; @@ -555,19 +561,19 @@ sample({ sample({ clock: $proxyForm.fields.account.onChange, source: { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, }, filter: (_, account) => Boolean(account), fn: ({ wallet, wallets }, account): Record => { if (!wallet) return { isMultisig: false, isProxy: false }; - if (walletUtils.isMultisig(wallet)) return { isMultisig: true, isProxy: false }; + if (walletUtils.isRegularMultisig(wallet)) return { isMultisig: true, isProxy: false }; if (!walletUtils.isProxied(wallet)) return { isMultisig: false, isProxy: false }; const accountWallet = walletUtils.getWalletById(wallets, account!.walletId); return { - isMultisig: walletUtils.isMultisig(accountWallet), + isMultisig: walletUtils.isRegularMultisig(accountWallet), isProxy: true, }; }, @@ -678,6 +684,7 @@ sample({ }); export const formModel = { + $wallet, $proxyForm, $proxyChains, $proxiedAccounts, @@ -701,6 +708,8 @@ export const formModel = { $canSubmit, $multisigAlreadyExists, + flow, + events: { formInitiated, proxyQueryChanged, diff --git a/src/renderer/widgets/AddProxyModal/ui/AddProxy.tsx b/src/renderer/features/proxy-add/ui/AddProxy.tsx similarity index 90% rename from src/renderer/widgets/AddProxyModal/ui/AddProxy.tsx rename to src/renderer/features/proxy-add/ui/AddProxy.tsx index 240fe733af..c95283444c 100644 --- a/src/renderer/widgets/AddProxyModal/ui/AddProxy.tsx +++ b/src/renderer/features/proxy-add/ui/AddProxy.tsx @@ -1,6 +1,6 @@ -import { useUnit } from 'effector-react'; +import { useGate, useUnit } from 'effector-react'; -import { type Chain } from '@/shared/core'; +import { type Chain, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useModalClose } from '@/shared/lib/hooks'; import { BaseModal, Button } from '@/shared/ui'; @@ -11,10 +11,17 @@ import { AddProxyConfirm, basketUtils } from '@/features/operations/OperationsCo import { addProxyUtils } from '../lib/add-proxy-utils'; import { Step } from '../lib/types'; import { addProxyModel } from '../model/add-proxy-model'; +import { formModel } from '../model/form-model'; import { AddProxyForm } from './AddProxyForm'; -export const AddProxy = () => { +type Props = { + wallet: Wallet | null; +}; + +export const AddProxy = ({ wallet }: Props) => { + useGate(formModel.flow, { wallet }); + const { t } = useI18n(); const step = useUnit(addProxyModel.$step); diff --git a/src/renderer/widgets/AddProxyModal/ui/AddProxyForm.tsx b/src/renderer/features/proxy-add/ui/AddProxyForm.tsx similarity index 97% rename from src/renderer/widgets/AddProxyModal/ui/AddProxyForm.tsx rename to src/renderer/features/proxy-add/ui/AddProxyForm.tsx index f274970352..a69646dc28 100644 --- a/src/renderer/widgets/AddProxyModal/ui/AddProxyForm.tsx +++ b/src/renderer/features/proxy-add/ui/AddProxyForm.tsx @@ -6,6 +6,7 @@ import { type MultisigAccount } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { toAddress, toShortAddress, validateAddress } from '@/shared/lib/utils'; import { Alert, Button, Combobox, Icon, Identicon, InputHint, Select } from '@/shared/ui'; +import { Field } from '@/shared/ui-kit'; import { AssetBalance } from '@/entities/asset'; import { ChainTitle } from '@/entities/chain'; import { SignatorySelector } from '@/entities/operations'; @@ -195,17 +196,16 @@ const ProxyInput = () => { const prefixElement = (
    {validateAddress(delegate.value) ? ( - + ) : ( - + )}
    ); return ( -
    + { {t(delegate.errorText())} -
    + ); }; diff --git a/src/renderer/features/proxy-remove-pure/index.ts b/src/renderer/features/proxy-remove-pure/index.ts new file mode 100644 index 0000000000..9e17ec0660 --- /dev/null +++ b/src/renderer/features/proxy-remove-pure/index.ts @@ -0,0 +1,7 @@ +import { removePureProxyModel } from './model/remove-pure-proxy-model'; +import { RemovePureProxy } from './ui/RemovePureProxy'; + +export const proxyRemovePureFeature = { + views: { RemovePureProxy }, + models: { removePureProxy: removePureProxyModel }, +}; diff --git a/src/renderer/widgets/RemovePureProxyModal/lib/remove-pure-proxy-utils.ts b/src/renderer/features/proxy-remove-pure/lib/remove-pure-proxy-utils.ts similarity index 100% rename from src/renderer/widgets/RemovePureProxyModal/lib/remove-pure-proxy-utils.ts rename to src/renderer/features/proxy-remove-pure/lib/remove-pure-proxy-utils.ts diff --git a/src/renderer/widgets/RemovePureProxyModal/lib/types.ts b/src/renderer/features/proxy-remove-pure/lib/types.ts similarity index 100% rename from src/renderer/widgets/RemovePureProxyModal/lib/types.ts rename to src/renderer/features/proxy-remove-pure/lib/types.ts diff --git a/src/renderer/widgets/RemovePureProxyModal/model/form-model.ts b/src/renderer/features/proxy-remove-pure/model/form-model.ts similarity index 94% rename from src/renderer/widgets/RemovePureProxyModal/model/form-model.ts rename to src/renderer/features/proxy-remove-pure/model/form-model.ts index 71babf4fa6..5a1f7c6bb7 100644 --- a/src/renderer/widgets/RemovePureProxyModal/model/form-model.ts +++ b/src/renderer/features/proxy-remove-pure/model/form-model.ts @@ -2,6 +2,7 @@ import { type ApiPromise } from '@polkadot/api'; import { BN } from '@polkadot/util'; import { combine, createEffect, createEvent, createStore, restore, sample } from 'effector'; import { createForm } from 'effector-forms'; +import { createGate } from 'effector-react'; import { spread } from 'patronum'; import { proxyService } from '@/shared/api/proxy'; @@ -10,9 +11,10 @@ import { type Address, type Chain, type ProxiedAccount, - ProxyType, + type ProxyType, type Transaction, TransactionType, + type Wallet, } from '@/shared/core'; import { TEST_ACCOUNTS, @@ -27,7 +29,6 @@ import { balanceModel, balanceUtils } from '@/entities/balance'; import { networkModel, networkUtils } from '@/entities/network'; import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; import { proxiesUtils } from '@/features/proxies'; -import { walletSelectModel } from '@/features/wallets'; type ProxyAccounts = { accounts: { @@ -51,6 +52,8 @@ type Input = { }[]; }; +const flow = createGate<{ wallet: Wallet | null }>({ defaultState: { wallet: null } }); + const formInitiated = createEvent(); const formSubmitted = createEvent(); const proxyQueryChanged = createEvent(); @@ -61,6 +64,8 @@ const feeChanged = createEvent(); const isFeeLoadingChanged = createEvent(); const isProxyDepositLoadingChanged = createEvent(); +const $wallet = flow.state.map(({ wallet }) => wallet); + const $formStore = restore(formInitiated, null); const $multisigDeposit = restore(multisigDepositChanged, '0'); @@ -119,7 +124,7 @@ const $proxyChains = combine(networkModel.$chains, (chains) => { const $proxiedAccounts = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, chain: $chain, balances: balanceModel.$balances, }, @@ -178,9 +183,7 @@ const $proxyTypes = combine( ({ apis, statuses, chain }) => { if (!chain) return []; - return networkUtils.isConnectedStatus(statuses[chain.chainId]) - ? getProxyTypes(apis[chain.chainId]) - : [ProxyType.ANY]; + return networkUtils.isConnectedStatus(statuses[chain.chainId]) ? getProxyTypes(apis[chain.chainId]) : ['Any']; }, ); @@ -224,7 +227,7 @@ const $fakeTx = combine( type: TransactionType.REMOVE_PURE_PROXY, args: { spawner: toAddress(TEST_ACCOUNTS[0], { prefix: chain.addressPrefix }), - proxyType: ProxyType.ANY, + proxyType: 'Any', index: 0, blockNumber: 1, extrinsicIndex: 1, @@ -262,19 +265,19 @@ sample({ sample({ clock: $realAccount, source: { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, }, filter: (_, account) => Boolean(account), fn: ({ wallet, wallets }, account): Record => { if (!wallet) return { isMultisig: false, isProxy: false }; - if (walletUtils.isMultisig(wallet)) return { isMultisig: true, isProxy: false }; + if (walletUtils.isRegularMultisig(wallet)) return { isMultisig: true, isProxy: false }; if (!walletUtils.isProxied(wallet)) return { isMultisig: false, isProxy: false }; const accountWallet = walletUtils.getWalletById(wallets, account!.walletId); return { - isMultisig: walletUtils.isMultisig(accountWallet), + isMultisig: walletUtils.isRegularMultisig(accountWallet), isProxy: true, }; }, @@ -339,6 +342,7 @@ sample({ }); export const formModel = { + $wallet, $proxyForm, $proxyChains, $proxiedAccounts, @@ -357,6 +361,8 @@ export const formModel = { $isChainConnected, $canSubmit, + flow, + events: { formInitiated, proxyQueryChanged, diff --git a/src/renderer/widgets/RemovePureProxyModal/model/remove-pure-proxy-model.ts b/src/renderer/features/proxy-remove-pure/model/remove-pure-proxy-model.ts similarity index 91% rename from src/renderer/widgets/RemovePureProxyModal/model/remove-pure-proxy-model.ts rename to src/renderer/features/proxy-remove-pure/model/remove-pure-proxy-model.ts index 452310fd28..c8cc019ec5 100644 --- a/src/renderer/widgets/RemovePureProxyModal/model/remove-pure-proxy-model.ts +++ b/src/renderer/features/proxy-remove-pure/model/remove-pure-proxy-model.ts @@ -4,33 +4,30 @@ import { spread } from 'patronum'; import { type Account, type BasketTransaction, - type Chain, + type ChainId, type MultisigTxWrapper, type ProxiedAccount, type ProxyAccount, type ProxyTxWrapper, - ProxyType, ProxyVariant, type Transaction, TransactionType, type TxWrapper, WrapperKind, } from '@/shared/core'; -import { nonNullable, toAddress, withdrawableAmount } from '@/shared/lib/utils'; +import { nonNullable, nullable, toAddress, withdrawableAmount } from '@/shared/lib/utils'; import { type PathType, Paths } from '@/shared/routes'; import { balanceModel, balanceUtils } from '@/entities/balance'; import { basketModel } from '@/entities/basket'; import { networkModel } from '@/entities/network'; -import { proxyModel } from '@/entities/proxy'; +import { proxyModel, proxyUtils } from '@/entities/proxy'; import { transactionService } from '@/entities/transaction'; import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; -import { balanceSubModel } from '@/features/balances'; +import { balanceSubModel } from '@/features/assets-balances'; import { navigationModel } from '@/features/navigation'; import { signModel } from '@/features/operations/OperationSign/model/sign-model'; import { submitModel, submitUtils } from '@/features/operations/OperationSubmit'; import { removePureProxiedConfirmModel as confirmModel } from '@/features/operations/OperationsConfirm'; -import { walletSelectModel } from '@/features/wallets'; -import { walletProviderModel } from '@/widgets/WalletDetails'; import { removePureProxyUtils } from '../lib/remove-pure-proxy-utils'; import { type RemoveProxyStore, Step } from '../lib/types'; @@ -63,12 +60,25 @@ const $isProxy = createStore(false); const $isMultisig = createStore(false); const $selectedSignatories = createStore([]); -const $chain = $removeProxyStore.map((store) => store?.chain, { skipVoid: false }); -const $account = $removeProxyStore.map((store) => store?.account, { skipVoid: false }); +const $chain = $removeProxyStore.map((store) => store?.chain ?? null); +const $account = $removeProxyStore.map((store) => store?.account ?? null); + +const $chainProxies = combine( + { + wallet: formModel.$wallet, + chains: networkModel.$chains, + proxies: proxyModel.$proxies, + }, + ({ wallet, chains, proxies }): Record => { + if (nullable(wallet)) return {}; + + return proxyUtils.getProxyAccountsOnChain(wallet.accounts, Object.keys(chains) as ChainId[], proxies); + }, +); const $txWrappers = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: formModel.$wallet, wallets: walletModel.$wallets, account: $account, chain: $chain, @@ -105,12 +115,11 @@ const $realAccount = combine( if (txWrappers.length === 0) return account; if (transactionService.hasMultisig([txWrappers[0]])) { - return (txWrappers[0] as MultisigTxWrapper).multisigAccount; + return (txWrappers[0] as MultisigTxWrapper)?.multisigAccount ?? null; } - return (txWrappers[0] as ProxyTxWrapper).proxyAccount; + return (txWrappers[0] as ProxyTxWrapper)?.proxyAccount ?? null; }, - { skipVoid: false }, ); const $signatories = combine( @@ -147,11 +156,10 @@ const $initiatorWallet = combine( wallets: walletModel.$wallets, }, ({ store, wallets }) => { - if (!store) return undefined; + if (!store) return null; - return walletUtils.getWalletById(wallets, store.account.walletId); + return walletUtils.getWalletById(wallets, store.account.walletId) ?? null; }, - { skipVoid: false }, ); sample({ @@ -178,7 +186,7 @@ sample({ const $shouldRemovePureProxy = combine( { - proxies: walletProviderModel.$chainsProxies, + proxies: $chainProxies, account: $account, chain: $chain, }, @@ -186,7 +194,7 @@ const $shouldRemovePureProxy = combine( if (!chain || !account) return true; const chainProxies = proxies[chain.chainId] || []; - const anyProxies = chainProxies.filter((proxy) => proxy.proxyType === ProxyType.ANY); + const anyProxies = chainProxies.filter((proxy) => proxy.proxyType === 'Any'); const isPureProxy = (account as ProxiedAccount).proxyVariant === ProxyVariant.PURE; return isPureProxy && anyProxies.length === 1; @@ -244,7 +252,7 @@ sample({ clock: flowStarted, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; @@ -280,10 +288,10 @@ sample({ return Boolean(account) && Boolean(realAccount) && Boolean(chain); }, fn: ({ realAccount, signatories, account, chain }) => ({ - account: realAccount, + account: realAccount ?? undefined, proxiedAccount: account as ProxiedAccount, signatories: signatories[0] || [], - chain, + chain: chain ?? undefined, }), target: formModel.events.formInitiated, }); @@ -361,12 +369,12 @@ sample({ event: [ { ...formData, - chain: chain as Chain, - account: realAccount, + chain: chain ?? undefined, + account: realAccount ?? undefined, proxiedAccount: account as ProxiedAccount, transaction: wrappedTx as Transaction, spawner: (account as ProxiedAccount).proxyAccountId, - proxyType: ProxyType.ANY, + proxyType: 'Any' as const, coreTx, }, ], @@ -444,7 +452,7 @@ sample({ step: $step, chain: $chain, account: $account, - chainProxies: walletProviderModel.$chainsProxies, + chainProxies: $chainProxies, }, filter: ({ step, chain, account }) => { return removePureProxyUtils.isSubmitStep(step) && Boolean(chain) && Boolean(account); @@ -466,8 +474,8 @@ sample({ clock: submitModel.output.formSubmitted, source: { step: $step, - wallet: walletSelectModel.$walletForDetails, - chainProxies: walletProviderModel.$chainsProxies, + wallet: formModel.$wallet, + chainProxies: $chainProxies, removeProxyStore: $removeProxyStore, }, filter: ({ step, chainProxies, wallet, removeProxyStore }) => { @@ -513,7 +521,7 @@ sample({ clock: flowFinished, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; diff --git a/src/renderer/widgets/RemovePureProxyModal/model/warning-model.ts b/src/renderer/features/proxy-remove-pure/model/warning-model.ts similarity index 100% rename from src/renderer/widgets/RemovePureProxyModal/model/warning-model.ts rename to src/renderer/features/proxy-remove-pure/model/warning-model.ts diff --git a/src/renderer/widgets/RemovePureProxyModal/ui/RemovePureProxy.tsx b/src/renderer/features/proxy-remove-pure/ui/RemovePureProxy.tsx similarity index 89% rename from src/renderer/widgets/RemovePureProxyModal/ui/RemovePureProxy.tsx rename to src/renderer/features/proxy-remove-pure/ui/RemovePureProxy.tsx index d14209dc17..66cdfafb69 100644 --- a/src/renderer/widgets/RemovePureProxyModal/ui/RemovePureProxy.tsx +++ b/src/renderer/features/proxy-remove-pure/ui/RemovePureProxy.tsx @@ -1,6 +1,6 @@ -import { useUnit } from 'effector-react'; +import { useGate, useUnit } from 'effector-react'; -import { type Chain } from '@/shared/core'; +import { type Chain, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useModalClose } from '@/shared/lib/hooks'; import { BaseModal, Button } from '@/shared/ui'; @@ -10,12 +10,19 @@ import { OperationSign, OperationSubmit } from '@/features/operations'; import { RemovePureProxiedConfirm as Confirmation, basketUtils } from '@/features/operations/OperationsConfirm'; import { removePureProxyUtils } from '../lib/remove-pure-proxy-utils'; import { Step } from '../lib/types'; +import { formModel } from '../model/form-model'; import { removePureProxyModel } from '../model/remove-pure-proxy-model'; import { RemovePureProxyForm } from './RemovePureProxyForm'; import { Warning } from './Warning'; -export const RemovePureProxy = () => { +type Props = { + wallet: Wallet; +}; + +export const RemovePureProxy = ({ wallet }: Props) => { + useGate(formModel.flow, { wallet }); + const { t } = useI18n(); const step = useUnit(removePureProxyModel.$step); @@ -33,7 +40,7 @@ export const RemovePureProxy = () => { removePureProxyModel.output.flowFinished, ); - const getModalTitle = (step: Step, chain?: Chain) => { + const getModalTitle = (step: Step, chain: Chain | null) => { if (removePureProxyUtils.isInitStep(step) || !chain) { return t(shouldRemovePureProxy ? 'operations.modalTitles.removePureProxy' : 'operations.modalTitles.removeProxy'); } diff --git a/src/renderer/widgets/RemovePureProxyModal/ui/RemovePureProxyForm.tsx b/src/renderer/features/proxy-remove-pure/ui/RemovePureProxyForm.tsx similarity index 100% rename from src/renderer/widgets/RemovePureProxyModal/ui/RemovePureProxyForm.tsx rename to src/renderer/features/proxy-remove-pure/ui/RemovePureProxyForm.tsx diff --git a/src/renderer/widgets/RemovePureProxyModal/ui/Warning.tsx b/src/renderer/features/proxy-remove-pure/ui/Warning.tsx similarity index 96% rename from src/renderer/widgets/RemovePureProxyModal/ui/Warning.tsx rename to src/renderer/features/proxy-remove-pure/ui/Warning.tsx index b112710a57..ecd5d3c7ce 100644 --- a/src/renderer/widgets/RemovePureProxyModal/ui/Warning.tsx +++ b/src/renderer/features/proxy-remove-pure/ui/Warning.tsx @@ -4,8 +4,8 @@ import { type ClipboardEvent, type FormEvent } from 'react'; import { Trans } from 'react-i18next'; import { useI18n } from '@/shared/i18n'; -import { Button, FootnoteText, Input } from '@/shared/ui'; -import { Checkbox } from '@/shared/ui-kit'; +import { Button, FootnoteText } from '@/shared/ui'; +import { Checkbox, Input } from '@/shared/ui-kit'; import { warningModel } from '../model/warning-model'; type Props = { @@ -33,7 +33,6 @@ export const Warning = ({ onGoBack }: Props) => {
    {t('pureProxyRemove.warning.warningMessage')} ({ defaultState: { wallet: null } }); + const formInitiated = createEvent(); const formSubmitted = createEvent(); const proxyQueryChanged = createEvent(); @@ -61,6 +64,8 @@ const feeChanged = createEvent(); const isFeeLoadingChanged = createEvent(); const isProxyDepositLoadingChanged = createEvent(); +const $wallet = flow.state.map(({ wallet }) => wallet); + const $formStore = restore(formInitiated, null); const $multisigDeposit = restore(multisigDepositChanged, '0'); @@ -119,7 +124,7 @@ const $proxyChains = combine(networkModel.$chains, (chains) => { const $proxiedAccounts = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, chain: $chain, balances: balanceModel.$balances, }, @@ -178,9 +183,7 @@ const $proxyTypes = combine( ({ apis, statuses, chain }) => { if (!chain) return []; - return networkUtils.isConnectedStatus(statuses[chain.chainId]) - ? getProxyTypes(apis[chain.chainId]) - : [ProxyType.ANY]; + return networkUtils.isConnectedStatus(statuses[chain.chainId]) ? getProxyTypes(apis[chain.chainId]) : ['Any']; }, ); @@ -224,7 +227,7 @@ const $fakeTx = combine( type: TransactionType.REMOVE_PURE_PROXY, args: { spawner: toAddress(TEST_ACCOUNTS[0], { prefix: chain.addressPrefix }), - proxyType: ProxyType.ANY, + proxyType: 'Any', index: 0, blockNumber: 1, extrinsicIndex: 1, @@ -262,19 +265,19 @@ sample({ sample({ clock: $realAccount, source: { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, wallets: walletModel.$wallets, }, filter: (_, account) => Boolean(account), fn: ({ wallet, wallets }, account): Record => { if (!wallet) return { isMultisig: false, isProxy: false }; - if (walletUtils.isMultisig(wallet)) return { isMultisig: true, isProxy: false }; + if (walletUtils.isRegularMultisig(wallet)) return { isMultisig: true, isProxy: false }; if (!walletUtils.isProxied(wallet)) return { isMultisig: false, isProxy: false }; const accountWallet = walletUtils.getWalletById(wallets, account!.walletId); return { - isMultisig: walletUtils.isMultisig(accountWallet), + isMultisig: walletUtils.isRegularMultisig(accountWallet), isProxy: true, }; }, @@ -339,6 +342,7 @@ sample({ }); export const formModel = { + $wallet, $proxyForm, $proxyChains, $proxiedAccounts, @@ -357,6 +361,8 @@ export const formModel = { $isChainConnected, $canSubmit, + flow, + events: { formInitiated, proxyQueryChanged, diff --git a/src/renderer/widgets/RemoveProxyModal/model/remove-proxy-model.ts b/src/renderer/features/proxy-remove/model/remove-proxy-model.ts similarity index 92% rename from src/renderer/widgets/RemoveProxyModal/model/remove-proxy-model.ts rename to src/renderer/features/proxy-remove/model/remove-proxy-model.ts index ff5b198ddf..58acb91856 100644 --- a/src/renderer/widgets/RemoveProxyModal/model/remove-proxy-model.ts +++ b/src/renderer/features/proxy-remove/model/remove-proxy-model.ts @@ -1,39 +1,35 @@ -import { combine, createEvent, createStore, sample, split } from 'effector'; +import { combine, createEvent, createStore, restore, sample, split } from 'effector'; import { spread } from 'patronum'; import { type Account, type BasketTransaction, type Chain, + type ChainId, type MultisigTxWrapper, type ProxiedAccount, type ProxyAccount, type ProxyTxWrapper, - ProxyType, ProxyVariant, type Transaction, TransactionType, type TxWrapper, WrapperKind, } from '@/shared/core'; -import { nonNullable, toAccountId, toAddress, withdrawableAmount } from '@/shared/lib/utils'; +import { nonNullable, nullable, toAccountId, toAddress, withdrawableAmount } from '@/shared/lib/utils'; import { type PathType, Paths } from '@/shared/routes'; import { balanceModel, balanceUtils } from '@/entities/balance'; import { basketModel } from '@/entities/basket'; import { networkModel } from '@/entities/network'; -import { proxyModel } from '@/entities/proxy'; +import { proxyModel, proxyUtils } from '@/entities/proxy'; import { transactionService } from '@/entities/transaction'; import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; -import { balanceSubModel } from '@/features/balances'; +import { balanceSubModel } from '@/features/assets-balances'; import { navigationModel } from '@/features/navigation'; import { signModel } from '@/features/operations/OperationSign/model/sign-model'; import { submitModel, submitUtils } from '@/features/operations/OperationSubmit'; import { removeProxyConfirmModel as confirmModel } from '@/features/operations/OperationsConfirm'; import { proxiesModel } from '@/features/proxies'; -import { walletSelectModel } from '@/features/wallets'; -// TODO fix cycle widgets/WalletDetails <=> widgets/RemoveProxyModal -// eslint-disable-next-line boundaries/entry-point -import { walletProviderModel } from '@/widgets/WalletDetails/model/wallet-provider-model'; import { removeProxyUtils } from '../lib/remove-proxy-utils'; import { type RemoveProxyStore, Step } from '../lib/types'; @@ -67,9 +63,26 @@ const $redirectAfterSubmitPath = createStore(null).reset(flowSt const $chain = $removeProxyStore.map((store) => store?.chain, { skipVoid: false }); const $account = $removeProxyStore.map((store) => store?.account, { skipVoid: false }); +const removeProxy = createEvent(); + +const $proxyForRemoval = restore(removeProxy, null); + +const $chainProxies = combine( + { + wallet: formModel.$wallet, + chains: networkModel.$chains, + proxies: proxyModel.$proxies, + }, + ({ wallet, chains, proxies }): Record => { + if (nullable(wallet)) return {}; + + return proxyUtils.getProxyAccountsOnChain(wallet.accounts, Object.keys(chains) as ChainId[], proxies); + }, +); + const $txWrappers = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: formModel.$wallet, wallets: walletModel.$wallets, account: $account, chain: $chain, @@ -99,7 +112,7 @@ const $txWrappers = combine( const $shouldRemovePureProxy = combine( { - proxies: walletProviderModel.$chainsProxies, + proxies: $chainProxies, account: $account, chain: $chain, }, @@ -107,7 +120,7 @@ const $shouldRemovePureProxy = combine( if (!chain || !account) return true; const chainProxies = proxies[chain.chainId] || []; - const anyProxies = chainProxies.filter((proxy) => proxy.proxyType === ProxyType.ANY); + const anyProxies = chainProxies.filter((proxy) => proxy.proxyType === 'Any'); const isPureProxy = (account as ProxiedAccount).proxyVariant === ProxyVariant.PURE; return isPureProxy && anyProxies.length === 1; @@ -211,7 +224,7 @@ split({ sample({ clock: flowStarted, source: { - proxyAccount: walletProviderModel.$proxyForRemoval, + proxyAccount: $proxyForRemoval, chains: networkModel.$chains, }, filter: ({ proxyAccount }) => Boolean(proxyAccount), @@ -239,7 +252,7 @@ sample({ clock: flowStarted, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; @@ -411,7 +424,7 @@ sample({ source: { step: $step, store: $removeProxyStore, - chainProxies: walletProviderModel.$chainsProxies, + chainProxies: $chainProxies, }, filter: ({ step }) => removeProxyUtils.isSubmitStep(step), fn: ({ store, chainProxies }) => { @@ -459,7 +472,7 @@ sample({ clock: flowFinished, source: { activeWallet: walletModel.$activeWallet, - walletDetails: walletSelectModel.$walletForDetails, + walletDetails: formModel.$wallet, }, filter: ({ activeWallet, walletDetails }) => { if (!activeWallet || !walletDetails) return false; @@ -475,6 +488,11 @@ sample({ target: proxiesModel.events.workerStarted, }); +sample({ + clock: flowFinished, + target: $proxyForRemoval.reinit, +}); + sample({ clock: flowFinished, fn: () => Step.NONE, @@ -497,6 +515,7 @@ sample({ }); export const removeProxyModel = { + $proxyForRemoval, $step, $chain, $account, @@ -508,6 +527,7 @@ export const removeProxyModel = { $initiatorWallet, events: { + removeProxy, flowStarted, stepChanged, wentBackFromConfirm, diff --git a/src/renderer/widgets/RemoveProxyModal/ui/RemoveProxy.tsx b/src/renderer/features/proxy-remove/ui/RemoveProxy.tsx similarity index 90% rename from src/renderer/widgets/RemoveProxyModal/ui/RemoveProxy.tsx rename to src/renderer/features/proxy-remove/ui/RemoveProxy.tsx index 741d921bb9..e17fa6defd 100644 --- a/src/renderer/widgets/RemoveProxyModal/ui/RemoveProxy.tsx +++ b/src/renderer/features/proxy-remove/ui/RemoveProxy.tsx @@ -1,6 +1,6 @@ -import { useUnit } from 'effector-react'; +import { useGate, useUnit } from 'effector-react'; -import { type Chain } from '@/shared/core'; +import { type Chain, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useModalClose } from '@/shared/lib/hooks'; import { BaseModal, Button } from '@/shared/ui'; @@ -10,11 +10,18 @@ import { OperationSign, OperationSubmit } from '@/features/operations'; import { RemoveProxyConfirm as Confirmation, basketUtils } from '@/features/operations/OperationsConfirm'; import { removeProxyUtils } from '../lib/remove-proxy-utils'; import { Step } from '../lib/types'; +import { formModel } from '../model/form-model'; import { removeProxyModel } from '../model/remove-proxy-model'; import { RemoveProxyForm } from './RemoveProxyForm'; -export const RemoveProxy = () => { +type Props = { + wallet: Wallet; +}; + +export const RemoveProxy = ({ wallet }: Props) => { + useGate(formModel.flow, { wallet }); + const { t } = useI18n(); const step = useUnit(removeProxyModel.$step); diff --git a/src/renderer/widgets/RemoveProxyModal/ui/RemoveProxyForm.tsx b/src/renderer/features/proxy-remove/ui/RemoveProxyForm.tsx similarity index 100% rename from src/renderer/widgets/RemoveProxyModal/ui/RemoveProxyForm.tsx rename to src/renderer/features/proxy-remove/ui/RemoveProxyForm.tsx diff --git a/src/renderer/features/settings-navigation/index.ts b/src/renderer/features/settings-navigation/index.ts index ba540b5562..db7b3cf49b 100644 --- a/src/renderer/features/settings-navigation/index.ts +++ b/src/renderer/features/settings-navigation/index.ts @@ -4,7 +4,7 @@ import { Paths } from '@/shared/routes'; import { navigationBottomLinksPipeline } from '@/features/app-shell'; export const settingsNavigationFeature = createFeature({ - name: 'Settings navigation', + name: 'settings/navigation', enable: $features.map(({ settings }) => settings), }); diff --git a/src/renderer/features/staking-navigation/index.ts b/src/renderer/features/staking-navigation/index.ts index 1d7f6146e7..4b06e0ca0b 100644 --- a/src/renderer/features/staking-navigation/index.ts +++ b/src/renderer/features/staking-navigation/index.ts @@ -6,7 +6,7 @@ import { Paths } from '@/shared/routes'; import { navigationTopLinksPipeline } from '@/features/app-shell'; export const stakingNavigationFeature = createFeature({ - name: 'Staking', + name: 'staking/navigation', input: createStore({}), enable: $features.map(({ staking }) => staking), }); diff --git a/src/renderer/features/staking/Validators/ui/Validators.tsx b/src/renderer/features/staking/Validators/ui/Validators.tsx index 13851284c8..b17ed1173c 100644 --- a/src/renderer/features/staking/Validators/ui/Validators.tsx +++ b/src/renderer/features/staking/Validators/ui/Validators.tsx @@ -4,8 +4,8 @@ import { memo } from 'react'; import { type Validator } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { cnTw } from '@/shared/lib/utils'; -import { BodyText, Button, Icon, Loader, SearchInput, Shimmering, SmallTitleText } from '@/shared/ui'; -import { Checkbox } from '@/shared/ui-kit'; +import { BodyText, Button, Icon, Loader, Shimmering, SmallTitleText } from '@/shared/ui'; +import { Checkbox, SearchInput } from '@/shared/ui-kit'; import { ValidatorsTable } from '@/entities/staking'; import { validatorsModel } from '../model/validators-model'; @@ -43,12 +43,13 @@ const Header = () => { {t('staking.validators.maxValidatorsLabel', { max: maxValidators })} )} - +
    + +
    ); }; diff --git a/src/renderer/features/wallet-details/index.ts b/src/renderer/features/wallet-details/index.ts new file mode 100644 index 0000000000..6d77cc52bb --- /dev/null +++ b/src/renderer/features/wallet-details/index.ts @@ -0,0 +1,11 @@ +import { walletDetailsModel } from './model/wallet-details-model'; +import { WalletDetails } from './ui/components/WalletDetails'; + +export const walletDetailsFeature = { + views: { + WalletDetails, + }, + models: { + walletDetails: walletDetailsModel, + }, +}; diff --git a/src/renderer/widgets/WalletDetails/lib/constants.ts b/src/renderer/features/wallet-details/lib/constants.ts similarity index 100% rename from src/renderer/widgets/WalletDetails/lib/constants.ts rename to src/renderer/features/wallet-details/lib/constants.ts diff --git a/src/renderer/widgets/WalletDetails/lib/types.ts b/src/renderer/features/wallet-details/lib/types.ts similarity index 100% rename from src/renderer/widgets/WalletDetails/lib/types.ts rename to src/renderer/features/wallet-details/lib/types.ts diff --git a/src/renderer/widgets/WalletDetails/lib/utils.ts b/src/renderer/features/wallet-details/lib/utils.ts similarity index 100% rename from src/renderer/widgets/WalletDetails/lib/utils.ts rename to src/renderer/features/wallet-details/lib/utils.ts diff --git a/src/renderer/widgets/WalletDetails/model/__tests__/vault-details-model.test.ts b/src/renderer/features/wallet-details/model/__tests__/vault-details-model.test.ts similarity index 100% rename from src/renderer/widgets/WalletDetails/model/__tests__/vault-details-model.test.ts rename to src/renderer/features/wallet-details/model/__tests__/vault-details-model.test.ts diff --git a/src/renderer/widgets/WalletDetails/model/vault-details-model.ts b/src/renderer/features/wallet-details/model/vault-details-model.ts similarity index 100% rename from src/renderer/widgets/WalletDetails/model/vault-details-model.ts rename to src/renderer/features/wallet-details/model/vault-details-model.ts diff --git a/src/renderer/features/wallet-details/model/wallet-balance.ts b/src/renderer/features/wallet-details/model/wallet-balance.ts new file mode 100644 index 0000000000..487a4b566a --- /dev/null +++ b/src/renderer/features/wallet-details/model/wallet-balance.ts @@ -0,0 +1,49 @@ +import { default as BigNumber } from 'bignumber.js'; +import { combine } from 'effector'; + +import { type Account } from '@/shared/core'; +import { dictionary, getRoundedValue, totalAmount } from '@/shared/lib/utils'; +import { balanceModel } from '@/entities/balance'; +import { networkModel } from '@/entities/network'; +import { currencyModel, priceProviderModel } from '@/entities/price'; +import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; + +const $walletBalance = combine( + { + wallet: walletModel.$activeWallet, + chains: networkModel.$chains, + balances: balanceModel.$balances, + currency: currencyModel.$activeCurrency, + prices: priceProviderModel.$assetsPrices, + }, + (params) => { + const { wallet, chains, balances, prices, currency } = params; + + if (!wallet || !prices || !balances || !currency?.coingeckoId) return new BigNumber(0); + + const isPolkadotVault = walletUtils.isPolkadotVault(wallet); + const accountMap = dictionary(wallet.accounts as Account[], 'accountId'); + + return balances.reduce((acc, balance) => { + const account = accountMap[balance.accountId]; + if (!account) return acc; + if (accountUtils.isBaseAccount(account) && isPolkadotVault) return acc; + + const asset = chains[balance.chainId]?.assets?.find((asset) => asset.assetId.toString() === balance.assetId); + + if (!asset?.priceId || !prices[asset.priceId]) return acc; + + const price = prices[asset.priceId][currency.coingeckoId]; + if (price) { + const fiatBalance = getRoundedValue(totalAmount(balance), price.price, asset.precision); + acc = acc.plus(new BigNumber(fiatBalance)); + } + + return acc; + }, new BigNumber(0)); + }, +); + +export const walletBalanceModel = { + $walletBalance, +}; diff --git a/src/renderer/features/wallet-details/model/wallet-details-model.ts b/src/renderer/features/wallet-details/model/wallet-details-model.ts new file mode 100644 index 0000000000..a0e02eeccd --- /dev/null +++ b/src/renderer/features/wallet-details/model/wallet-details-model.ts @@ -0,0 +1,169 @@ +import { combine } from 'effector'; +import { createGate } from 'effector-react'; +import { isEmpty } from 'lodash'; + +import { + type AccountId, + type ChainId, + type Contact, + type ProxyAccount, + type ProxyGroup, + type Wallet, +} from '@/shared/core'; +import { dictionary, nullable } from '@/shared/lib/utils'; +import { contactModel } from '@/entities/contact'; +import { networkModel } from '@/entities/network'; +import { proxyModel, proxyUtils } from '@/entities/proxy'; +import { accountUtils, permissionUtils, walletModel, walletUtils } from '@/entities/wallet'; +import { walletDetailsUtils } from '../lib/utils'; + +const flow = createGate<{ wallet: Wallet | null }>({ defaultState: { wallet: null } }); + +const $wallet = flow.state.map(({ wallet }) => wallet); + +const $multiShardAccounts = $wallet.map((wallet) => { + if (nullable(wallet) || !walletUtils.isMultiShard(wallet)) return new Map(); + + return walletDetailsUtils.getMultishardMap(wallet.accounts); +}); + +const $canCreateProxy = $wallet.map((wallet) => { + if (nullable(wallet)) return false; + + const canCreateAnyProxy = permissionUtils.canCreateAnyProxy(wallet); + const canCreateNonAnyProxy = permissionUtils.canCreateNonAnyProxy(wallet); + + return canCreateAnyProxy || canCreateNonAnyProxy; +}); + +const $vaultAccounts = $wallet.map((wallet) => { + if (!wallet || !walletUtils.isPolkadotVault(wallet)) return null; + + const root = accountUtils.getBaseAccount(wallet.accounts); + const accountsMap = walletDetailsUtils.getVaultAccountsMap(wallet.accounts); + + if (!root || isEmpty(accountsMap)) return null; + + return { root, accountsMap }; +}); + +const $multisigAccount = $wallet.map((wallet) => { + if (nullable(wallet) || !walletUtils.isMultisig(wallet)) return null; + + return wallet.accounts.at(0) ?? null; +}); + +const $signatories = combine( + { + account: $multisigAccount, + wallets: walletModel.$wallets, + contacts: contactModel.$contacts, + }, + ({ account, wallets, contacts }): { wallets: [Wallet, AccountId][]; contacts: Contact[]; people: AccountId[] } => { + if (!account) { + return { wallets: [], contacts: [], people: [] }; + } + + const signatoriesMap = dictionary(account.signatories, 'accountId', true); + + const walletSignatories: [Wallet, AccountId][] = []; + for (const wallet of wallets) { + if (walletUtils.isWatchOnly(wallet)) continue; + + for (const account of wallet.accounts) { + if (!signatoriesMap[account.accountId]) continue; + + delete signatoriesMap[account.accountId]; + walletSignatories.push([wallet, account.accountId]); + } + } + + const contactSignatories: Contact[] = []; + for (const contact of contacts) { + if (!signatoriesMap[contact.accountId]) continue; + + contactSignatories.push(contact); + delete signatoriesMap[contact.accountId]; + } + + return { + wallets: walletSignatories, + contacts: contactSignatories, + people: Object.keys(signatoriesMap) as AccountId[], + }; + }, +); + +const $chainsProxies = combine( + { + wallet: $wallet, + chains: networkModel.$chains, + proxies: proxyModel.$proxies, + }, + ({ wallet, chains, proxies }): Record => { + if (nullable(wallet)) return {}; + + return proxyUtils.getProxyAccountsOnChain(wallet.accounts, Object.keys(chains) as ChainId[], proxies); + }, +); + +const $walletProxyGroups = combine( + { + wallet: $wallet, + chainsProxies: $chainsProxies, + groups: proxyModel.$walletsProxyGroups, + }, + ({ wallet, groups }): ProxyGroup[] => { + if (nullable(wallet) || nullable(groups[wallet.id])) return []; + + // TODO: Find why it can be doubled sometimes https://github.com/novasamatech/nova-spektr/issues/1655 + const walletGroups = groups[wallet.id]; + const filteredGroups = walletGroups.reduceRight( + (acc, group) => { + const id = `${group.chainId}_${group.proxiedAccountId}_${group.walletId}`; + + if (!acc[id]) { + acc[id] = group; + } + + return acc; + }, + {} as Record, + ); + + return Object.values(filteredGroups); + }, +); + +const $proxyWallet = combine( + { + wallet: $wallet, + wallets: walletModel.$wallets, + }, + ({ wallet, wallets }): Wallet | null => { + if (!wallet || !walletUtils.isProxied(wallet)) return null; + + return walletUtils.getWalletFilteredAccounts(wallets, { + walletFn: (w) => !walletUtils.isWatchOnly(w), + accountFn: (a) => a.accountId === wallet.accounts[0].proxyAccountId, + }); + }, +); + +const $hasProxies = combine($chainsProxies, (chainsProxies) => { + return Object.values(chainsProxies).some((accounts) => accounts.length > 0); +}); + +export const walletDetailsModel = { + flow, + + $vaultAccounts, + $multiShardAccounts, + $signatories, + + $chainsProxies, + $walletProxyGroups, + $proxyWallet, + $hasProxies, + $canCreateProxy, +}; diff --git a/src/renderer/widgets/WalletDetails/model/wc-details-model.ts b/src/renderer/features/wallet-details/model/wc-details-model.ts similarity index 91% rename from src/renderer/widgets/WalletDetails/model/wc-details-model.ts rename to src/renderer/features/wallet-details/model/wc-details-model.ts index 8934f7c376..42cdcb7975 100644 --- a/src/renderer/widgets/WalletDetails/model/wc-details-model.ts +++ b/src/renderer/features/wallet-details/model/wc-details-model.ts @@ -1,4 +1,5 @@ import { createEvent, createStore, sample } from 'effector'; +import { createGate } from 'effector-react'; import { combineEvents, spread } from 'patronum'; import { AccountType, type ChainId, type Wallet, type WcAccount } from '@/shared/core'; @@ -7,9 +8,12 @@ import { balanceModel } from '@/entities/balance'; import { networkModel } from '@/entities/network'; import { walletModel, walletUtils } from '@/entities/wallet'; import { type InitConnectParams, walletConnectModel, walletConnectUtils } from '@/entities/walletConnect'; -import { walletSelectModel } from '@/features/wallets'; import { ForgetStep, ReconnectStep } from '../lib/constants'; +const walletConnectDetailsFlow = createGate<{ wallet: Wallet | null }>({ defaultState: { wallet: null } }); + +const $wallet = walletConnectDetailsFlow.state.map(({ wallet }) => wallet); + const reset = createEvent(); const confirmReconnectShown = createEvent(); const reconnectStarted = createEvent & { currentSession: string }>(); @@ -53,7 +57,7 @@ sample({ clock: walletConnectModel.events.connected, source: { step: $reconnectStep, - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, session: walletConnectModel.$session, }, filter: ({ step, wallet, session }) => { @@ -75,11 +79,11 @@ sample({ reset: reconnectStarted, }), source: { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, newAccounts: walletConnectModel.$accounts, chains: networkModel.$chains, }, - filter: ({ wallet }) => Boolean(wallet), + filter: ({ wallet }) => nonNullable(wallet), fn: ({ wallet, newAccounts, chains }) => { const updatedAccounts: WcAccount[] = []; const chainIds = Object.keys(chains); @@ -135,11 +139,9 @@ sample({ sample({ clock: forgetButtonClicked, - source: { - wallet: walletSelectModel.$walletForDetails, - }, - filter: ({ wallet }) => nonNullable(wallet), - fn: ({ wallet }) => ({ + source: $wallet, + filter: nonNullable, + fn: (wallet) => ({ sessionTopic: wallet!.accounts[0].signingExtras?.sessionTopic, pairingTopic: wallet!.accounts[0].signingExtras?.pairingTopic, }), @@ -157,8 +159,8 @@ sample({ sample({ clock: forgetButtonClicked, - source: walletSelectModel.$walletForDetails, - filter: (wallet): wallet is Wallet => wallet !== null, + source: $wallet, + filter: nonNullable, fn: (wallet) => wallet!.id, target: walletModel.events.walletRemoved, }); @@ -171,11 +173,6 @@ sample({ target: $forgetStep, }); -sample({ - clock: forgetModalClosed, - target: walletSelectModel.events.walletIdCleared, -}); - export const wcDetailsModel = { $reconnectStep, $forgetStep, @@ -188,4 +185,5 @@ export const wcDetailsModel = { forgetButtonClicked, forgetModalClosed, }, + walletConnectDetailsFlow, }; diff --git a/src/renderer/widgets/WalletDetails/ui/components/NoProxiesAction.tsx b/src/renderer/features/wallet-details/ui/components/NoProxiesAction.tsx similarity index 100% rename from src/renderer/widgets/WalletDetails/ui/components/NoProxiesAction.tsx rename to src/renderer/features/wallet-details/ui/components/NoProxiesAction.tsx diff --git a/src/renderer/widgets/WalletDetails/ui/components/ProxiesList.tsx b/src/renderer/features/wallet-details/ui/components/ProxiesList.tsx similarity index 78% rename from src/renderer/widgets/WalletDetails/ui/components/ProxiesList.tsx rename to src/renderer/features/wallet-details/ui/components/ProxiesList.tsx index b5eb61d644..d23070c17b 100644 --- a/src/renderer/widgets/WalletDetails/ui/components/ProxiesList.tsx +++ b/src/renderer/features/wallet-details/ui/components/ProxiesList.tsx @@ -1,6 +1,6 @@ import { useUnit } from 'effector-react'; -import { type ProxiedAccount, type ProxyAccount, ProxyType, ProxyVariant } from '@/shared/core'; +import { type ProxiedAccount, type ProxyAccount, ProxyVariant, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useToggle } from '@/shared/lib/hooks'; import { cnTw } from '@/shared/lib/utils'; @@ -9,44 +9,56 @@ import { AssetBalance } from '@/entities/asset'; import { ChainTitle } from '@/entities/chain'; import { networkModel } from '@/entities/network'; import { accountUtils } from '@/entities/wallet'; -import { walletSelectModel } from '@/features/wallets'; -import { RemoveProxy, removeProxyModel } from '@/widgets/RemoveProxyModal'; -import { RemovePureProxy, removePureProxyModel } from '@/widgets/RemovePureProxyModal'; -import { walletProviderModel } from '../../model/wallet-provider-model'; +import { proxyRemoveFeature } from '@/features/proxy-remove'; +import { proxyRemovePureFeature } from '@/features/proxy-remove-pure'; +import { walletDetailsModel } from '../../model/wallet-details-model'; import { ProxyAccountWithActions } from './ProxyAccountWithActions'; +const { + models: { removeProxy }, + views: { RemoveProxy }, +} = proxyRemoveFeature; + +const { + models: { removePureProxy }, + views: { RemovePureProxy }, +} = proxyRemovePureFeature; + type Props = { + wallet: Wallet; canCreateProxy?: boolean; className?: string; }; -export const ProxiesList = ({ className, canCreateProxy = true }: Props) => { +export const ProxiesList = ({ className, wallet, canCreateProxy = true }: Props) => { const { t } = useI18n(); - const wallet = useUnit(walletSelectModel.$walletForDetails); const chains = useUnit(networkModel.$chains); - const chainsProxies = useUnit(walletProviderModel.$chainsProxies); - const walletProxyGroups = useUnit(walletProviderModel.$walletProxyGroups); - const proxyForRemoval = useUnit(walletProviderModel.$proxyForRemoval); + const chainsProxies = useUnit(walletDetailsModel.$chainsProxies); + const walletProxyGroups = useUnit(walletDetailsModel.$walletProxyGroups); + const proxyForRemoval = useUnit(removeProxy.$proxyForRemoval); const [isRemoveConfirmOpen, toggleIsRemoveConfirmOpen] = useToggle(); const handleDeleteProxy = (proxyAccount: ProxyAccount) => { const chainProxies = chainsProxies[proxyAccount.chainId] || []; - const anyProxies = chainProxies.filter((proxy) => proxy.proxyType === ProxyType.ANY); + const anyProxies = chainProxies.filter((proxy) => proxy.proxyType === 'Any'); const isPureProxy = (wallet?.accounts[0] as ProxiedAccount).proxyVariant === ProxyVariant.PURE; const shouldRemovePureProxy = isPureProxy && anyProxies.length === 1; if (shouldRemovePureProxy) { - removePureProxyModel.events.flowStarted({ - account: wallet?.accounts[0] as ProxiedAccount, - proxy: proxyAccount, - }); + const account = wallet?.accounts.at(0); + if (account) { + removePureProxy.events.flowStarted({ + account: wallet?.accounts[0] as ProxiedAccount, + proxy: proxyAccount, + }); + } } else { - walletProviderModel.events.removeProxy(proxyAccount); + removeProxy.events.removeProxy(proxyAccount); toggleIsRemoveConfirmOpen(); } }; @@ -63,7 +75,7 @@ export const ProxiesList = ({ className, canCreateProxy = true }: Props) => { ); }); - removeProxyModel.events.flowStarted({ account: account!, proxy: proxyForRemoval }); + removeProxy.events.flowStarted({ account: account!, proxy: proxyForRemoval }); }; return ( @@ -133,8 +145,8 @@ export const ProxiesList = ({ className, canCreateProxy = true }: Props) => { - - + + ); }; diff --git a/src/renderer/widgets/WalletDetails/ui/components/ProxyAccountWithActions.tsx b/src/renderer/features/wallet-details/ui/components/ProxyAccountWithActions.tsx similarity index 100% rename from src/renderer/widgets/WalletDetails/ui/components/ProxyAccountWithActions.tsx rename to src/renderer/features/wallet-details/ui/components/ProxyAccountWithActions.tsx diff --git a/src/renderer/widgets/WalletDetails/ui/components/ShardsList.tsx b/src/renderer/features/wallet-details/ui/components/ShardsList.tsx similarity index 100% rename from src/renderer/widgets/WalletDetails/ui/components/ShardsList.tsx rename to src/renderer/features/wallet-details/ui/components/ShardsList.tsx diff --git a/src/renderer/widgets/WalletDetails/ui/components/WalletConnectAccounts.tsx b/src/renderer/features/wallet-details/ui/components/WalletConnectAccounts.tsx similarity index 100% rename from src/renderer/widgets/WalletDetails/ui/components/WalletConnectAccounts.tsx rename to src/renderer/features/wallet-details/ui/components/WalletConnectAccounts.tsx diff --git a/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx b/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx new file mode 100644 index 0000000000..4abcaa3ae7 --- /dev/null +++ b/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx @@ -0,0 +1,73 @@ +import { useGate, useUnit } from 'effector-react'; + +import { type Wallet } from '@/shared/core'; +import { nullable } from '@/shared/lib/utils'; +import { walletUtils } from '@/entities/wallet'; +import { walletDetailsModel } from '../../model/wallet-details-model'; +import { MultishardWalletDetails } from '../wallets/MultishardWalletDetails'; +import { MultisigWalletDetails } from '../wallets/MultisigWalletDetails'; +import { ProxiedWalletDetails } from '../wallets/ProxiedWalletDetails'; +import { SimpleWalletDetails } from '../wallets/SimpleWalletDetails'; +import { VaultWalletDetails } from '../wallets/VaultWalletDetails'; +import { WalletConnectDetails } from '../wallets/WalletConnectDetails'; + +type Props = { + wallet: Wallet | null; + isOpen: boolean; + onClose: VoidFunction; +}; + +export const WalletDetails = ({ isOpen, wallet, onClose }: Props) => { + useGate(walletDetailsModel.flow, { wallet }); + + const multiShardAccounts = useUnit(walletDetailsModel.$multiShardAccounts); + const vaultAccounts = useUnit(walletDetailsModel.$vaultAccounts); + const signatories = useUnit(walletDetailsModel.$signatories); + const proxyWallet = useUnit(walletDetailsModel.$proxyWallet); + + if (!isOpen || nullable(wallet)) { + return null; + } + + if (walletUtils.isWatchOnly(wallet) || walletUtils.isSingleShard(wallet)) { + return ; + } + + if (walletUtils.isMultiShard(wallet) && multiShardAccounts.size > 0) { + return ; + } + + // TODO: Separate wallet details for regular and flexible multisig + if (walletUtils.isMultisig(wallet)) { + return ( + + ); + } + + if (walletUtils.isWalletConnect(wallet) || walletUtils.isNovaWallet(wallet)) { + return ; + } + + if (walletUtils.isPolkadotVault(wallet) && vaultAccounts) { + return ( + + ); + } + + if (walletUtils.isProxied(wallet) && proxyWallet) { + return ; + } + + return null; +}; diff --git a/src/renderer/features/wallets/WalletSelect/ui/WalletFiatBalance.tsx b/src/renderer/features/wallet-details/ui/components/WalletFiatBalance.tsx similarity index 89% rename from src/renderer/features/wallets/WalletSelect/ui/WalletFiatBalance.tsx rename to src/renderer/features/wallet-details/ui/components/WalletFiatBalance.tsx index 54674571ae..8b27c2acfd 100644 --- a/src/renderer/features/wallets/WalletSelect/ui/WalletFiatBalance.tsx +++ b/src/renderer/features/wallet-details/ui/components/WalletFiatBalance.tsx @@ -7,7 +7,7 @@ import { formatFiatBalance } from '@/shared/lib/utils'; import { Shimmering } from '@/shared/ui'; import { FiatBalance, priceProviderModel } from '@/entities/price'; import { walletModel } from '@/entities/wallet'; -import { walletSelectModel } from '../model/wallet-select-model'; +import { walletBalanceModel } from '../../model/wallet-balance'; BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_DOWN, @@ -22,7 +22,7 @@ export const WalletFiatBalance = ({ walletId, className }: Props) => { const { t } = useI18n(); const fiatFlag = useUnit(priceProviderModel.$fiatFlag); - const walletBalances = useUnit(walletSelectModel.$walletBalance); + const walletBalances = useUnit(walletBalanceModel.$walletBalance); const activeWallet = useUnit(walletModel.$activeWallet); if (!fiatFlag || walletId !== activeWallet?.id) { diff --git a/src/renderer/features/wallet-details/ui/components/index.ts b/src/renderer/features/wallet-details/ui/components/index.ts new file mode 100644 index 0000000000..40cce87f51 --- /dev/null +++ b/src/renderer/features/wallet-details/ui/components/index.ts @@ -0,0 +1,7 @@ +export { NoProxiesAction } from './NoProxiesAction'; +export { ProxiesList } from './ProxiesList'; +export { ProxyAccountWithActions } from './ProxyAccountWithActions'; +export { ShardsList } from './ShardsList'; +export { WalletConnectAccounts } from './WalletConnectAccounts'; +export { WalletDetails } from './WalletDetails'; +export { WalletFiatBalance } from './WalletFiatBalance'; diff --git a/src/renderer/widgets/WalletDetails/ui/wallets/MultishardWalletDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/MultishardWalletDetails.tsx similarity index 82% rename from src/renderer/widgets/WalletDetails/ui/wallets/MultishardWalletDetails.tsx rename to src/renderer/features/wallet-details/ui/wallets/MultishardWalletDetails.tsx index 0f4caf696c..3437053f5d 100644 --- a/src/renderer/widgets/WalletDetails/ui/wallets/MultishardWalletDetails.tsx +++ b/src/renderer/features/wallet-details/ui/wallets/MultishardWalletDetails.tsx @@ -8,16 +8,26 @@ import { type IconNames } from '@/shared/ui/Icon/data'; import { type TabItem } from '@/shared/ui/types'; import { networkModel } from '@/entities/network'; import { MultishardAccountsList, WalletCardLg, permissionUtils } from '@/entities/wallet'; +import { proxyAddFeature } from '@/features/proxy-add'; +import { proxyAddPureFeature } from '@/features/proxy-add-pure'; import { ForgetWalletModal } from '@/features/wallets/ForgetWallet'; import { RenameWalletModal } from '@/features/wallets/RenameWallet'; -import { AddProxy, addProxyModel } from '@/widgets/AddProxyModal'; -import { AddPureProxied, addPureProxiedModel } from '@/widgets/AddPureProxiedModal'; import { type MultishardMap } from '../../lib/types'; import { walletDetailsUtils } from '../../lib/utils'; -import { walletProviderModel } from '../../model/wallet-provider-model'; +import { walletDetailsModel } from '../../model/wallet-details-model'; import { NoProxiesAction } from '../components/NoProxiesAction'; import { ProxiesList } from '../components/ProxiesList'; +const { + models: { addProxy }, + views: { AddProxy }, +} = proxyAddFeature; + +const { + models: { addPureProxied }, + views: { AddPureProxied }, +} = proxyAddPureFeature; + type Props = { wallet: MultiShardWallet; accounts: MultishardMap; @@ -27,8 +37,8 @@ export const MultishardWalletDetails = ({ wallet, accounts, onClose }: Props) => const { t } = useI18n(); const chains = useUnit(networkModel.$chains); - const hasProxies = useUnit(walletProviderModel.$hasProxies); - const canCreateProxy = useUnit(walletProviderModel.$canCreateProxy); + const hasProxies = useUnit(walletDetailsModel.$hasProxies); + const canCreateProxy = useUnit(walletDetailsModel.$canCreateProxy); const [isModalOpen, closeModal] = useModalClose(true, onClose); const [isRenameModalOpen, toggleIsRenameModalOpen] = useToggle(); @@ -56,7 +66,7 @@ export const MultishardWalletDetails = ({ wallet, accounts, onClose }: Props) => Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addProxyAction'), - onClick: addProxyModel.events.flowStarted, + onClick: addProxy.events.flowStarted, }); } @@ -64,7 +74,7 @@ export const MultishardWalletDetails = ({ wallet, accounts, onClose }: Props) => Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addPureProxiedAction'), - onClick: addPureProxiedModel.events.flowStarted, + onClick: addPureProxied.events.flowStarted, }); } @@ -90,12 +100,12 @@ export const MultishardWalletDetails = ({ wallet, accounts, onClose }: Props) => id: 'proxies', title: t('walletDetails.common.proxiesTabTitle'), panel: hasProxies ? ( - + ) : ( ), }, @@ -127,8 +137,8 @@ export const MultishardWalletDetails = ({ wallet, accounts, onClose }: Props) => onForget={onClose} /> - - + + ); }; diff --git a/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx new file mode 100644 index 0000000000..f8840ec8d6 --- /dev/null +++ b/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx @@ -0,0 +1,353 @@ +import { useUnit } from 'effector-react'; +import { useMemo } from 'react'; +import { Trans } from 'react-i18next'; + +import { + type AccountId, + type Contact, + type FlexibleMultisigWallet, + type MultisigWallet, + type Wallet, +} from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { useModalClose, useToggle } from '@/shared/lib/hooks'; +import { toAddress } from '@/shared/lib/utils'; +import { BaseModal, DropdownIconButton, FootnoteText, Icon, Tabs } from '@/shared/ui'; +import { type IconNames } from '@/shared/ui/Icon/data'; +import { type TabItem } from '@/shared/ui/types'; +import { AccountExplorers, Address, RootExplorers } from '@/shared/ui-entities'; +import { ChainTitle } from '@/entities/chain'; +import { networkModel, networkUtils } from '@/entities/network'; +import { + AccountsList, + ContactItem, + WalletCardLg, + WalletCardMd, + accountUtils, + permissionUtils, +} from '@/entities/wallet'; +import { proxyAddFeature } from '@/features/proxy-add'; +import { proxyAddPureFeature } from '@/features/proxy-add-pure'; +import { ForgetWalletModal } from '@/features/wallets/ForgetWallet'; +import { RenameWalletModal } from '@/features/wallets/RenameWallet'; +import { walletDetailsModel } from '../../model/wallet-details-model'; +import { NoProxiesAction, ProxiesList } from '../components'; + +const { + models: { addProxy }, + views: { AddProxy }, +} = proxyAddFeature; + +const { + models: { addPureProxied }, + views: { AddPureProxied }, +} = proxyAddPureFeature; + +type Props = { + wallet: MultisigWallet | FlexibleMultisigWallet; + signatoryWallets: [Wallet, AccountId][]; + signatoryContacts: Contact[]; + signatoryPeople: AccountId[]; + onClose: () => void; +}; +export const MultisigWalletDetails = ({ + wallet, + signatoryWallets = [], + signatoryContacts = [], + signatoryPeople = [], + onClose, +}: Props) => { + const { t } = useI18n(); + + const chains = useUnit(networkModel.$chains); + const hasProxies = useUnit(walletDetailsModel.$hasProxies); + + const [isModalOpen, closeModal] = useModalClose(true, onClose); + const [isRenameModalOpen, toggleIsRenameModalOpen] = useToggle(); + const [isConfirmForgetOpen, toggleConfirmForget] = useToggle(); + + const multisigAccount = wallet.accounts[0]; + const singleChain = multisigAccount.chainId ? chains[multisigAccount.chainId] : undefined; + + const multisigChains = useMemo(() => { + return Object.values(chains).filter((chain) => { + const isAccountChain = multisigAccount.chainId === chain.chainId; + const isMultisigSupported = networkUtils.isMultisigSupported(chain.options); + const isChainAndCryptoMatch = accountUtils.isChainAndCryptoMatch(multisigAccount, chain); + + return isAccountChain || (isMultisigSupported && isChainAndCryptoMatch); + }); + }, [chains]); + + const canCreateProxy = useMemo(() => { + const anyProxy = permissionUtils.canCreateAnyProxy(wallet); + const nonAnyProxy = permissionUtils.canCreateNonAnyProxy(wallet); + + if (!singleChain) { + return anyProxy || nonAnyProxy; + } + + return (anyProxy || nonAnyProxy) && networkUtils.isProxySupported(singleChain?.options); + }, [singleChain]); + + const canCreatePureProxy = useMemo(() => { + const anyProxy = permissionUtils.canCreateAnyProxy(wallet); + + if (!singleChain) { + return anyProxy; + } + + return anyProxy && networkUtils.isPureProxySupported(singleChain?.options); + }, [singleChain]); + + const Options = [ + { + icon: 'rename' as IconNames, + title: t('walletDetails.common.renameButton'), + onClick: toggleIsRenameModalOpen, + }, + { + icon: 'forget' as IconNames, + title: t('walletDetails.common.forgetButton'), + onClick: toggleConfirmForget, + }, + ]; + + if (canCreateProxy) { + Options.push({ + icon: 'addCircle' as IconNames, + title: t('walletDetails.common.addProxyAction'), + onClick: addProxy.events.flowStarted, + }); + } + + if (canCreatePureProxy) { + Options.push({ + icon: 'addCircle' as IconNames, + title: t('walletDetails.common.addPureProxiedAction'), + onClick: addPureProxied.events.flowStarted, + }); + } + + const ActionButton = ( + + + {Options.map((option) => ( + + + + ))} + + + ); + + const TabItems: TabItem[] = []; + + if (singleChain) { + const TabAccount = { + id: 1, + title: t('walletDetails.multisig.accountTab'), + panel: ( +
    +
    + {t('walletDetails.multisig.accountGroup')} + +
    + + + +
    +
    + +
    + + {t('walletDetails.multisig.signatoriesGroup', { amount: multisigAccount.signatories.length })} + + +
      + {signatoryWallets.map(([wallet, accountId]) => ( +
    • + +
      +
    + } + > + + + + ))} + {signatoryContacts.map((signatory) => ( +
  • + + + +
  • + ))} + {signatoryPeople.map((accountId) => ( +
  • + + + +
  • + ))} + +
    + + ), + }; + TabItems.push(TabAccount); + } + + if (!singleChain) { + const TabAccountList = { + id: 1, + title: t('walletDetails.multisig.networksTab'), + panel: , + }; + + const TabSignatories = { + id: 2, + title: t('walletDetails.multisig.signatoriesTab'), + panel: ( +
    + + {t('walletDetails.multisig.thresholdLabel', { + min: multisigAccount.threshold, + max: multisigAccount.signatories.length, + })} + + +
    + {signatoryWallets.length > 0 && ( +
    + + {t('walletDetails.multisig.walletsGroup')} {signatoryWallets.length} + + +
      + {signatoryWallets.map(([wallet, accountId]) => ( +
    • + +
      +
    + } + > + + + + ))} + +
    + )} + + {signatoryContacts.length > 0 && ( +
    + + {t('walletDetails.multisig.contactsGroup')} {signatoryContacts.length} + + +
      + {signatoryContacts.map((signatory) => ( +
    • + + + +
    • + ))} +
    +
    + )} +
    + + ), + }; + + TabItems.push(TabAccountList); + TabItems.push(TabSignatories); + } + + if (canCreateProxy) { + const TabProxy = { + id: 3, + title: t('walletDetails.common.proxiesTabTitle'), + panel: hasProxies ? ( + + ) : ( + + ), + }; + + TabItems.push(TabProxy); + } + + return ( + +
    + {singleChain ? ( +
    + +
    + +
    + + ), + }} + values={{ threshold: multisigAccount.threshold, signatories: multisigAccount.signatories.length }} + /> +
    +
    +
    + ) : ( +
    + +
    + )} + + +
    + + + + + + + +
    + ); +}; diff --git a/src/renderer/widgets/WalletDetails/ui/wallets/ProxiedWalletDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/ProxiedWalletDetails.tsx similarity index 77% rename from src/renderer/widgets/WalletDetails/ui/wallets/ProxiedWalletDetails.tsx rename to src/renderer/features/wallet-details/ui/wallets/ProxiedWalletDetails.tsx index 38cc9c5d88..2b2ad1af16 100644 --- a/src/renderer/widgets/WalletDetails/ui/wallets/ProxiedWalletDetails.tsx +++ b/src/renderer/features/wallet-details/ui/wallets/ProxiedWalletDetails.tsx @@ -1,7 +1,7 @@ import { useUnit } from 'effector-react'; import noop from 'lodash/noop'; -import { type ProxiedWallet, ProxyType, type Wallet } from '@/shared/core'; +import { type ProxiedWallet, type ProxyType, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useModalClose, useToggle } from '@/shared/lib/hooks'; import { BaseModal, DropdownIconButton, FootnoteText, Icon, Tabs } from '@/shared/ui'; @@ -9,22 +9,31 @@ import { type IconNames } from '@/shared/ui/Icon/data'; import { type TabItem } from '@/shared/ui/types'; import { networkModel } from '@/entities/network'; import { AccountsList, WalletCardLg, WalletIcon, permissionUtils } from '@/entities/wallet'; +import { proxyAddFeature } from '@/features/proxy-add'; +import { proxyAddPureFeature } from '@/features/proxy-add-pure'; import { RenameWalletModal } from '@/features/wallets/RenameWallet'; -import { AddProxy, addProxyModel } from '@/widgets/AddProxyModal'; -import { AddPureProxied } from '@/widgets/AddPureProxiedModal'; -import { walletProviderModel } from '../../model/wallet-provider-model'; +import { walletDetailsModel } from '../../model/wallet-details-model'; import { NoProxiesAction } from '../components/NoProxiesAction'; import { ProxiesList } from '../components/ProxiesList'; +const { + models: { addProxy }, + views: { AddProxy }, +} = proxyAddFeature; + +const { + views: { AddPureProxied }, +} = proxyAddPureFeature; + const ProxyTypeOperation: Record = { - [ProxyType.ANY]: 'proxy.operations.any', - [ProxyType.NON_TRANSFER]: 'proxy.operations.nonTransfer', - [ProxyType.STAKING]: 'proxy.operations.staking', - [ProxyType.AUCTION]: 'proxy.operations.auction', - [ProxyType.CANCEL_PROXY]: 'proxy.operations.cancelProxy', - [ProxyType.GOVERNANCE]: 'proxy.operations.governance', - [ProxyType.IDENTITY_JUDGEMENT]: 'proxy.operations.identityJudgement', - [ProxyType.NOMINATION_POOLS]: 'proxy.operations.nominationPools', + Any: 'proxy.operations.any', + NonTransfer: 'proxy.operations.nonTransfer', + Staking: 'proxy.operations.staking', + Auction: 'proxy.operations.auction', + CancelProxy: 'proxy.operations.cancelProxy', + Governance: 'proxy.operations.governance', + IdentityJudgement: 'proxy.operations.identityJudgement', + NominationPools: 'proxy.operations.nominationPools', }; type Props = { @@ -37,8 +46,8 @@ export const ProxiedWalletDetails = ({ wallet, proxyWallet, onClose }: Props) => const { t } = useI18n(); const chains = useUnit(networkModel.$chains); - const hasProxies = useUnit(walletProviderModel.$hasProxies); - const canCreateProxy = useUnit(walletProviderModel.$canCreateProxy); + const hasProxies = useUnit(walletDetailsModel.$hasProxies); + const canCreateProxy = useUnit(walletDetailsModel.$canCreateProxy); const [isModalOpen, closeModal] = useModalClose(true, onClose); const [isRenameModalOpen, toggleIsRenameModalOpen] = useToggle(); @@ -55,7 +64,7 @@ export const ProxiedWalletDetails = ({ wallet, proxyWallet, onClose }: Props) => Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addProxyAction'), - onClick: addProxyModel.events.flowStarted, + onClick: addProxy.events.flowStarted, }); } @@ -87,7 +96,7 @@ export const ProxiedWalletDetails = ({ wallet, proxyWallet, onClose }: Props) => id: 'proxies', title: t('walletDetails.common.proxiesTabTitle'), panel: hasProxies ? ( - + ) : ( ), @@ -125,8 +134,8 @@ export const ProxiedWalletDetails = ({ wallet, proxyWallet, onClose }: Props) => - - + + ); }; diff --git a/src/renderer/widgets/WalletDetails/ui/wallets/SimpleWalletDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/SimpleWalletDetails.tsx similarity index 84% rename from src/renderer/widgets/WalletDetails/ui/wallets/SimpleWalletDetails.tsx rename to src/renderer/features/wallet-details/ui/wallets/SimpleWalletDetails.tsx index b7be2aca6e..37db005d17 100644 --- a/src/renderer/widgets/WalletDetails/ui/wallets/SimpleWalletDetails.tsx +++ b/src/renderer/features/wallet-details/ui/wallets/SimpleWalletDetails.tsx @@ -9,14 +9,24 @@ import { type IconNames } from '@/shared/ui/Icon/data'; import { type TabItem } from '@/shared/ui/types'; import { networkModel, networkUtils } from '@/entities/network'; import { AccountsList, WalletCardLg, accountUtils, permissionUtils, walletUtils } from '@/entities/wallet'; +import { proxyAddFeature } from '@/features/proxy-add'; +import { proxyAddPureFeature } from '@/features/proxy-add-pure'; import { ForgetWalletModal } from '@/features/wallets/ForgetWallet'; import { RenameWalletModal } from '@/features/wallets/RenameWallet'; -import { AddProxy, addProxyModel } from '@/widgets/AddProxyModal'; -import { AddPureProxied, addPureProxiedModel } from '@/widgets/AddPureProxiedModal'; -import { walletProviderModel } from '../../model/wallet-provider-model'; +import { walletDetailsModel } from '../../model/wallet-details-model'; import { NoProxiesAction } from '../components/NoProxiesAction'; import { ProxiesList } from '../components/ProxiesList'; +const { + models: { addProxy }, + views: { AddProxy }, +} = proxyAddFeature; + +const { + models: { addPureProxied }, + views: { AddPureProxied }, +} = proxyAddPureFeature; + type Props = { wallet: SingleShardWallet | WatchOnlyWallet; onClose: () => void; @@ -25,8 +35,8 @@ export const SimpleWalletDetails = ({ wallet, onClose }: Props) => { const { t } = useI18n(); const allChains = useUnit(networkModel.$chains); - const hasProxies = useUnit(walletProviderModel.$hasProxies); - const canCreateProxy = useUnit(walletProviderModel.$canCreateProxy); + const hasProxies = useUnit(walletDetailsModel.$hasProxies); + const canCreateProxy = useUnit(walletDetailsModel.$canCreateProxy); const [isModalOpen, closeModal] = useModalClose(true, onClose); const [isRenameModalOpen, toggleIsRenameModalOpen] = useToggle(); @@ -61,7 +71,7 @@ export const SimpleWalletDetails = ({ wallet, onClose }: Props) => { Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addProxyAction'), - onClick: addProxyModel.events.flowStarted, + onClick: addProxy.events.flowStarted, }); } @@ -69,7 +79,7 @@ export const SimpleWalletDetails = ({ wallet, onClose }: Props) => { Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addPureProxiedAction'), - onClick: addPureProxiedModel.events.flowStarted, + onClick: addPureProxied.events.flowStarted, }); } @@ -97,12 +107,12 @@ export const SimpleWalletDetails = ({ wallet, onClose }: Props) => { id: 'proxies', title: t('walletDetails.common.proxiesTabTitle'), panel: hasProxies ? ( - + ) : ( ), }, @@ -138,8 +148,8 @@ export const SimpleWalletDetails = ({ wallet, onClose }: Props) => { onForget={onClose} /> - - + + ); }; diff --git a/src/renderer/widgets/WalletDetails/ui/wallets/VaultWalletDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/VaultWalletDetails.tsx similarity index 90% rename from src/renderer/widgets/WalletDetails/ui/wallets/VaultWalletDetails.tsx rename to src/renderer/features/wallet-details/ui/wallets/VaultWalletDetails.tsx index 82cbc107bc..dd619fa277 100644 --- a/src/renderer/widgets/WalletDetails/ui/wallets/VaultWalletDetails.tsx +++ b/src/renderer/features/wallet-details/ui/wallets/VaultWalletDetails.tsx @@ -18,19 +18,29 @@ import { type IconNames } from '@/shared/ui/Icon/data'; import { type TabItem } from '@/shared/ui/types'; import { networkModel } from '@/entities/network'; import { RootAccountLg, VaultAccountsList, WalletCardLg, accountUtils, permissionUtils } from '@/entities/wallet'; +import { proxyAddFeature } from '@/features/proxy-add'; +import { proxyAddPureFeature } from '@/features/proxy-add-pure'; import { DerivationsAddressModal, ImportKeysModal, KeyConstructor } from '@/features/wallets'; import { ForgetWalletModal } from '@/features/wallets/ForgetWallet'; import { RenameWalletModal } from '@/features/wallets/RenameWallet'; -import { AddProxy, addProxyModel } from '@/widgets/AddProxyModal'; -import { AddPureProxied, addPureProxiedModel } from '@/widgets/AddPureProxiedModal'; import { type VaultMap } from '../../lib/types'; import { walletDetailsUtils } from '../../lib/utils'; import { vaultDetailsModel } from '../../model/vault-details-model'; -import { walletProviderModel } from '../../model/wallet-provider-model'; +import { walletDetailsModel } from '../../model/wallet-details-model'; import { NoProxiesAction } from '../components/NoProxiesAction'; import { ProxiesList } from '../components/ProxiesList'; import { ShardsList } from '../components/ShardsList'; +const { + models: { addProxy }, + views: { AddProxy }, +} = proxyAddFeature; + +const { + models: { addPureProxied }, + views: { AddPureProxied }, +} = proxyAddPureFeature; + type Props = { wallet: PolkadotVaultWallet; root: BaseAccount; @@ -41,9 +51,9 @@ export const VaultWalletDetails = ({ wallet, root, accountsMap, onClose }: Props const { t } = useI18n(); const allChains = useUnit(networkModel.$chains); - const hasProxies = useUnit(walletProviderModel.$hasProxies); + const hasProxies = useUnit(walletDetailsModel.$hasProxies); const keysToAdd = useUnit(vaultDetailsModel.$keysToAdd); - const canCreateProxy = useUnit(walletProviderModel.$canCreateProxy); + const canCreateProxy = useUnit(walletDetailsModel.$canCreateProxy); const [isModalOpen, closeModal] = useModalClose(true, onClose); @@ -135,7 +145,7 @@ export const VaultWalletDetails = ({ wallet, root, accountsMap, onClose }: Props Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addProxyAction'), - onClick: addProxyModel.events.flowStarted, + onClick: addProxy.events.flowStarted, }); } @@ -143,7 +153,7 @@ export const VaultWalletDetails = ({ wallet, root, accountsMap, onClose }: Props Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addPureProxiedAction'), - onClick: addPureProxiedModel.events.flowStarted, + onClick: addPureProxied.events.flowStarted, }); } @@ -194,12 +204,12 @@ export const VaultWalletDetails = ({ wallet, root, accountsMap, onClose }: Props id: 'proxies', title: t('walletDetails.common.proxiesTabTitle'), panel: hasProxies ? ( - + ) : ( ), }, @@ -255,8 +265,8 @@ export const VaultWalletDetails = ({ wallet, root, accountsMap, onClose }: Props onForget={onClose} /> - - + + ); }; diff --git a/src/renderer/widgets/WalletDetails/ui/wallets/WalletConnectDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/WalletConnectDetails.tsx similarity index 88% rename from src/renderer/widgets/WalletDetails/ui/wallets/WalletConnectDetails.tsx rename to src/renderer/features/wallet-details/ui/wallets/WalletConnectDetails.tsx index 41fdcac172..a69775ae4b 100644 --- a/src/renderer/widgets/WalletDetails/ui/wallets/WalletConnectDetails.tsx +++ b/src/renderer/features/wallet-details/ui/wallets/WalletConnectDetails.tsx @@ -1,4 +1,4 @@ -import { useUnit } from 'effector-react'; +import { useGate, useUnit } from 'effector-react'; import { useEffect } from 'react'; import { chainsService } from '@/shared/api/network'; @@ -20,29 +20,40 @@ import { type IconNames } from '@/shared/ui/Icon/data'; import { type TabItem } from '@/shared/ui/types'; import { WalletCardLg, permissionUtils } from '@/entities/wallet'; import { walletConnectUtils } from '@/entities/walletConnect'; +import { proxyAddFeature } from '@/features/proxy-add'; +import { proxyAddPureFeature } from '@/features/proxy-add-pure'; import { forgetWalletModel } from '@/features/wallets/ForgetWallet'; import { RenameWalletModal } from '@/features/wallets/RenameWallet'; -import { AddProxy, addProxyModel } from '@/widgets/AddProxyModal'; -import { AddPureProxied, addPureProxiedModel } from '@/widgets/AddPureProxiedModal'; import { ForgetStep } from '../../lib/constants'; import { walletDetailsUtils, wcDetailsUtils } from '../../lib/utils'; -import { walletProviderModel } from '../../model/wallet-provider-model'; +import { walletDetailsModel } from '../../model/wallet-details-model'; import { wcDetailsModel } from '../../model/wc-details-model'; import { NoProxiesAction } from '../components/NoProxiesAction'; import { ProxiesList } from '../components/ProxiesList'; import { WalletConnectAccounts } from '../components/WalletConnectAccounts'; +const { + models: { addProxy }, + views: { AddProxy }, +} = proxyAddFeature; + +const { + models: { addPureProxied }, + views: { AddPureProxied }, +} = proxyAddPureFeature; + type Props = { wallet: WalletConnectGroup; onClose: () => void; }; export const WalletConnectDetails = ({ wallet, onClose }: Props) => { + useGate(wcDetailsModel.walletConnectDetailsFlow, { wallet }); const { t } = useI18n(); - const hasProxies = useUnit(walletProviderModel.$hasProxies); + const hasProxies = useUnit(walletDetailsModel.$hasProxies); const forgetStep = useUnit(wcDetailsModel.$forgetStep); const reconnectStep = useUnit(wcDetailsModel.$reconnectStep); - const canCreateProxy = useUnit(walletProviderModel.$canCreateProxy); + const canCreateProxy = useUnit(walletDetailsModel.$canCreateProxy); const [isModalOpen, closeModal] = useModalClose(true, onClose); const [isConfirmForgetOpen, toggleConfirmForget] = useToggle(); @@ -88,7 +99,7 @@ export const WalletConnectDetails = ({ wallet, onClose }: Props) => { Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addProxyAction'), - onClick: addProxyModel.events.flowStarted, + onClick: addProxy.events.flowStarted, }); } @@ -96,7 +107,7 @@ export const WalletConnectDetails = ({ wallet, onClose }: Props) => { Options.push({ icon: 'addCircle' as IconNames, title: t('walletDetails.common.addPureProxiedAction'), - onClick: addPureProxiedModel.events.flowStarted, + onClick: addPureProxied.events.flowStarted, }); } @@ -122,12 +133,12 @@ export const WalletConnectDetails = ({ wallet, onClose }: Props) => { id: 'proxies', title: t('walletDetails.common.proxiesTabTitle'), panel: hasProxies ? ( - + ) : ( ), }, @@ -222,8 +233,8 @@ export const WalletConnectDetails = ({ wallet, onClose }: Props) => { - - + + ); }; diff --git a/src/renderer/features/wallet-fiat-balance/components/WalletFiatBalance.tsx b/src/renderer/features/wallet-fiat-balance/components/WalletFiatBalance.tsx new file mode 100644 index 0000000000..f312d6be7a --- /dev/null +++ b/src/renderer/features/wallet-fiat-balance/components/WalletFiatBalance.tsx @@ -0,0 +1,41 @@ +import { default as BigNumber } from 'bignumber.js'; +import { useUnit } from 'effector-react'; + +import { type ID } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { formatFiatBalance } from '@/shared/lib/utils'; +import { Skeleton } from '@/shared/ui-kit'; +import { FiatBalance, priceProviderModel } from '@/entities/price'; +import { walletModel } from '@/entities/wallet'; +import { walletFiatBalanceModel } from '../model/fiatBalance'; + +BigNumber.config({ + ROUNDING_MODE: BigNumber.ROUND_DOWN, +}); + +type Props = { + walletId: ID; + className?: string; +}; + +export const WalletFiatBalance = ({ walletId, className }: Props) => { + const { t } = useI18n(); + + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + const walletBalances = useUnit(walletFiatBalanceModel.$activeWalletBalance); + const activeWallet = useUnit(walletModel.$activeWallet); + + if (!fiatFlag || walletId !== activeWallet?.id) { + return null; + } + + if (!walletBalances) { + return ; + } + + const { value: formattedValue, suffix } = formatFiatBalance(walletBalances.toString()); + + const balanceValue = t('assetBalance.number', { value: formattedValue }); + + return ; +}; diff --git a/src/renderer/features/wallet-fiat-balance/index.tsx b/src/renderer/features/wallet-fiat-balance/index.tsx new file mode 100644 index 0000000000..82e6e8089b --- /dev/null +++ b/src/renderer/features/wallet-fiat-balance/index.tsx @@ -0,0 +1,9 @@ +import { WalletFiatBalance } from './components/WalletFiatBalance'; +import { walletsFiatBalanceFeatureStatus } from './model/feature'; + +export const walletsFiatBalanceFeature = { + feature: walletsFiatBalanceFeatureStatus, + views: { + WalletFiatBalance, + }, +}; diff --git a/src/renderer/features/wallet-fiat-balance/model/feature.tsx b/src/renderer/features/wallet-fiat-balance/model/feature.tsx new file mode 100644 index 0000000000..ce9bcfa3b8 --- /dev/null +++ b/src/renderer/features/wallet-fiat-balance/model/feature.tsx @@ -0,0 +1,5 @@ +import { createFeature } from '@/shared/effector'; + +export const walletsFiatBalanceFeatureStatus = createFeature({ + name: 'wallet/fiat balance', +}); diff --git a/src/renderer/features/wallet-fiat-balance/model/fiatBalance.ts b/src/renderer/features/wallet-fiat-balance/model/fiatBalance.ts new file mode 100644 index 0000000000..e1a166ad90 --- /dev/null +++ b/src/renderer/features/wallet-fiat-balance/model/fiatBalance.ts @@ -0,0 +1,51 @@ +import { default as BigNumber } from 'bignumber.js'; +import { combine } from 'effector'; + +import { dictionary, getRoundedValue, nullable, totalAmount } from '@/shared/lib/utils'; +import { balanceModel } from '@/entities/balance'; +import { networkModel } from '@/entities/network'; +import { currencyModel, priceProviderModel } from '@/entities/price'; +import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; + +const $activeWalletBalance = combine( + { + wallet: walletModel.$activeWallet, + chains: networkModel.$chains, + balances: balanceModel.$balances, + currency: currencyModel.$activeCurrency, + prices: priceProviderModel.$assetsPrices, + }, + (params) => { + const { wallet, chains, balances, prices, currency } = params; + + if (nullable(currency?.coingeckoId) || nullable(wallet) || nullable(prices) || balances.length === 0) { + return new BigNumber(0); + } + + const isPolkadotVault = walletUtils.isPolkadotVault(wallet); + + const accountMap = dictionary(wallet.accounts, 'accountId'); + + return balances.reduce((acc, balance) => { + const account = accountMap[balance.accountId]; + const chain = chains[balance.chainId]; + if (nullable(account) || nullable(chain)) return acc; + if (accountUtils.isBaseAccount(account) && isPolkadotVault) return acc; + + const asset = chain.assets.find((asset) => asset.assetId.toString() === balance.assetId); + if (nullable(asset?.priceId)) return acc; + const pricesMap = prices[asset.priceId]; + if (nullable(pricesMap)) return acc; + const price = pricesMap[currency.coingeckoId]; + if (nullable(price)) return acc; + + const fiatBalance = getRoundedValue(totalAmount(balance), price.price, asset.precision); + + return acc.plus(new BigNumber(fiatBalance)); + }, new BigNumber(0)); + }, +); + +export const walletFiatBalanceModel = { + $activeWalletBalance, +}; diff --git a/src/renderer/features/wallets/WalletSelect/lib/__tests__/wallet-select-utils.test.ts b/src/renderer/features/wallet-fiat-balance/service/__tests__/walletSelectService.test.ts similarity index 73% rename from src/renderer/features/wallets/WalletSelect/lib/__tests__/wallet-select-utils.test.ts rename to src/renderer/features/wallet-fiat-balance/service/__tests__/walletSelectService.test.ts index ebf60ba6c7..3eebb91e8b 100644 --- a/src/renderer/features/wallets/WalletSelect/lib/__tests__/wallet-select-utils.test.ts +++ b/src/renderer/features/wallet-fiat-balance/service/__tests__/walletSelectService.test.ts @@ -1,7 +1,7 @@ import { type Wallet, WalletType } from '@/shared/core'; -import { walletSelectUtils } from '../wallet-select-utils'; +import { walletSelectService } from '../walletSelectService'; -describe('features/wallets/WalletSelect/lib/wallet-select-utils', () => { +describe('walletSelectService', () => { const wallets = [ { id: 1, type: WalletType.POLKADOT_VAULT, name: 'pv' }, { id: 2, type: WalletType.WALLET_CONNECT, name: 'wc' }, @@ -9,14 +9,14 @@ describe('features/wallets/WalletSelect/lib/wallet-select-utils', () => { ] as Wallet[]; test('should group wallets POLKADOT_VAULT > MULTISIG > NOVA_WALLET > WALLET_CONNECT > WATCH_ONLY > PROXIES', () => { - const groups = walletSelectUtils.getWalletByGroups(wallets); + const groups = walletSelectService.getWalletByGroups(wallets); const groupedWallets = Object.values(groups).flat(); expect(groupedWallets).toEqual([wallets[0], wallets[2], wallets[1]]); }); test('should group wallets with respect to query', () => { - const groups = walletSelectUtils.getWalletByGroups(wallets, 'p'); + const groups = walletSelectService.getWalletByGroups(wallets, 'p'); const groupedWallets = Object.values(groups).flat(); expect(groupedWallets).toEqual([wallets[0], wallets[2]]); diff --git a/src/renderer/features/wallets/WalletSelect/lib/wallet-select-utils.ts b/src/renderer/features/wallet-fiat-balance/service/walletSelectService.ts similarity index 84% rename from src/renderer/features/wallets/WalletSelect/lib/wallet-select-utils.ts rename to src/renderer/features/wallet-fiat-balance/service/walletSelectService.ts index 92bcae2097..223bff4c49 100644 --- a/src/renderer/features/wallets/WalletSelect/lib/wallet-select-utils.ts +++ b/src/renderer/features/wallet-fiat-balance/service/walletSelectService.ts @@ -6,6 +6,7 @@ const getWalletByGroups = (wallets: Wallet[], query = ''): Record = { [WalletType.POLKADOT_VAULT]: [], [WalletType.MULTISIG]: [], + [WalletType.FLEXIBLE_MULTISIG]: [], [WalletType.NOVA_WALLET]: [], [WalletType.WALLET_CONNECT]: [], [WalletType.WATCH_ONLY]: [], @@ -16,7 +17,8 @@ const getWalletByGroups = (wallets: Wallet[], query = ''): Record { return Object.values(getWalletByGroups(wallets)).flat().at(0) ?? null; }; -export const walletSelectUtils = { +export const walletSelectService = { getWalletByGroups, getFirstWallet, }; diff --git a/src/renderer/features/wallets/WalletSelect/ui/ProxiedTooltip.tsx b/src/renderer/features/wallet-select/components/ProxiedTooltip.tsx similarity index 100% rename from src/renderer/features/wallets/WalletSelect/ui/ProxiedTooltip.tsx rename to src/renderer/features/wallet-select/components/ProxiedTooltip.tsx diff --git a/src/renderer/features/wallets/WalletSelect/ui/WalletGroup.tsx b/src/renderer/features/wallet-select/components/WalletGroup.tsx similarity index 77% rename from src/renderer/features/wallets/WalletSelect/ui/WalletGroup.tsx rename to src/renderer/features/wallet-select/components/WalletGroup.tsx index 38d8c56d8a..27f7a029bc 100644 --- a/src/renderer/features/wallets/WalletSelect/ui/WalletGroup.tsx +++ b/src/renderer/features/wallet-select/components/WalletGroup.tsx @@ -1,15 +1,20 @@ import { type Wallet, type WalletFamily, WalletType } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { Accordion, CaptionText, Icon } from '@/shared/ui'; +import { Accordion, CaptionText, Icon, IconButton } from '@/shared/ui'; import { WalletCardMd, WalletIcon, walletUtils } from '@/entities/wallet'; +import { walletsFiatBalanceFeature } from '@/features/wallet-fiat-balance'; import { walletSelectModel } from '../model/wallet-select-model'; import { ProxiedTooltip } from './ProxiedTooltip'; -import { WalletFiatBalance } from './WalletFiatBalance'; -export const GroupLabels: Record = { +const { + views: { WalletFiatBalance }, +} = walletsFiatBalanceFeature; + +export const GROUP_LABELS: Record = { [WalletType.POLKADOT_VAULT]: 'wallets.paritySignerLabel', [WalletType.MULTISIG]: 'wallets.multisigLabel', + [WalletType.FLEXIBLE_MULTISIG]: 'wallets.flexibleMultisigLabel', [WalletType.WALLET_CONNECT]: 'wallets.walletConnectLabel', [WalletType.NOVA_WALLET]: 'wallets.novaWalletLabel', [WalletType.WATCH_ONLY]: 'wallets.watchOnlyLabel', @@ -19,9 +24,10 @@ export const GroupLabels: Record = { type Props = { type: WalletFamily; wallets: Wallet[]; + onInfoClick: (wallet: Wallet) => void; }; -export const WalletGroup = ({ type, wallets }: Props) => { +export const WalletGroup = ({ type, wallets, onInfoClick }: Props) => { const { t } = useI18n(); return ( @@ -30,7 +36,7 @@ export const WalletGroup = ({ type, wallets }: Props) => {
    - {t(GroupLabels[type as WalletFamily])} + {t(GROUP_LABELS[type as WalletFamily])} {wallets.length} {walletUtils.isProxied(wallets[0]) && } @@ -54,8 +60,9 @@ export const WalletGroup = ({ type, wallets }: Props) => { ) } onClick={() => walletSelectModel.events.walletSelected(wallet.id)} - onInfoClick={() => walletSelectModel.events.walletIdSet(wallet.id)} - /> + > + onInfoClick(wallet)} /> + ))} diff --git a/src/renderer/features/wallet-select/components/WalletSelect.tsx b/src/renderer/features/wallet-select/components/WalletSelect.tsx new file mode 100644 index 0000000000..3afee681eb --- /dev/null +++ b/src/renderer/features/wallet-select/components/WalletSelect.tsx @@ -0,0 +1,100 @@ +import { useUnit } from 'effector-react'; +import { type ReactNode, useEffect, useState } from 'react'; + +import { type Wallet, type WalletFamily } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { Icon, SmallTitleText } from '@/shared/ui'; +import { Box, Popover, SearchInput, Skeleton } from '@/shared/ui-kit'; +import { WalletCardLg, walletModel } from '@/entities/wallet'; +import { walletDetailsFeature } from '@/features/wallet-details'; +import { walletsFiatBalanceFeature } from '@/features/wallet-fiat-balance'; +import { walletSelectModel } from '../model/wallet-select-model'; + +import { WalletGroup } from './WalletGroup'; + +const { + views: { WalletDetails }, +} = walletDetailsFeature; + +const { + views: { WalletFiatBalance }, +} = walletsFiatBalanceFeature; + +type Props = { + action?: ReactNode; +}; + +export const WalletSelect = ({ action }: Props) => { + const { t } = useI18n(); + + const activeWallet = useUnit(walletModel.$activeWallet); + const filterQuery = useUnit(walletSelectModel.$filterQuery); + const filteredWalletGroups = useUnit(walletSelectModel.$filteredWalletGroups); + + const [selectedWallet, setSelectedWallet] = useState(null); + + useEffect(() => { + // TODO: WTF + walletSelectModel.events.callbacksChanged({ onClose: walletSelectModel.events.clearData }); + }, []); + + if (!activeWallet) { + return ; + } + + return ( + <> + + + + + + +
    +
    + {t('wallets.title')} +
    {action}
    +
    + +
    + +
    + +
    + {Object.entries(filteredWalletGroups).map(([walletType, wallets]) => { + if (wallets.length === 0) { + return null; + } + + return ( + + ); + })} +
    +
    +
    +
    + setSelectedWallet(null)} /> + + ); +}; diff --git a/src/renderer/features/wallet-select/index.ts b/src/renderer/features/wallet-select/index.ts new file mode 100644 index 0000000000..99bcdc92eb --- /dev/null +++ b/src/renderer/features/wallet-select/index.ts @@ -0,0 +1,18 @@ +import { GROUP_LABELS, WalletGroup } from './components/WalletGroup'; +import { walletsSelectFeatureStatus } from './model/feature'; +import { walletSelectModel } from './model/wallet-select-model'; +import { walletSelectService } from './service/walletSelectService'; + +export const walletSelectFeature = { + feature: walletsSelectFeatureStatus, + services: { + walletSelect: walletSelectService, + }, + selectModel: walletSelectModel, + constants: { + GROUP_LABELS, + }, + views: { + WalletGroup, + }, +}; diff --git a/src/renderer/features/wallets/WalletSelect/model/__tests__/wallet-select-model.test.ts b/src/renderer/features/wallet-select/model/__tests__/wallet-select-model.test.ts similarity index 90% rename from src/renderer/features/wallets/WalletSelect/model/__tests__/wallet-select-model.test.ts rename to src/renderer/features/wallet-select/model/__tests__/wallet-select-model.test.ts index 0ae652c187..9fb8aa755f 100644 --- a/src/renderer/features/wallets/WalletSelect/model/__tests__/wallet-select-model.test.ts +++ b/src/renderer/features/wallet-select/model/__tests__/wallet-select-model.test.ts @@ -5,7 +5,7 @@ import { SigningType, type Wallet, type WalletFamily, WalletType } from '@/share import { walletModel } from '@/entities/wallet'; import { walletSelectModel } from '../wallet-select-model'; -describe('features/wallets/WalletSelect/model/wallet-select-model', () => { +describe('wallet-select-model', () => { const wallets: Wallet[] = [ { id: 1, @@ -42,6 +42,7 @@ describe('features/wallets/WalletSelect/model/wallet-select-model', () => { const emptyGroups: Record = { [WalletType.POLKADOT_VAULT]: [], [WalletType.MULTISIG]: [], + [WalletType.FLEXIBLE_MULTISIG]: [], [WalletType.NOVA_WALLET]: [], [WalletType.WALLET_CONNECT]: [], [WalletType.WATCH_ONLY]: [], @@ -64,16 +65,6 @@ describe('features/wallets/WalletSelect/model/wallet-select-model', () => { }); }); - test('should set $walletForDetails on walletIdSet', async () => { - const scope = fork({ - values: new Map().set(walletModel._test.$allWallets, wallets), - }); - - expect(scope.getState(walletSelectModel.$walletForDetails)).toEqual(undefined); - await allSettled(walletSelectModel.events.walletIdSet, { scope, params: 2 }); - expect(scope.getState(walletSelectModel.$walletForDetails)).toEqual(wallets[1]); - }); - test('should change $activeWallet on walletSelected', async () => { jest.spyOn(storageService.wallets, 'readAll').mockResolvedValue(wallets); jest.spyOn(storageService.wallets, 'updateAll').mockResolvedValue([1]); diff --git a/src/renderer/features/wallet-select/model/feature.tsx b/src/renderer/features/wallet-select/model/feature.tsx new file mode 100644 index 0000000000..cac2bbbc73 --- /dev/null +++ b/src/renderer/features/wallet-select/model/feature.tsx @@ -0,0 +1,12 @@ +import { createFeature } from '@/shared/effector'; +import { navigationHeaderSlot } from '@/features/app-shell'; +import { SelectWalletPairing } from '@/features/wallets/SelectWalletPairing'; +import { WalletSelect } from '../components/WalletSelect'; + +export const walletsSelectFeatureStatus = createFeature({ + name: 'wallet/select', +}); + +walletsSelectFeatureStatus.inject(navigationHeaderSlot, () => { + return } />; +}); diff --git a/src/renderer/features/wallets/WalletSelect/model/wallet-select-model.ts b/src/renderer/features/wallet-select/model/wallet-select-model.ts similarity index 76% rename from src/renderer/features/wallets/WalletSelect/model/wallet-select-model.ts rename to src/renderer/features/wallet-select/model/wallet-select-model.ts index d9f094c6b7..ff6127c449 100644 --- a/src/renderer/features/wallets/WalletSelect/model/wallet-select-model.ts +++ b/src/renderer/features/wallet-select/model/wallet-select-model.ts @@ -1,20 +1,19 @@ import { default as BigNumber } from 'bignumber.js'; -import { attach, combine, createApi, createEvent, createStore, sample } from 'effector'; +import { attach, combine, createApi, createEvent, createStore, restore, sample } from 'effector'; import { once } from 'patronum'; -import { type Account, type ID, type Wallet } from '@/shared/core'; +import { type Account } from '@/shared/core'; import { dictionary, getRoundedValue, nonNullable, totalAmount } from '@/shared/lib/utils'; import { balanceModel } from '@/entities/balance'; import { networkModel } from '@/entities/network'; import { currencyModel, priceProviderModel } from '@/entities/price'; import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; -import { walletSelectUtils } from '../lib/wallet-select-utils'; +import { walletSelectService } from '../service/walletSelectService'; export type Callbacks = { onClose: () => void; }; -const walletIdSet = createEvent(); const queryChanged = createEvent(); const $callbacks = createStore(null); @@ -22,21 +21,7 @@ const callbacksApi = createApi($callbacks, { callbacksChanged: (state, props: Callbacks) => ({ ...state, ...props }), }); -const $walletId = createStore(null); -const $filterQuery = createStore(''); - -const $walletForDetails = combine( - { - walletId: $walletId, - wallets: walletModel.$wallets, - }, - ({ wallets, walletId }): Wallet | undefined => { - if (!walletId) return; - - return walletUtils.getWalletById(wallets, walletId); - }, - { skipVoid: false }, -); +const $filterQuery = restore(queryChanged, ''); const $filteredWalletGroups = combine( { @@ -44,7 +29,7 @@ const $filteredWalletGroups = combine( wallets: walletModel.$wallets, }, ({ wallets, query }) => { - return walletSelectUtils.getWalletByGroups(wallets, query); + return walletSelectService.getWalletByGroups(wallets, query); }, ); @@ -56,7 +41,7 @@ const $walletBalance = combine( currency: currencyModel.$activeCurrency, prices: priceProviderModel.$assetsPrices, }, - (params): BigNumber => { + (params) => { const { wallet, chains, balances, prices, currency } = params; if (!wallet || !prices || !balances || !currency?.coingeckoId) return new BigNumber(0); @@ -84,14 +69,15 @@ const $walletBalance = combine( }, ); -sample({ clock: queryChanged, target: $filterQuery }); - -sample({ clock: walletIdSet, target: $walletId }); +sample({ + clock: queryChanged, + target: $filterQuery, +}); const select = sample({ clock: walletModel.$wallets, filter: (wallets) => wallets.every((wallet) => !wallet.isActive), - fn: (wallets) => walletSelectUtils.getFirstWallet(wallets)?.id ?? null, + fn: (wallets) => walletSelectService.getFirstWallet(wallets)?.id ?? null, }); sample({ @@ -126,7 +112,7 @@ sample({ clock: once(walletModel.$wallets), filter: (wallets) => wallets.length > 0 && wallets.every((wallet) => !wallet.isActive), fn: (wallets) => { - const groups = walletSelectUtils.getWalletByGroups(wallets); + const groups = walletSelectService.getWalletByGroups(wallets); return Object.values(groups).flat()[0].id; }, @@ -134,16 +120,14 @@ sample({ }); export const walletSelectModel = { + $filterQuery, $filteredWalletGroups, $walletBalance, - $walletForDetails, events: { walletSelected: walletModel.events.selectWallet, - walletIdSet, queryChanged, clearData: $filterQuery.reinit, - walletIdCleared: $walletId.reinit, callbacksChanged: callbacksApi.callbacksChanged, }, }; diff --git a/src/renderer/features/wallet-select/service/__tests__/walletSelectService.test.ts b/src/renderer/features/wallet-select/service/__tests__/walletSelectService.test.ts new file mode 100644 index 0000000000..3eebb91e8b --- /dev/null +++ b/src/renderer/features/wallet-select/service/__tests__/walletSelectService.test.ts @@ -0,0 +1,24 @@ +import { type Wallet, WalletType } from '@/shared/core'; +import { walletSelectService } from '../walletSelectService'; + +describe('walletSelectService', () => { + const wallets = [ + { id: 1, type: WalletType.POLKADOT_VAULT, name: 'pv' }, + { id: 2, type: WalletType.WALLET_CONNECT, name: 'wc' }, + { id: 3, type: WalletType.SINGLE_PARITY_SIGNER, name: 'sps' }, + ] as Wallet[]; + + test('should group wallets POLKADOT_VAULT > MULTISIG > NOVA_WALLET > WALLET_CONNECT > WATCH_ONLY > PROXIES', () => { + const groups = walletSelectService.getWalletByGroups(wallets); + const groupedWallets = Object.values(groups).flat(); + + expect(groupedWallets).toEqual([wallets[0], wallets[2], wallets[1]]); + }); + + test('should group wallets with respect to query', () => { + const groups = walletSelectService.getWalletByGroups(wallets, 'p'); + const groupedWallets = Object.values(groups).flat(); + + expect(groupedWallets).toEqual([wallets[0], wallets[2]]); + }); +}); diff --git a/src/renderer/features/wallet-select/service/walletSelectService.ts b/src/renderer/features/wallet-select/service/walletSelectService.ts new file mode 100644 index 0000000000..dbbef4cdc3 --- /dev/null +++ b/src/renderer/features/wallet-select/service/walletSelectService.ts @@ -0,0 +1,42 @@ +import { type Wallet, type WalletFamily, WalletType } from '@/shared/core'; +import { includes } from '@/shared/lib/utils'; +import { walletUtils } from '@/entities/wallet'; + +const getWalletByGroups = (wallets: Wallet[], query = ''): Record => { + const accumulator: Record = { + [WalletType.POLKADOT_VAULT]: [], + [WalletType.MULTISIG]: [], + [WalletType.FLEXIBLE_MULTISIG]: [], + [WalletType.NOVA_WALLET]: [], + [WalletType.WALLET_CONNECT]: [], + [WalletType.WATCH_ONLY]: [], + [WalletType.PROXIED]: [], + }; + + return wallets.reduce>((acc, wallet) => { + let groupIndex: WalletFamily | undefined; + + if (walletUtils.isPolkadotVaultGroup(wallet)) groupIndex = WalletType.POLKADOT_VAULT; + if (walletUtils.isRegularMultisig(wallet)) groupIndex = WalletType.MULTISIG; + if (walletUtils.isFlexibleMultisig(wallet)) groupIndex = WalletType.FLEXIBLE_MULTISIG; + if (walletUtils.isWatchOnly(wallet)) groupIndex = WalletType.WATCH_ONLY; + if (walletUtils.isWalletConnect(wallet)) groupIndex = WalletType.WALLET_CONNECT; + if (walletUtils.isNovaWallet(wallet)) groupIndex = WalletType.NOVA_WALLET; + if (walletUtils.isProxied(wallet)) groupIndex = WalletType.PROXIED; + + if (groupIndex && includes(wallet.name, query)) { + acc[groupIndex].push(wallet); + } + + return acc; + }, accumulator); +}; + +const getFirstWallet = (wallets: Wallet[]) => { + return getWalletByGroups(wallets)[WalletType.POLKADOT_VAULT].at(0) ?? null; +}; + +export const walletSelectService = { + getWalletByGroups, + getFirstWallet, +}; diff --git a/src/renderer/features/wallets-select/index.tsx b/src/renderer/features/wallets-select/index.tsx deleted file mode 100644 index 08f779cfde..0000000000 --- a/src/renderer/features/wallets-select/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createStore } from 'effector'; - -import { createFeature } from '@/shared/effector'; -import { navigationHeaderSlot } from '@/features/app-shell'; -import { SelectWalletPairing, WalletSelect } from '@/features/wallets'; - -export const walletsSelectFeature = createFeature({ - name: 'Wallets select', - enable: createStore(true), -}); - -walletsSelectFeature.inject(navigationHeaderSlot, () => { - return } />; -}); diff --git a/src/renderer/features/wallets/ForgetWallet/model/__tests__/forget-wallet-model.test.ts b/src/renderer/features/wallets/ForgetWallet/model/__tests__/forget-wallet-model.test.ts index a93e1604e7..25746a62a5 100644 --- a/src/renderer/features/wallets/ForgetWallet/model/__tests__/forget-wallet-model.test.ts +++ b/src/renderer/features/wallets/ForgetWallet/model/__tests__/forget-wallet-model.test.ts @@ -6,7 +6,6 @@ import { type BaseAccount, ChainType, CryptoType, - ProxyType, ProxyVariant, SigningType, type Wallet, @@ -75,7 +74,7 @@ const proxiedWallet = { proxyAccountId: '0x00', chainId: TEST_CHAIN_ID, delay: 0, - proxyType: ProxyType.ANY, + proxyType: 'Any', proxyVariant: ProxyVariant.REGULAR, walletId: 2, name: 'proxied', @@ -138,7 +137,7 @@ describe('features/wallets/ForgetModel', () => { accountId: '0x00', proxiedAccountId: '0x01', chainId: TEST_CHAIN_ID, - proxyType: ProxyType.ANY, + proxyType: 'Any', delay: 0, }, ], diff --git a/src/renderer/features/wallets/ImportKeys/ui/ImportKeysModal.tsx b/src/renderer/features/wallets/ImportKeys/ui/ImportKeysModal.tsx index 812cafcc96..d97dc73b29 100644 --- a/src/renderer/features/wallets/ImportKeys/ui/ImportKeysModal.tsx +++ b/src/renderer/features/wallets/ImportKeys/ui/ImportKeysModal.tsx @@ -3,8 +3,9 @@ import { useEffect } from 'react'; import { type AccountId, type ChainAccount, type DraftAccount, type ShardAccount } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { cnTw } from '@/shared/lib/utils'; -import { Alert, BaseModal, Button, InfoLink, InputFile, InputHint } from '@/shared/ui'; +import { nonNullable } from '@/shared/lib/utils'; +import { Alert, BaseModal, Button, InfoLink, InputHint } from '@/shared/ui'; +import { InputFile } from '@/shared/ui-kit'; import { TEMPLATE_GITHUB_LINK } from '../lib/constants'; import { importKeysUtils } from '../lib/import-keys-utils'; import { importKeysModel } from '../model/import-keys-model'; @@ -58,21 +59,24 @@ export const ImportKeysModal = ({ isOpen, rootAccountId, existingKeys, onConfirm return ( -
    - +
    +
    +
    + +
    - - {validationError && importKeysUtils.getErrorsText(t, validationError.error, validationError.details)} - + + {validationError && importKeysUtils.getErrorsText(t, validationError.error, validationError.details)} + +
    @@ -84,7 +88,7 @@ export const ImportKeysModal = ({ isOpen, rootAccountId, existingKeys, onConfirm ))} - + {t('dynamicDerivations.importKeys.downloadTemplateButton')}
    diff --git a/src/renderer/features/wallets/KeyConstructor/ui/KeyForm.tsx b/src/renderer/features/wallets/KeyConstructor/ui/KeyForm.tsx index 48d602e047..d03a678d7e 100644 --- a/src/renderer/features/wallets/KeyConstructor/ui/KeyForm.tsx +++ b/src/renderer/features/wallets/KeyConstructor/ui/KeyForm.tsx @@ -4,8 +4,8 @@ import { type FormEvent, useEffect, useMemo, useRef } from 'react'; import { KeyType } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { Button, FootnoteText, Input, InputHint, Select } from '@/shared/ui'; -import { Checkbox } from '@/shared/ui-kit'; +import { Button, FootnoteText, InputHint, Select } from '@/shared/ui'; +import { Box, Checkbox, Field, Input } from '@/shared/ui-kit'; import { ChainTitle } from '@/entities/chain'; import { networkModel } from '@/entities/network'; import { constructorModel } from '../model/constructor-model'; @@ -120,15 +120,18 @@ export const KeyForm = () => {
    - + + + + + {t(shards?.errorText())} @@ -136,28 +139,34 @@ export const KeyForm = () => {
    - + + + + + {t(keyName?.errorText())}
    - + + + + + {t(derivationPath?.errorText())} diff --git a/src/renderer/features/wallets/RenameWallet/ui/RenameWalletModal.tsx b/src/renderer/features/wallets/RenameWallet/ui/RenameWalletModal.tsx index f227c91b6c..935cf3cb0c 100644 --- a/src/renderer/features/wallets/RenameWallet/ui/RenameWalletModal.tsx +++ b/src/renderer/features/wallets/RenameWallet/ui/RenameWalletModal.tsx @@ -3,7 +3,8 @@ import { type FormEvent, useEffect } from 'react'; import { type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { BaseModal, Button, Input, InputHint } from '@/shared/ui'; +import { BaseModal, Button, InputHint } from '@/shared/ui'; +import { Field, Input } from '@/shared/ui-kit'; import { renameWalletModel } from '../model/rename-wallet-model'; type Props = { @@ -37,20 +38,12 @@ export const RenameWalletModal = ({ wallet, isOpen, onClose }: Props) => { return ( -
    - + + {t(name.errorText())} -
    + - - ); -}; diff --git a/src/renderer/features/wallets/WalletSelect/ui/WalletPanel.tsx b/src/renderer/features/wallets/WalletSelect/ui/WalletPanel.tsx deleted file mode 100644 index 31847dc967..0000000000 --- a/src/renderer/features/wallets/WalletSelect/ui/WalletPanel.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useUnit } from 'effector-react'; -import { type ReactNode, useEffect } from 'react'; - -import { type WalletFamily } from '@/shared/core'; -import { useI18n } from '@/shared/i18n'; -import { SearchInput, SmallTitleText } from '@/shared/ui'; -import { Popover } from '@/shared/ui-kit'; -import { type Callbacks, walletSelectModel } from '../model/wallet-select-model'; - -import { WalletGroup } from './WalletGroup'; - -type Props = Callbacks & { - action?: ReactNode; -}; -export const WalletPanel = ({ action, onClose }: Props) => { - const { t } = useI18n(); - - const filteredWalletGroups = useUnit(walletSelectModel.$filteredWalletGroups); - - useEffect(() => { - walletSelectModel.events.callbacksChanged({ onClose }); - }, [onClose]); - - return ( - -
    -
    - {t('wallets.title')} -
    {action}
    -
    - -
    - -
    - -
    - {Object.entries(filteredWalletGroups).map(([walletType, wallets]) => { - if (wallets.length === 0) { - return null; - } - - return ; - })} -
    -
    -
    - ); -}; diff --git a/src/renderer/features/wallets/WalletSelect/ui/WalletSelect.tsx b/src/renderer/features/wallets/WalletSelect/ui/WalletSelect.tsx deleted file mode 100644 index e5841368e3..0000000000 --- a/src/renderer/features/wallets/WalletSelect/ui/WalletSelect.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useUnit } from 'effector-react'; -import { type ReactNode } from 'react'; - -import { Popover, Skeleton } from '@/shared/ui-kit'; -import { walletModel } from '@/entities/wallet'; -import { walletSelectModel } from '../model/wallet-select-model'; - -import { WalletButton } from './WalletButton'; -import { WalletPanel } from './WalletPanel'; - -type Props = { - action?: ReactNode; -}; -export const WalletSelect = ({ action }: Props) => { - const activeWallet = useUnit(walletModel.$activeWallet); - - if (!activeWallet) { - return ; - } - - return ( - - - - - ); -}; diff --git a/src/renderer/features/wallets/index.ts b/src/renderer/features/wallets/index.ts index a67afdf1d8..6510527729 100644 --- a/src/renderer/features/wallets/index.ts +++ b/src/renderer/features/wallets/index.ts @@ -1,5 +1,4 @@ export { KeyConstructor } from './KeyConstructor'; -export { WalletSelect, walletSelectModel } from './WalletSelect'; export { SelectWalletPairing, walletPairingModel } from './SelectWalletPairing'; export { ShardSelectorModal, ShardSelectorButton } from './ShardSelectorModal'; export { DerivationsAddressModal } from './DerivationsAddressModal/ui/DerivationsAddressModal'; diff --git a/src/renderer/pages/AddressBook/Contacts/Contacts.tsx b/src/renderer/pages/AddressBook/Contacts/Contacts.tsx index 5313eda1b5..97624beba5 100644 --- a/src/renderer/pages/AddressBook/Contacts/Contacts.tsx +++ b/src/renderer/pages/AddressBook/Contacts/Contacts.tsx @@ -18,7 +18,7 @@ export const Contacts = () => { <>
    -
    +
    diff --git a/src/renderer/pages/Assets/Assets/Assets.tsx b/src/renderer/pages/Assets/Assets/Assets.tsx index 52b8c70f40..d591bc7002 100644 --- a/src/renderer/pages/Assets/Assets/Assets.tsx +++ b/src/renderer/pages/Assets/Assets/Assets.tsx @@ -28,7 +28,7 @@ export const Assets = () => { <>
    -
    +
    diff --git a/src/renderer/pages/Fellowship/ui/Fellowship.tsx b/src/renderer/pages/Fellowship/ui/Fellowship.tsx index 75f5895b1a..2be7559b8f 100644 --- a/src/renderer/pages/Fellowship/ui/Fellowship.tsx +++ b/src/renderer/pages/Fellowship/ui/Fellowship.tsx @@ -43,7 +43,9 @@ export const Fellowship = () => { return (
    - +
    + +
    diff --git a/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx b/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx index a2ad3bf282..21aa9af427 100644 --- a/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx +++ b/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx @@ -8,8 +8,9 @@ import { type BaseAccount, type Chain, type ChainAccount, type ChainId, type Hex import { AccountType, ChainType, CryptoType, ErrorType, KeyType, SigningType, WalletType } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { RootExplorers, cnTw, toAccountId, toAddress } from '@/shared/lib/utils'; -import { Button, FootnoteText, HeaderTitleText, Icon, IconButton, Input, InputHint, SmallTitleText } from '@/shared/ui'; +import { Button, FootnoteText, HeaderTitleText, Icon, IconButton, InputHint, SmallTitleText } from '@/shared/ui'; import { AccountExplorers, Address } from '@/shared/ui-entities'; +import { Field, Input } from '@/shared/ui-kit'; import { ChainTitle } from '@/entities/chain'; import { type AddressInfo, type CompactSeedInfo, type SeedInfo } from '@/entities/transaction'; import { ExplorersPopover, walletModel } from '@/entities/wallet'; @@ -198,146 +199,146 @@ export const ManageMultishard = ({ seedInfo, onBack, onClose, onComplete }: Prop }; return ( - <> -
    - {t('onboarding.vault.title')} - {t('onboarding.vault.manageTitle')} - - - ( - - )} - /> - - {t('onboarding.watchOnly.walletNameMaxLenError')} - - - {t('onboarding.watchOnly.walletNameRequiredError')} - - -
    - - - -
    - +
    +
    +
    + {t('onboarding.vault.title')} + {t('onboarding.vault.manageTitle')} + +
    + ( + + + + )} + /> + + {t('onboarding.watchOnly.walletNameMaxLenError')} + + + {t('onboarding.watchOnly.walletNameRequiredError')} + + +
    + + + +
    + +
    -
    - onClose()} /> +
    +
    + onClose()} /> -
    - {t('onboarding.vault.accountsTitle')} +
    + {t('onboarding.vault.accountsTitle')} - -
    -
    - {t('onboarding.vault.addressColumn')} - {t('onboarding.vault.nameColumn')} -
    -
    - {accounts.map((account, index) => ( -
    -
    - -
    - - - } - address={account.address} - explorers={RootExplorers} - contextClassName="mr-[-2rem]" - /> -
    + +
    +
    + {t('onboarding.vault.addressColumn')} + {t('onboarding.vault.nameColumn')} +
    +
    + {accounts.map((account, index) => ( +
    +
    + +
    + + + } + address={account.address} + explorers={RootExplorers} + contextClassName="mr-[-2rem]" + /> updateAccountName(name, index)} + onChange={(value) => updateAccountName(value, index)} />
    -
    -
    - {Object.entries(chainsObject).map(([chainId, chain]) => { - const derivedKeys = account.derivedKeys[chainId as ChainId]; - - if (!derivedKeys) return; - - return ( -
    -
    -
    - -
    - {derivedKeys.map(({ address }, derivedKeyIndex) => ( -
    -
    -
    -
    -
    - -
    - - +
      + {Object.entries(chainsObject).map(([chainId, chain]) => { + const derivedKeys = account.derivedKeys[chainId as ChainId]; + + if (!derivedKeys) return; + + return ( +
    • +
      +
      + +
      + {derivedKeys.map(({ address }, derivedKeyIndex) => ( +
      +
      +
      +
      +
      + +
      + + +
      +
      +
      + updateAccountName(value, index, chainId, derivedKeyIndex)} + /> + toggleAccount(index, chainId, derivedKeyIndex)} + />
      -
      - updateAccountName(name, index, chainId, derivedKeyIndex)} - /> - toggleAccount(index, chainId, derivedKeyIndex)} - /> -
      -
      - ))} -
      - ); - })} + ))} +
    • + ); + })} +
    -
    - ))} + ))} +
    - +
    ); }; diff --git a/src/renderer/pages/Onboarding/Vault/ManageSingleshard/ManageSingleshard.tsx b/src/renderer/pages/Onboarding/Vault/ManageSingleshard/ManageSingleshard.tsx index 5c3538b75c..6c76c35d7e 100644 --- a/src/renderer/pages/Onboarding/Vault/ManageSingleshard/ManageSingleshard.tsx +++ b/src/renderer/pages/Onboarding/Vault/ManageSingleshard/ManageSingleshard.tsx @@ -14,7 +14,8 @@ import { WalletType, } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { Button, HeaderTitleText, IconButton, Input, InputHint, SmallTitleText } from '@/shared/ui'; +import { Button, HeaderTitleText, IconButton, InputHint, SmallTitleText } from '@/shared/ui'; +import { Field, Input } from '@/shared/ui-kit'; import { networkModel, networkUtils } from '@/entities/network'; import { type SeedInfo } from '@/entities/transaction'; import { AccountsList, walletModel } from '@/entities/wallet'; @@ -100,10 +101,8 @@ export const ManageSingleshard = ({ seedInfo, onBack, onClose, onComplete }: Pro control={control} rules={{ required: true, maxLength: 256 }} render={({ field: { onChange, value } }) => ( -
    + {t('onboarding.watchOnly.walletNameRequiredError')} -
    + )} /> diff --git a/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx b/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx index eaa5b27edb..e3dd549cb9 100644 --- a/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx +++ b/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx @@ -29,11 +29,11 @@ import { HelpText, Icon, IconButton, - Input, InputHint, SmallTitleText, } from '@/shared/ui'; import { Animation } from '@/shared/ui/Animation/Animation'; +import { Field, Input } from '@/shared/ui-kit'; import { ChainTitle } from '@/entities/chain'; import { type SeedInfo } from '@/entities/transaction'; import { DerivedAccount, RootAccountLg, accountUtils } from '@/entities/wallet'; @@ -87,10 +87,10 @@ export const ManageVault = ({ seedInfo, onBack, onClose, onComplete }: Props) => useEffect(() => { const chains = chainsService.getChainsData({ sort: true }); - const chainsMap = dictionary(chains, 'chainId', () => []); + const chainsMap = dictionary(chains, 'chainId', [] as (ChainAccount | ShardAccount[])[]); for (const account of keysGroups) { - const chainId = Array.isArray(account) ? account[0].chainId : account.chainId; + const chainId = accountUtils.isAccountWithShards(account) ? account[0].chainId : account.chainId; chainsMap[chainId].push(account); } @@ -178,10 +178,8 @@ export const ManageVault = ({ seedInfo, onBack, onClose, onComplete }: Props) => {t('onboarding.vault.manageTitle')}
    -
    + {t(name.errorText())} -
    +
    +
    + +
    -
    +
    - +
    ); }; diff --git a/src/renderer/pages/Onboarding/Vault/Vault.tsx b/src/renderer/pages/Onboarding/Vault/Vault.tsx index b4cb70ce8a..04a5ad38bd 100644 --- a/src/renderer/pages/Onboarding/Vault/Vault.tsx +++ b/src/renderer/pages/Onboarding/Vault/Vault.tsx @@ -103,7 +103,7 @@ export const Vault = ({ isOpen, onClose, onComplete }: Props) => { return ( diff --git a/src/renderer/pages/Onboarding/WalletConnect/ManageStep.tsx b/src/renderer/pages/Onboarding/WalletConnect/ManageStep.tsx index 2b1c2c7fe7..6798740af7 100644 --- a/src/renderer/pages/Onboarding/WalletConnect/ManageStep.tsx +++ b/src/renderer/pages/Onboarding/WalletConnect/ManageStep.tsx @@ -16,8 +16,9 @@ import { } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { toAccountId } from '@/shared/lib/utils'; -import { Button, HeaderTitleText, Icon, Input, InputHint, SmallTitleText } from '@/shared/ui'; +import { Button, HeaderTitleText, Icon, InputHint, SmallTitleText } from '@/shared/ui'; import { type IconNames } from '@/shared/ui/Icon/data'; +import { Field, Input } from '@/shared/ui-kit'; import { MultiAccountsList, walletModel } from '@/entities/wallet'; const WalletLogo: Record = { @@ -153,10 +154,8 @@ export const ManageStep = ({ accounts, type, pairingTopic, sessionTopic, onBack, control={control} rules={{ required: true, maxLength: 256 }} render={({ field: { onChange, value } }) => ( -
    + {t('onboarding.watchOnly.walletNameRequiredError')} -
    + )} /> diff --git a/src/renderer/pages/Onboarding/WatchOnly/WatchOnly.tsx b/src/renderer/pages/Onboarding/WatchOnly/WatchOnly.tsx index 95a0c1e717..3efdca6282 100644 --- a/src/renderer/pages/Onboarding/WatchOnly/WatchOnly.tsx +++ b/src/renderer/pages/Onboarding/WatchOnly/WatchOnly.tsx @@ -15,10 +15,10 @@ import { Icon, IconButton, Identicon, - Input, InputHint, SmallTitleText, } from '@/shared/ui'; +import { Field, Input } from '@/shared/ui-kit'; import { networkModel, networkUtils } from '@/entities/network'; import { AccountsList, walletModel } from '@/entities/wallet'; @@ -119,10 +119,8 @@ const WatchOnly = ({ isOpen, onClose, onComplete }: Props) => { control={control} rules={{ required: true, maxLength: 256 }} render={({ field: { onChange, value } }) => ( -
    + { {t('onboarding.watchOnly.walletNameRequiredError')} -
    + )} /> @@ -144,17 +142,13 @@ const WatchOnly = ({ isOpen, onClose, onComplete }: Props) => { control={control} rules={{ required: true, validate: validateAddress }} render={({ field: { onChange, value } }) => ( -
    + - {isValid ? : } -
    + isValid ? : } testId={TEST_IDS.ONBOARDING.WALLET_ADDRESS_INPUT} onChange={onChange} @@ -163,7 +157,7 @@ const WatchOnly = ({ isOpen, onClose, onComplete }: Props) => { {t('onboarding.watchOnly.accountAddressError')} -
    + )} /> diff --git a/src/renderer/pages/Operations/common/utils.ts b/src/renderer/pages/Operations/common/utils.ts index f062b62155..821d44a945 100644 --- a/src/renderer/pages/Operations/common/utils.ts +++ b/src/renderer/pages/Operations/common/utils.ts @@ -1,3 +1,4 @@ +// TODO: Remove this file, use from entities/operations import { type ApiPromise } from '@polkadot/api'; import { BN } from '@polkadot/util'; diff --git a/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx b/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx index 23c9e81396..8bb01cd093 100644 --- a/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx +++ b/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx @@ -1,7 +1,14 @@ import { useUnit } from 'effector-react'; import { useEffect, useState } from 'react'; -import { type Account, type MultisigAccount, type MultisigTransaction, type Transaction } from '@/shared/core'; +import { + type Account, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, + type MultisigAccount, + type MultisigTransaction, + type Transaction, +} from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { getAssetById } from '@/shared/lib/utils'; import { DetailRow, Icon } from '@/shared/ui'; @@ -23,8 +30,8 @@ import { TransactionAmount } from '@/pages/Operations/components/TransactionAmou import { Details } from '../Details'; type Props = { - tx: MultisigTransaction; - account: MultisigAccount; + tx: MultisigTransaction | FlexibleMultisigTransaction; + account: MultisigAccount | FlexibleMultisigAccount; signAccount?: Account; chainConnection: ExtendedChain; feeTx?: Transaction; diff --git a/src/renderer/pages/Operations/components/Details.tsx b/src/renderer/pages/Operations/components/Details.tsx index 69af1e09c0..a972901f7f 100644 --- a/src/renderer/pages/Operations/components/Details.tsx +++ b/src/renderer/pages/Operations/components/Details.tsx @@ -5,6 +5,8 @@ import { Trans } from 'react-i18next'; import { type Account, type Address, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, type MultisigAccount, type MultisigTransaction, type Transaction, @@ -52,8 +54,8 @@ import { } from '../common/utils'; type Props = { - tx: MultisigTransaction; - account?: MultisigAccount; + tx: MultisigTransaction | FlexibleMultisigTransaction; + account?: MultisigAccount | FlexibleMultisigAccount; signatory?: Account; extendedChain?: ExtendedChain; }; diff --git a/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.tsx b/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.tsx index 67f3483659..b46a4c6065 100644 --- a/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.tsx +++ b/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.tsx @@ -1,9 +1,9 @@ -import { type MultisigAccount } from '@/shared/core'; +import { type FlexibleMultisigAccount, type MultisigAccount } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { BodyText, Icon } from '@/shared/ui'; type Props = { - multisigAccount?: MultisigAccount; + multisigAccount?: MultisigAccount | FlexibleMultisigAccount; isEmptyFromFilters: boolean; }; diff --git a/src/renderer/pages/Operations/components/LogModal.tsx b/src/renderer/pages/Operations/components/LogModal.tsx index 23111cef60..9daf6131f0 100644 --- a/src/renderer/pages/Operations/components/LogModal.tsx +++ b/src/renderer/pages/Operations/components/LogModal.tsx @@ -2,11 +2,12 @@ import { useUnit } from 'effector-react'; import groupBy from 'lodash/groupBy'; import { chainsService } from '@/shared/api/network'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; import { type Account, type AccountId, type Contact, + type FlexibleMultisigAccount, type MultisigAccount, type MultisigEvent, type SigningStatus, @@ -19,15 +20,14 @@ import { BaseModal, BodyText, ContextMenu, ExplorerLink, FootnoteText, IconButto import { AssetBalance } from '@/entities/asset'; import { useMultisigEvent } from '@/entities/multisig'; import { type ExtendedChain } from '@/entities/network'; +import { Status } from '@/entities/operations'; import { TransactionTitle, getTransactionAmount } from '@/entities/transaction'; import { WalletIcon, walletModel, walletUtils } from '@/entities/wallet'; import { getSignatoryName } from '../common/utils'; -import { Status } from './Status'; - type Props = { - tx: MultisigTransactionDS; - account?: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account?: MultisigAccount | FlexibleMultisigAccount; connection?: ExtendedChain; contacts: Contact[]; isOpen: boolean; diff --git a/src/renderer/pages/Operations/components/Operation.tsx b/src/renderer/pages/Operations/components/Operation.tsx index e59212c597..701f2ee217 100644 --- a/src/renderer/pages/Operations/components/Operation.tsx +++ b/src/renderer/pages/Operations/components/Operation.tsx @@ -1,66 +1,27 @@ -import { chainsService } from '@/shared/api/network'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; -import { type MultisigAccount } from '@/shared/core'; -import { useI18n } from '@/shared/i18n'; -import { getAssetById } from '@/shared/lib/utils'; -import { Accordion, FootnoteText } from '@/shared/ui'; -import { AssetBalance } from '@/entities/asset'; -import { ChainTitle, XcmChains } from '@/entities/chain'; -import { useMultisigEvent } from '@/entities/multisig'; -import { TransactionTitle, getTransactionAmount, isXcmTransaction } from '@/entities/transaction'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { type FlexibleMultisigAccount, type MultisigAccount } from '@/shared/core'; +import { useSlot } from '@/shared/di'; +import { Accordion } from '@/shared/ui'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; import { OperationFullInfo } from './OperationFullInfo'; -import { Status } from './Status'; type Props = { - tx: MultisigTransactionDS; - account?: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account?: MultisigAccount | FlexibleMultisigAccount; }; const Operation = ({ tx, account }: Props) => { - const { formatDate } = useI18n(); - const { getLiveEventsByKeys } = useMultisigEvent({}); - - const events = getLiveEventsByKeys([tx]); - const approvals = events?.filter((e) => e.status === 'SIGNED') || []; - const initEvent = approvals.find((e) => e.accountId === tx.depositor); - const date = new Date(tx.dateCreated || initEvent?.dateCreated || Date.now()); - const asset = - tx.transaction && getAssetById(tx.transaction.args.asset, chainsService.getChainById(tx.chainId)?.assets); - const amount = tx.transaction && getTransactionAmount(tx.transaction); + const operationTitle = useSlot(multisigOperationsFeature.slots.operationTitle, { + props: { + operation: tx, + }, + }); return ( -
    -
    - - {formatDate(date, 'p')} - -
    - - - - {asset && amount && ( -
    - -
    - )} - - {isXcmTransaction(tx.transaction) ? ( - - ) : ( - - )} - -
    - -
    -
    +
    {operationTitle}
    diff --git a/src/renderer/pages/Operations/components/OperationCardDetails.tsx b/src/renderer/pages/Operations/components/OperationCardDetails.tsx deleted file mode 100644 index 31bd68d8fc..0000000000 --- a/src/renderer/pages/Operations/components/OperationCardDetails.tsx +++ /dev/null @@ -1,484 +0,0 @@ -import { useUnit } from 'effector-react'; -import { useEffect, useState } from 'react'; -import { Trans } from 'react-i18next'; - -import { chainsService } from '@/shared/api/network'; -import { - type Address, - type MultisigAccount, - type MultisigTransaction, - type Transaction, - type Validator, -} from '@/shared/core'; -import { TransactionType } from '@/shared/core'; -import { useI18n } from '@/shared/i18n'; -import { useToggle } from '@/shared/lib/hooks'; -import { cnTw, copyToClipboard, getAssetById, truncate } from '@/shared/lib/utils'; -import { Button, DetailRow, FootnoteText, Icon } from '@/shared/ui'; -import { Skeleton } from '@/shared/ui-kit'; -import { AssetBalance } from '@/entities/asset'; -import { ChainTitle } from '@/entities/chain'; -import { TracksDetails, voteTransactionService } from '@/entities/governance'; -import { getTransactionFromMultisigTx } from '@/entities/multisig'; -import { type ExtendedChain, networkModel, networkUtils } from '@/entities/network'; -import { proxyUtils } from '@/entities/proxy'; -import { signatoryUtils } from '@/entities/signatory'; -import { ValidatorsModal, useValidatorsMap } from '@/entities/staking'; -import { - isAddProxyTransaction, - isManageProxyTransaction, - isRemoveProxyTransaction, - isRemovePureProxyTransaction, - isUndelegateTransaction, - isXcmTransaction, -} from '@/entities/transaction'; -import { AddressWithExplorers, ExplorersPopover, WalletCardSm, walletModel } from '@/entities/wallet'; -import { AddressStyle, InteractionStyle } from '../common/constants'; -import { - getDelegate, - getDelegationTarget, - getDelegationTracks, - getDelegationVotes, - getDestination, - getDestinationChain, - getMultisigExtrinsicLink, - getPayee, - getProxyType, - getReferendumId, - getSender, - getUndelegationData, - getVote, -} from '../common/utils'; - -type Props = { - tx: MultisigTransaction; - account?: MultisigAccount; - extendedChain?: ExtendedChain; -}; - -export const OperationCardDetails = ({ tx, account, extendedChain }: Props) => { - const { t } = useI18n(); - - const activeWallet = useUnit(walletModel.$activeWallet); - const wallets = useUnit(walletModel.$wallets); - const chains = useUnit(networkModel.$chains); - - const payee = getPayee(tx); - const sender = getSender(tx); - const delegate = getDelegate(tx); - const proxyType = getProxyType(tx); - const destinationChain = getDestinationChain(tx); - const destination = getDestination(tx, chains, destinationChain); - - const delegationTarget = getDelegationTarget(tx); - const delegationTracks = getDelegationTracks(tx); - const delegationVotes = getDelegationVotes(tx); - - const referendumId = getReferendumId(tx); - const vote = getVote(tx); - - const api = extendedChain?.api; - const defaultAsset = extendedChain?.assets[0]; - const addressPrefix = extendedChain?.addressPrefix; - const explorers = extendedChain?.explorers; - const connection = extendedChain?.connection; - - const [isUndelegationLoading, setIsUndelegationLoading] = useState(false); - const [undelegationVotes, setUndelegationVotes] = useState(); - const [undelegationTarget, setUndelegationTarget] = useState
    (); - - useEffect(() => { - if (isUndelegateTransaction(transaction)) { - setIsUndelegationLoading(true); - } - - if (!api) return; - - getUndelegationData(api, tx).then(({ votes, target }) => { - setUndelegationVotes(votes); - setUndelegationTarget(target); - setIsUndelegationLoading(false); - }); - }, [api, tx]); - - const [isAdvancedShown, toggleAdvanced] = useToggle(); - const [isValidatorsOpen, toggleValidators] = useToggle(); - - const { indexCreated, blockCreated, deposit, depositor, callHash, callData } = tx; - - const transaction = getTransactionFromMultisigTx(tx); - const validatorsMap = useValidatorsMap(api, connection && networkUtils.isLightClientConnection(connection)); - - const allValidators = Object.values(validatorsMap); - - const startStakingValidators: Address[] = - (tx.transaction?.type === TransactionType.BATCH_ALL && - tx.transaction.args.transactions.find((tx: Transaction) => tx.type === TransactionType.NOMINATE)?.args - ?.targets) || - []; - - const selectedValidators: Validator[] = - allValidators.filter((v) => (transaction?.args.targets || startStakingValidators).includes(v.address)) || []; - const selectedValidatorsAddress = selectedValidators.map((validator) => validator.address); - const notSelectedValidators = allValidators.filter((v) => !selectedValidatorsAddress.includes(v.address)); - - const depositorSignatory = account?.signatories.find((s) => s.accountId === depositor); - const extrinsicLink = getMultisigExtrinsicLink(callHash, indexCreated, blockCreated, explorers); - const validatorsAsset = - transaction && getAssetById(transaction.args.asset, chainsService.getChainById(tx.chainId)?.assets); - - const valueClass = 'text-text-secondary'; - const depositorWallet = - depositorSignatory && signatoryUtils.getSignatoryWallet(wallets, depositorSignatory.accountId); - - return ( -
    - {account && activeWallet && ( - -
    - } - address={account.accountId} - explorers={explorers} - addressPrefix={addressPrefix} - /> -
    -
    - )} - - {isXcmTransaction(transaction) && ( - <> - {sender && ( - - - - )} - - - - - - {destinationChain && ( - - - - )} - - )} - - {destination && ( - - - - )} - - {isAddProxyTransaction(transaction) && delegate && ( - - - - )} - - {isRemoveProxyTransaction(transaction) && delegate && ( - - - - )} - - {isRemovePureProxyTransaction(transaction) && sender && ( - - - - )} - - {isManageProxyTransaction(transaction) && proxyType && ( - - {t(proxyUtils.getProxyTypeName(proxyType))} - - )} - - {referendumId && ( - - #{referendumId} - - )} - - {vote && ( - - - <> - - {t(`governance.referendum.${voteTransactionService.getDecision(vote)}`)} - - :{' '} - - ), - }} - /> - - - - )} - - {isUndelegationLoading && ( - <> - - - - - - - - - )} - - {delegationTarget && ( - - - - )} - - {!delegationTarget && undelegationTarget && ( - - - - )} - - {delegationVotes && ( - - - - - - )} - - {!delegationVotes && undelegationVotes && ( - - - - - - )} - - {delegationTracks && ( - - - - )} - - {Boolean(selectedValidators?.length) && defaultAsset && ( - <> - - - - - - )} - - {payee && ( - - {typeof payee === 'string' ? ( - t('staking.confirmation.restakeRewards') - ) : ( - - )} - - )} - - - - {isAdvancedShown && ( - <> - {callHash && ( - - - - )} - - {callData && ( - - - - )} - - {deposit && defaultAsset && depositorSignatory &&
    } - - {depositorSignatory && ( - -
    - {depositorWallet ? ( - } - address={depositorSignatory.accountId} - explorers={explorers} - addressPrefix={addressPrefix} - /> - ) : ( - - )} -
    -
    - )} - - {deposit && defaultAsset && ( - - - - )} - - {deposit && defaultAsset && depositorSignatory &&
    } - - {indexCreated && blockCreated && ( - - {extrinsicLink ? ( - - - {blockCreated}-{indexCreated} - - - - ) : ( - `${blockCreated}-${indexCreated}` - )} - - )} - - )} -
    - ); -}; diff --git a/src/renderer/pages/Operations/components/OperationFullInfo.tsx b/src/renderer/pages/Operations/components/OperationFullInfo.tsx index 3f8e773f67..ff119b911c 100644 --- a/src/renderer/pages/Operations/components/OperationFullInfo.tsx +++ b/src/renderer/pages/Operations/components/OperationFullInfo.tsx @@ -1,25 +1,26 @@ import { useUnit } from 'effector-react'; import { useMultisigChainContext } from '@/app/providers'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; -import { type CallData, type MultisigAccount } from '@/shared/core'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { type CallData, type FlexibleMultisigAccount, type MultisigAccount } from '@/shared/core'; +import { useSlot } from '@/shared/di'; import { useI18n } from '@/shared/i18n'; import { useToggle } from '@/shared/lib/hooks'; import { Button, Icon, InfoLink, SmallTitleText } from '@/shared/ui'; import { useMultisigTx } from '@/entities/multisig'; import { useNetworkData } from '@/entities/network'; import { permissionUtils, walletModel } from '@/entities/wallet'; +import { multisigOperationsFeature } from '@/features/multisig-operations'; import { getMultisigExtrinsicLink } from '../common/utils'; -import { OperationCardDetails } from './OperationCardDetails'; import { OperationSignatories } from './OperationSignatories'; import ApproveTxModal from './modals/ApproveTx'; import CallDataModal from './modals/CallDataModal'; import RejectTxModal from './modals/RejectTx'; type Props = { - tx: MultisigTransactionDS; - account?: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account?: MultisigAccount | FlexibleMultisigAccount; }; export const OperationFullInfo = ({ tx, account }: Props) => { @@ -47,6 +48,12 @@ export const OperationFullInfo = ({ tx, account }: Props) => { return hasDepositor && permissionUtils.canRejectMultisigTx(wallet); }); + const operationDetails = useSlot(multisigOperationsFeature.slots.operationDetails, { + props: { + operation: tx, + }, + }); + return (
    @@ -70,7 +77,7 @@ export const OperationFullInfo = ({ tx, account }: Props) => { )}
    - +
    {operationDetails}
    {connection && isRejectAvailable && account && ( diff --git a/src/renderer/pages/Operations/components/OperationSignatories.tsx b/src/renderer/pages/Operations/components/OperationSignatories.tsx index 358e79e74f..c86707844d 100644 --- a/src/renderer/pages/Operations/components/OperationSignatories.tsx +++ b/src/renderer/pages/Operations/components/OperationSignatories.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react'; import { type AccountId, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, type MultisigAccount, type MultisigEvent, type MultisigTransaction, @@ -26,9 +28,9 @@ import LogModal from './LogModal'; type WalletSignatory = Signatory & { wallet: Wallet }; type Props = { - tx: MultisigTransaction; + tx: MultisigTransaction | FlexibleMultisigTransaction; connection: ExtendedChain; - account: MultisigAccount; + account: MultisigAccount | FlexibleMultisigAccount; }; export const OperationSignatories = ({ tx, connection, account }: Props) => { diff --git a/src/renderer/pages/Operations/components/modals/ApproveTx.tsx b/src/renderer/pages/Operations/components/modals/ApproveTx.tsx index 9de0b2e7c1..2b1bdbbf28 100644 --- a/src/renderer/pages/Operations/components/modals/ApproveTx.tsx +++ b/src/renderer/pages/Operations/components/modals/ApproveTx.tsx @@ -3,10 +3,11 @@ import { BN } from '@polkadot/util'; import { useUnit } from 'effector-react'; import { useEffect, useState } from 'react'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; import { type Account, type Address, + type FlexibleMultisigAccount, type HexString, type MultisigAccount, type Timepoint, @@ -41,8 +42,8 @@ import { Submit } from '../ActionSteps/Submit'; import { SignatorySelectModal } from './SignatorySelectModal'; type Props = { - tx: MultisigTransactionDS; - account: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account: MultisigAccount | FlexibleMultisigAccount; connection: ExtendedChain; children: React.ReactNode; }; diff --git a/src/renderer/pages/Operations/components/modals/CallDataModal.tsx b/src/renderer/pages/Operations/components/modals/CallDataModal.tsx index 8bf8d63432..ea44db9e26 100644 --- a/src/renderer/pages/Operations/components/modals/CallDataModal.tsx +++ b/src/renderer/pages/Operations/components/modals/CallDataModal.tsx @@ -3,8 +3,9 @@ import { Controller, type SubmitHandler, useForm } from 'react-hook-form'; import { type MultisigTransactionDS } from '@/shared/api/storage'; import { type CallData } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { validateCallData } from '@/shared/lib/utils'; -import { BaseModal, Button, InputArea, InputHint } from '@/shared/ui'; +import { nonNullable, validateCallData } from '@/shared/lib/utils'; +import { BaseModal, Button, InputHint } from '@/shared/ui'; +import { TextArea } from '@/shared/ui-kit'; type CallDataForm = { callData: string; @@ -61,10 +62,10 @@ const CallDataModal = ({ isOpen, tx, onClose, onSubmit }: Props) => { rules={{ required: true, validate: validateCallDataValue }} render={({ field: { value, onChange }, fieldState: { error } }) => ( <> - diff --git a/src/renderer/pages/Operations/components/modals/RejectTx.tsx b/src/renderer/pages/Operations/components/modals/RejectTx.tsx index 10900d9f67..c0c77544bd 100644 --- a/src/renderer/pages/Operations/components/modals/RejectTx.tsx +++ b/src/renderer/pages/Operations/components/modals/RejectTx.tsx @@ -3,8 +3,15 @@ import { useUnit } from 'effector-react'; import { sortBy } from 'lodash'; import { useEffect, useState } from 'react'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; -import { type Account, type Address, type HexString, type MultisigAccount, type Transaction } from '@/shared/core'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { + type Account, + type Address, + type FlexibleMultisigAccount, + type HexString, + type MultisigAccount, + type Transaction, +} from '@/shared/core'; import { TransactionType } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useToggle } from '@/shared/lib/hooks'; @@ -29,8 +36,8 @@ import { Confirmation } from '../ActionSteps/Confirmation'; import { Submit } from '../ActionSteps/Submit'; type Props = { - tx: MultisigTransactionDS; - account: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account: MultisigAccount | FlexibleMultisigAccount; connection: ExtendedChain; children: React.ReactNode; }; diff --git a/src/renderer/pages/Settings/Networks/ui/Networks.tsx b/src/renderer/pages/Settings/Networks/ui/Networks.tsx index b05b84ed42..e0180d73c1 100644 --- a/src/renderer/pages/Settings/Networks/ui/Networks.tsx +++ b/src/renderer/pages/Settings/Networks/ui/Networks.tsx @@ -186,7 +186,9 @@ export const Networks = () => { title={t('settings.networks.title')} onClose={closeModal} > - +
    + +
    0 && shardsStats.selected < shardsStake.length} - onChange={(checked) => selectAllShards(checked)} + onChange={selectAllShards} >
    diff --git a/src/renderer/pages/Staking/ui/Staking.tsx b/src/renderer/pages/Staking/ui/Staking.tsx index 28568ee4b9..5e597ab524 100644 --- a/src/renderer/pages/Staking/ui/Staking.tsx +++ b/src/renderer/pages/Staking/ui/Staking.tsx @@ -24,7 +24,7 @@ import { } from '@/entities/staking'; import { accountUtils, permissionUtils, walletModel, walletUtils } from '@/entities/wallet'; import { EmptyAccountMessage } from '@/features/emptyList'; -import { walletSelectModel } from '@/features/wallets'; +import { walletDetailsFeature } from '@/features/wallet-details'; import * as Operations from '@/widgets/Staking'; import { type NominatorInfo, Operations as StakeOperations } from '../lib/types'; @@ -35,6 +35,10 @@ import { NetworkInfo } from './NetworkInfo'; // eslint-disable-next-line import-x/max-dependencies import { NominatorsList } from './NominatorsList'; +const { + views: { WalletDetails }, +} = walletDetailsFeature; + export const Staking = () => { const { t } = useI18n(); @@ -58,6 +62,7 @@ export const Staking = () => { const [selectedNominators, setSelectedNominators] = useState([]); const [selectedStash, setSelectedStash] = useState
    (''); + const [showWalletDetails, setShowWalletDetails] = useState(false); const { api, connection, connectionStatus } = useNetworkData(chainId || undefined); @@ -333,7 +338,7 @@ export const Staking = () => { {networkIsActive && activeWallet && accounts.length === 0 && ( }> {walletUtils.isPolkadotVault(activeWallet) && ( - )} @@ -354,6 +359,12 @@ export const Staking = () => { onClose={toggleNominators} /> + setShowWalletDetails(false)} + /> + diff --git a/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts b/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts index 91587676c8..65d0874699 100644 --- a/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts +++ b/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts @@ -1,38 +1,90 @@ -import { AccountType, type Chain, ChainType, CryptoType, SigningType, WalletType } from '@/shared/core'; +import { + type AccountId, + AccountType, + type Chain, + ChainOptions, + ChainType, + CryptoType, + type FlexibleMultisigAccount, + type MultisigAccount, + type NoID, +} from '@/shared/core'; import { isEthereumAccountId, toAddress } from '@/shared/lib/utils'; export const multisigUtils = { - buildMultisig, + isMultisigSupported, + isFlexibleMultisigSupported, + buildMultisigAccount, + buildFlexibleMultisigAccount, }; +function isMultisigSupported(chain: Chain) { + return chain.options?.includes(ChainOptions.MULTISIG) ?? false; +} + +function isFlexibleMultisigSupported(chain: Chain) { + const options = chain.options ?? []; + + return ( + isMultisigSupported(chain) && + (options.includes(ChainOptions.REGULAR_PROXY) || options.includes(ChainOptions.PURE_PROXY)) + ); +} + type BuildMultisigParams = { threshold: number; - accountId: `0x${string}`; - signatories: string[]; + accountId: AccountId; + signatories: AccountId[]; + chain: Chain; +}; + +function buildMultisigAccount({ threshold, accountId, signatories, chain }: BuildMultisigParams) { + const account: NoID> = { + threshold: threshold, + accountId: accountId, + signatories: signatories.map((signatory) => ({ + accountId: signatory, + address: toAddress(signatory), + })), + name: toAddress(accountId, { chunk: 5, prefix: chain.addressPrefix }), + chainId: chain.chainId, + cryptoType: isEthereumAccountId(accountId) ? CryptoType.ETHEREUM : CryptoType.SR25519, + chainType: ChainType.SUBSTRATE, + type: AccountType.MULTISIG, + }; + + return account; +} + +type BuildFlexibleMultisigParams = { + threshold: number; + accountId: AccountId; + signatories: AccountId[]; chain: Chain; + proxyAccountId: AccountId; }; -function buildMultisig({ threshold, accountId, signatories, chain }: BuildMultisigParams) { - return { - wallet: { - name: toAddress(accountId, { chunk: 5, prefix: chain.addressPrefix }), - type: WalletType.MULTISIG, - signingType: SigningType.MULTISIG, - }, - accounts: [ - { - threshold: threshold, - accountId: accountId, - signatories: signatories.map((signatory) => ({ - accountId: signatory, - address: toAddress(signatory), - })), - name: toAddress(accountId, { chunk: 5, prefix: chain.addressPrefix }), - chainId: chain.chainId, - cryptoType: isEthereumAccountId(accountId) ? CryptoType.ETHEREUM : CryptoType.SR25519, - chainType: ChainType.SUBSTRATE, - type: AccountType.MULTISIG, - }, - ], +function buildFlexibleMultisigAccount({ + threshold, + accountId, + proxyAccountId, + signatories, + chain, +}: BuildFlexibleMultisigParams) { + const account: NoID> = { + threshold, + accountId, + proxyAccountId, + signatories: signatories.map((signatory) => ({ + accountId: signatory, + address: toAddress(signatory), + })), + name: toAddress(accountId, { chunk: 5, prefix: chain.addressPrefix }), + chainId: chain.chainId, + cryptoType: isEthereumAccountId(accountId) ? CryptoType.ETHEREUM : CryptoType.SR25519, + chainType: ChainType.SUBSTRATE, + type: AccountType.FLEXIBLE_MULTISIG, }; + + return account; } diff --git a/src/renderer/processes/multisigs/model/__tests__/multisigs-model.test.ts b/src/renderer/processes/multisigs/model/__tests__/multisigs-model.test.ts index a40065f1e9..3ae1b00cdc 100644 --- a/src/renderer/processes/multisigs/model/__tests__/multisigs-model.test.ts +++ b/src/renderer/processes/multisigs/model/__tests__/multisigs-model.test.ts @@ -21,11 +21,10 @@ const mockConnections = { }, }; -describe('features/multisigs/model/multisigs-model', () => { +describe('multisigs model', () => { beforeAll(() => { jest.useFakeTimers(); }); - beforeEach(() => { jest.restoreAllMocks(); jest.spyOn(multisigService, 'filterMultisigsAccounts').mockResolvedValue([ @@ -51,10 +50,10 @@ describe('features/multisigs/model/multisigs-model', () => { ]) .set(networkModel.$chains, mockChains) .set(networkModel.$connections, mockConnections), - handlers: new Map().set(multisigsModel._test.saveMultisigFx, spySaveMultisig), + handlers: new Map().set(walletModel._test.walletCreatedFx, spySaveMultisig), }); - allSettled(multisigsModel.events.multisigsDiscoveryStarted, { scope }); + allSettled(multisigsModel.events.subscribe, { scope }); await jest.runOnlyPendingTimersAsync(); expect(spySaveMultisig).toHaveBeenCalled(); @@ -79,10 +78,10 @@ describe('features/multisigs/model/multisigs-model', () => { ]) .set(networkModel.$chains, mockChains) .set(networkModel.$connections, mockConnections), - handlers: new Map().set(multisigsModel._test.saveMultisigFx, spySaveMultisig), + handlers: new Map().set(walletModel._test.walletCreatedFx, spySaveMultisig), }); - allSettled(multisigsModel.events.multisigsDiscoveryStarted, { scope }); + allSettled(multisigsModel.events.subscribe, { scope }); await jest.runOnlyPendingTimersAsync(); expect(spySaveMultisig).not.toHaveBeenCalled(); diff --git a/src/renderer/processes/multisigs/model/multisigs-model.ts b/src/renderer/processes/multisigs/model/multisigs-model.ts index 731b0e3a1a..b11badee09 100644 --- a/src/renderer/processes/multisigs/model/multisigs-model.ts +++ b/src/renderer/processes/multisigs/model/multisigs-model.ts @@ -1,33 +1,59 @@ -import { combine, createEffect, createEvent, sample, scopeBind } from 'effector'; +import { combine, createEffect, createEvent, createStore, sample } from 'effector'; import { GraphQLClient } from 'graphql-request'; -import { interval, once } from 'patronum'; +import { uniq } from 'lodash'; +import { interval } from 'patronum'; import { + type Account, type Chain, + type ChainId, ExternalType, + type FlexibleMultisigAccount, + type FlexibleMultisigCreated, + type FlexibleMultisigWallet, type MultisigAccount, type MultisigCreated, + type MultisigWallet, type NoID, NotificationType, - type Wallet, + type ProxyAccount, + SigningType, + WalletType, } from '@/shared/core'; -import { nullable } from '@/shared/lib/utils'; -import { type MultisigResult, multisigService } from '@/entities/multisig'; +import { series } from '@/shared/effector'; +import { nonNullable, nullable, toAddress } from '@/shared/lib/utils'; +import { multisigService } from '@/entities/multisig'; import { networkModel, networkUtils } from '@/entities/network'; import { notificationModel } from '@/entities/notification'; import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; import { multisigUtils } from '../lib/mulitisigs-utils'; -type SaveMultisigParams = { - wallet: Omit, 'isActive' | 'accounts'>; - accounts: Omit, 'walletId'>[]; - external: boolean; -}; - const MULTISIG_DISCOVERY_TIMEOUT = 30000; -const multisigsDiscoveryStarted = createEvent(); -const multisigSaved = createEvent(); +const subscribe = createEvent(); +const request = createEvent(); + +const $multisigAccounts = walletModel.$allWallets + .map(walletUtils.getAllAccounts) + .map((accounts) => accounts.filter(accountUtils.isMultisigAccount)); + +const { tick: pollingRequest } = interval({ + start: subscribe, + timeout: MULTISIG_DISCOVERY_TIMEOUT, +}); + +const updateRequested = sample({ + clock: [pollingRequest, networkModel.events.connectionsPopulated], + source: walletModel.$allWallets, + fn: (wallets) => { + const filteredWallets = + walletUtils.getWalletsFilteredAccounts(wallets, { + walletFn: (w) => !walletUtils.isWatchOnly(w) && !walletUtils.isProxied(w) && !walletUtils.isMultisig(w), + }) ?? []; + + return walletUtils.getAllAccounts(filteredWallets); + }, +}); const $multisigChains = combine(networkModel.$chains, (chains) => { return Object.values(chains).filter((chain) => { @@ -40,118 +66,214 @@ const $multisigChains = combine(networkModel.$chains, (chains) => { type GetMultisigsParams = { chains: Chain[]; - wallets: Wallet[]; + accounts: Account[]; + multisigAccounts: Account[]; + proxies: Record; }; -type GetMultisigsResult = { +type MultisigResponse = { + type: 'multisig'; + account: NoID>; chain: Chain; - indexedMultisigs: MultisigResult[]; }; -const getMultisigsFx = createEffect(({ chains, wallets }: GetMultisigsParams) => { - for (const chain of chains) { - const multisigIndexerUrl = chain.externalApi?.[ExternalType.PROXY]?.at(0)?.url; - if (!multisigIndexerUrl) continue; +type FlexibleMultisigResponse = { + type: 'flexibleMultisig'; + account: NoID>; + chain: Chain; +}; + +type GetMultisigResponse = MultisigResponse | FlexibleMultisigResponse; + +const getMultisigsFx = createEffect( + ({ chains, accounts, proxies, multisigAccounts }: GetMultisigsParams): Promise => { + const requests = chains.map(async (chain) => { + const multisigIndexer = networkUtils.getProxyExternalApi(chain); + + if (nullable(multisigIndexer) || accounts.length === 0) return []; - const filteredWallets = walletUtils.getWalletsFilteredAccounts(wallets, { - walletFn: (w) => !walletUtils.isMultisig(w) && !walletUtils.isWatchOnly(w) && !walletUtils.isProxied(w), - accountFn: (a) => accountUtils.isChainIdMatch(a, chain.chainId), + const client = new GraphQLClient(multisigIndexer.url); + const accountIds = uniq( + accounts.filter((a) => accountUtils.isChainIdMatch(a, chain.chainId)).map((account) => account.accountId), + ); + + const indexedMultisigs = await multisigService.filterMultisigsAccounts(client, accountIds); + + return ( + indexedMultisigs + // filter out multisigs that already exists + .filter((multisigResult) => nullable(multisigAccounts.find((a) => a.accountId === multisigResult.accountId))) + .map(({ threshold, accountId, signatories }): GetMultisigResponse => { + const proxiesList = proxies[accountId]; + const proxy = nonNullable(proxiesList) + ? // TODO should we filter out not Any proxy type? + (proxiesList.find((p) => p.chainId === chain.chainId) ?? null) + : null; + + if (proxy) { + return { + type: 'flexibleMultisig', + account: multisigUtils.buildFlexibleMultisigAccount({ + threshold, + proxyAccountId: proxy.accountId, + accountId, + signatories, + chain, + }), + chain, + }; + } + + return { + type: 'multisig', + account: multisigUtils.buildMultisigAccount({ threshold, accountId, signatories, chain }), + chain, + }; + }) + ); }); - const accountIds = (filteredWallets || []).flatMap(({ accounts }) => accounts).map(({ accountId }) => accountId); - if (accountIds.length === 0) continue; - - const client = new GraphQLClient(multisigIndexerUrl); - const boundMultisigSaved = scopeBind(multisigSaved, { safe: true }); - - multisigService - .filterMultisigsAccounts(client, accountIds) - .then((indexedMultisigs) => { - const multisigWallets = walletUtils.getWalletsFilteredAccounts(wallets, { walletFn: walletUtils.isMultisig }); - const walletsToSave: MultisigResult[] = []; - - for (const multisig of indexedMultisigs) { - const existingWallet = walletUtils.getWalletFilteredAccounts(multisigWallets || [], { - accountFn: (a) => a.accountId === multisig.accountId, - }); - if (existingWallet) continue; - - walletsToSave.push(multisig); - } - - if (walletsToSave.length > 0) { - boundMultisigSaved({ indexedMultisigs: walletsToSave, chain }); - } - }) - .catch(console.error); - } -}); + return Promise.all(requests).then((res) => res.flat()); + }, +); -const saveMultisigFx = createEffect((multisigsToSave: SaveMultisigParams[]) => { - for (const multisig of multisigsToSave) { - walletModel.events.multisigCreated(multisig); +const populateMultisigWalletFx = createEffect(({ account, chain }: MultisigResponse) => { + const walletName = toAddress(account.accountId, { chunk: 5, prefix: chain.addressPrefix }); + const wallet: NoID> = { + name: walletName, + type: WalletType.MULTISIG, + signingType: SigningType.MULTISIG, + }; - const signatories = multisig.accounts[0].signatories.map((signatory) => signatory.accountId); - notificationModel.events.notificationsAdded([ - { - read: false, - type: NotificationType.MULTISIG_CREATED, - dateCreated: Date.now(), - multisigAccountId: multisig.accounts[0].accountId, - multisigAccountName: multisig.wallet.name, - chainId: multisig.accounts[0].chainId, - signatories, - threshold: multisig.accounts[0].threshold, - originatorAccountId: '' as string, - } as NoID, - ]); - } + return { + wallet, + accounts: [account], + external: true, + }; }); -const { tick: multisigsDiscoveryTriggered } = interval({ - start: multisigsDiscoveryStarted, - timeout: MULTISIG_DISCOVERY_TIMEOUT, +const populateFlexibleMultisigWalletFx = createEffect(({ account, chain }: FlexibleMultisigResponse) => { + const walletName = toAddress(account.accountId, { chunk: 5, prefix: chain.addressPrefix }); + const wallet: NoID> = { + name: walletName, + type: WalletType.FLEXIBLE_MULTISIG, + signingType: SigningType.MULTISIG, + activated: false, + }; + + return { + wallet, + accounts: [account], + external: true, + }; }); sample({ - clock: [multisigsDiscoveryTriggered, once(networkModel.$connections)], + clock: [updateRequested, request], source: { + multisigAccounts: $multisigAccounts, chains: $multisigChains, - wallets: walletModel.$allWallets, + // TODO uncomment when we're ready to work with flexible multisig. + // proxies: proxyModel.$proxies, + proxies: createStore({}), connections: networkModel.$connections, }, - fn: ({ chains, wallets, connections }) => { + fn: ({ multisigAccounts, chains, proxies, connections }, accounts) => { const filteredChains = chains.filter((chain) => { if (nullable(connections[chain.chainId])) return false; return !networkUtils.isDisabledConnection(connections[chain.chainId]); }); - return { wallets, chains: filteredChains }; + return { + chains: filteredChains, + multisigAccounts, + accounts, + proxies, + }; }, target: getMultisigsFx, }); +const populateWallet = createEvent(); + sample({ - clock: multisigSaved, - fn: ({ indexedMultisigs, chain }) => { - return indexedMultisigs.map( - ({ threshold, accountId, signatories }) => - ({ - ...multisigUtils.buildMultisig({ threshold, accountId, signatories, chain }), - external: true, - }) as SaveMultisigParams, - ); + clock: getMultisigsFx.doneData, + target: series(populateWallet), +}); + +const populateMultisigWallet = populateWallet.filter({ + fn: (x) => x.type === 'multisig', +}); + +const populateFlexibleMultisigWallet = populateWallet.filter({ + fn: (x) => x.type === 'flexibleMultisig', +}); + +sample({ + clock: populateMultisigWallet, + target: populateMultisigWalletFx, +}); + +sample({ + clock: populateFlexibleMultisigWallet, + target: populateFlexibleMultisigWalletFx, +}); + +sample({ + clock: populateMultisigWalletFx.doneData, + target: walletModel.events.multisigCreated, +}); + +sample({ + clock: populateFlexibleMultisigWalletFx.doneData, + target: walletModel.events.flexibleMultisigCreated, +}); + +sample({ + clock: walletModel.events.walletCreatedDone, + filter: ({ wallet }) => wallet.type === WalletType.MULTISIG, + fn: ({ accounts }) => { + return accounts.filter(accountUtils.isRegularMultisigAccount).map>((account) => { + return { + read: false, + type: NotificationType.MULTISIG_CREATED, + dateCreated: Date.now(), + multisigAccountId: account.accountId, + multisigAccountName: account.name, + chainId: account.chainId, + signatories: account.signatories.map((signatory) => signatory.accountId), + threshold: account.threshold, + }; + }); }, - target: saveMultisigFx, + target: notificationModel.events.notificationsAdded, }); -export const multisigsModel = { - events: { - multisigsDiscoveryStarted, +sample({ + clock: walletModel.events.walletCreatedDone, + filter: ({ wallet }) => wallet.type === WalletType.FLEXIBLE_MULTISIG, + fn: ({ accounts, wallet }) => { + return accounts.filter(accountUtils.isFlexibleMultisigAccount).map>((account) => { + return { + read: false, + walletId: wallet.id, + type: NotificationType.FLEXIBLE_MULTISIG_CREATED, + dateCreated: Date.now(), + multisigAccountId: account.accountId, + multisigAccountName: account.name, + chainId: account.chainId, + signatories: account.signatories.map((signatory) => signatory.accountId), + threshold: account.threshold, + }; + }); }, + target: notificationModel.events.notificationsAdded, +}); - _test: { - saveMultisigFx, +export const multisigsModel = { + events: { + subscribe, + request, }, }; diff --git a/src/renderer/shared/api/balances/service/balanceService.ts b/src/renderer/shared/api/balances/service/balanceService.ts index bc6d7c261a..4d741b5407 100644 --- a/src/renderer/shared/api/balances/service/balanceService.ts +++ b/src/renderer/shared/api/balances/service/balanceService.ts @@ -4,7 +4,7 @@ import { type Vec } from '@polkadot/types'; import { type AccountData, type Balance as ChainBalance } from '@polkadot/types/interfaces'; import { type PalletBalancesBalanceLock } from '@polkadot/types/lookup'; import { type Codec } from '@polkadot/types/types'; -import { type BN, BN_ZERO, hexToU8a } from '@polkadot/util'; +import { BN, BN_ZERO, hexToU8a } from '@polkadot/util'; import noop from 'lodash/noop'; import uniq from 'lodash/uniq'; @@ -25,6 +25,7 @@ type NoIdBalance = Omit; export const balanceService = { subscribeBalances, subscribeLockBalances, + getExistentialDeposit, }; /** @@ -308,3 +309,17 @@ function subscribeLockOrmlAssetChange( callback(newLocks); }); } + +async function getExistentialDeposit(api: ApiPromise, asset: Asset): Promise { + switch (asset.type) { + case AssetType.NATIVE: { + return api.consts.balances.existentialDeposit.toBn(); + } + case AssetType.STATEMINE: { + return await api.query.assets.asset(asset.assetId).then((balance) => balance.value.minBalance.toBn()); + } + case AssetType.ORML: { + return new BN((asset.typeExtras as OrmlExtras).existentialDeposit); + } + } +} diff --git a/src/renderer/shared/api/storage/lib/types.ts b/src/renderer/shared/api/storage/lib/types.ts index 48fe2424bc..5c8ec9d77b 100644 --- a/src/renderer/shared/api/storage/lib/types.ts +++ b/src/renderer/shared/api/storage/lib/types.ts @@ -11,6 +11,7 @@ import { type ChainMetadata, type Connection, type Contact, + type FlexibleMultisigTransaction, type MultisigEvent, type MultisigTransaction, type MultisigTransactionKey, @@ -72,6 +73,7 @@ export type ID = string; type WithID> = { id?: ID } & T; export type MultisigTransactionDS = WithID; +export type FlexibleMultisigTransactionDS = WithID; export type MultisigEventDS = WithID; export type TWallet = Table, Wallet['id']>; diff --git a/src/renderer/shared/assets/images/walletTypes/flexibleMultisigBackground.svg b/src/renderer/shared/assets/images/walletTypes/flexibleMultisigBackground.svg new file mode 100644 index 0000000000..fcdae8e95e --- /dev/null +++ b/src/renderer/shared/assets/images/walletTypes/flexibleMultisigBackground.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/renderer/shared/core/index.ts b/src/renderer/shared/core/index.ts index c951f61e0a..d9c5f06109 100644 --- a/src/renderer/shared/core/index.ts +++ b/src/renderer/shared/core/index.ts @@ -13,6 +13,7 @@ export type { SingleShardWallet, MultiShardWallet, MultisigWallet, + FlexibleMultisigWallet, WatchOnlyWallet, WalletConnectWallet, NovaWalletWallet, @@ -30,6 +31,7 @@ export type { BaseAccount, ChainAccount, MultisigAccount, + FlexibleMultisigAccount, WcAccount, ProxiedAccount, ShardAccount, @@ -63,10 +65,17 @@ export type { PartialProxiedAccount, ProxyDeposits, ProxyGroup, + ProxyType, } from './types/proxy'; -export { ProxyType, ProxyVariant } from './types/proxy'; +export { ProxyVariant } from './types/proxy'; -export type { Notification, MultisigCreated, MultisigOperation, ProxyAction } from './types/notification'; +export type { + Notification, + MultisigCreated, + FlexibleMultisigCreated, + MultisigOperation, + ProxyAction, +} from './types/notification'; export { NotificationType } from './types/notification'; export { XcmPallets } from './types/substrate'; @@ -79,6 +88,7 @@ export type { DecodedTransaction, MultisigEvent, MultisigTransaction, + FlexibleMultisigTransaction, MultisigTransactionKey, TxWrapper, TxWrappers_OLD, diff --git a/src/renderer/shared/core/types/account.ts b/src/renderer/shared/core/types/account.ts index 41d19eb835..f6535f50b7 100644 --- a/src/renderer/shared/core/types/account.ts +++ b/src/renderer/shared/core/types/account.ts @@ -46,9 +46,17 @@ export type MultisigAccount = GenericAccount & { type: AccountType.MULTISIG; signatories: Signatory[]; threshold: MultisigThreshold; - chainId?: ChainId; + chainId: ChainId; + cryptoType: CryptoType; +}; + +export type FlexibleMultisigAccount = GenericAccount & { + type: AccountType.FLEXIBLE_MULTISIG; + signatories: Signatory[]; + threshold: MultisigThreshold; + chainId: ChainId; cryptoType: CryptoType; - creatorAccountId: AccountId; + proxyAccountId?: AccountId; // we have accountId only after proxy is created }; export type WcAccount = GenericAccount & { @@ -68,7 +76,14 @@ export type ProxiedAccount = GenericAccount & { cryptoType: CryptoType; }; -export type Account = BaseAccount | ChainAccount | ShardAccount | MultisigAccount | WcAccount | ProxiedAccount; +export type Account = + | BaseAccount + | ChainAccount + | ShardAccount + | MultisigAccount + | WcAccount + | ProxiedAccount + | FlexibleMultisigAccount; export type DraftAccount = Omit, 'accountId' | 'walletId' | 'baseId'>; @@ -77,6 +92,7 @@ export const enum AccountType { CHAIN = 'chain', SHARD = 'shard', MULTISIG = 'multisig', + FLEXIBLE_MULTISIG = 'flexible_multisig', WALLET_CONNECT = 'wallet_connect', PROXIED = 'proxied', } diff --git a/src/renderer/shared/core/types/notification.ts b/src/renderer/shared/core/types/notification.ts index c18891d697..3c0c855322 100644 --- a/src/renderer/shared/core/types/notification.ts +++ b/src/renderer/shared/core/types/notification.ts @@ -8,6 +8,8 @@ export const enum NotificationType { MULTISIG_EXECUTED = 'MultisigExecutedNotification', MULTISIG_CANCELLED = 'MultisigCancelledNotification', + FLEXIBLE_MULTISIG_CREATED = 'FlexibleMultisigCreatedNotification', + PROXY_CREATED = 'ProxyCreatedNotification', PROXY_REMOVED = 'ProxyRemovedNotification', } @@ -21,7 +23,6 @@ type BaseNotification = { type MultisigBaseNotification = BaseNotification & { multisigAccountId: AccountId; - originatorAccountId: AccountId; }; export type MultisigCreated = MultisigBaseNotification & { @@ -31,6 +32,14 @@ export type MultisigCreated = MultisigBaseNotification & { chainId: ChainId; }; +export type FlexibleMultisigCreated = MultisigBaseNotification & { + walletId: number; + signatories: AccountId[]; + threshold: number; + multisigAccountName: string; + chainId: ChainId; +}; + export type MultisigOperation = MultisigBaseNotification & { callHash: CallHash; callTimepoint: Timepoint; diff --git a/src/renderer/shared/core/types/proxy.ts b/src/renderer/shared/core/types/proxy.ts index 0104042902..1d690b1fc1 100644 --- a/src/renderer/shared/core/types/proxy.ts +++ b/src/renderer/shared/core/types/proxy.ts @@ -18,16 +18,15 @@ export type ProxyAccount = { delay: number; }; -export const enum ProxyType { - ANY = 'Any', - NON_TRANSFER = 'NonTransfer', - STAKING = 'Staking', - AUCTION = 'Auction', - CANCEL_PROXY = 'CancelProxy', - GOVERNANCE = 'Governance', - IDENTITY_JUDGEMENT = 'IdentityJudgement', - NOMINATION_POOLS = 'NominationPools', -} +export type ProxyType = + | 'Any' + | 'NonTransfer' + | 'Staking' + | 'Auction' + | 'CancelProxy' + | 'Governance' + | 'IdentityJudgement' + | 'NominationPools'; export const enum ProxyVariant { NONE = 'none', // temp value, until we not receive correct proxy variant diff --git a/src/renderer/shared/core/types/transaction.ts b/src/renderer/shared/core/types/transaction.ts index cfc08edd3a..7c246bf69b 100644 --- a/src/renderer/shared/core/types/transaction.ts +++ b/src/renderer/shared/core/types/transaction.ts @@ -113,6 +113,10 @@ export type MultisigTransaction = { transaction?: Transaction | DecodedTransaction; }; +export type FlexibleMultisigTransaction = MultisigTransaction & { + proxiedAccountId: AccountId; +}; + export type MultisigTransactionKey = Pick< MultisigTransaction, 'accountId' | 'callHash' | 'chainId' | 'indexCreated' | 'blockCreated' diff --git a/src/renderer/shared/core/types/wallet.ts b/src/renderer/shared/core/types/wallet.ts index 0d82781bf9..50c67f16a7 100644 --- a/src/renderer/shared/core/types/wallet.ts +++ b/src/renderer/shared/core/types/wallet.ts @@ -2,6 +2,7 @@ import { type Account, type BaseAccount, type ChainAccount, + type FlexibleMultisigAccount, type MultisigAccount, type ProxiedAccount, type ShardAccount, @@ -45,6 +46,13 @@ export interface MultisigWallet extends Wallet { accounts: MultisigAccount[]; } +// TODO: try to move signatories data out of account +export interface FlexibleMultisigWallet extends Wallet { + type: WalletType.FLEXIBLE_MULTISIG; + activated: boolean; + accounts: FlexibleMultisigAccount[]; +} + export interface ProxiedWallet extends Wallet { type: WalletType.PROXIED; accounts: ProxiedAccount[]; @@ -68,6 +76,7 @@ export const enum WalletType { WATCH_ONLY = 'wallet_wo', POLKADOT_VAULT = 'wallet_pv', MULTISIG = 'wallet_ms', + FLEXIBLE_MULTISIG = 'wallet_fxms', WALLET_CONNECT = 'wallet_wc', NOVA_WALLET = 'wallet_nw', PROXIED = 'wallet_pxd', @@ -87,6 +96,7 @@ export type SignableWalletFamily = export type WalletFamily = | WalletType.POLKADOT_VAULT | WalletType.MULTISIG + | WalletType.FLEXIBLE_MULTISIG | WalletType.WATCH_ONLY | WalletType.WALLET_CONNECT | WalletType.NOVA_WALLET diff --git a/src/renderer/shared/effector/attachToFeatureInput.test.ts b/src/renderer/shared/effector/attachToFeatureInput.test.ts index fb5e4402b7..ac65ed4731 100644 --- a/src/renderer/shared/effector/attachToFeatureInput.test.ts +++ b/src/renderer/shared/effector/attachToFeatureInput.test.ts @@ -8,7 +8,7 @@ describe('attachToFeatureInput', () => { const scope = fork(); const $input = createStore('hello'); const $store = createStore('world'); - const featureStatus = createFeature({ name: 'test', input: $input }); + const featureStatus = createFeature({ name: 'test/test', input: $input }); const combinedEvent = attachToFeatureInput(featureStatus, $store); const $testStore = createStore({}); @@ -42,7 +42,7 @@ describe('attachToFeatureInput', () => { const scope = fork(); const $input = createStore('hello'); const event = createEvent(); - const featureStatus = createFeature({ name: 'test', input: $input }); + const featureStatus = createFeature({ name: 'test/test', input: $input }); const combinedEvent = attachToFeatureInput(featureStatus, event); const $testStore = createStore({}); @@ -61,7 +61,7 @@ describe('attachToFeatureInput', () => { const scope = fork(); const $input = createStore('hello'); const event = createEvent(); - const featureStatus = createFeature({ name: 'test', input: $input }); + const featureStatus = createFeature({ name: 'test/test', input: $input }); const combinedEvent = attachToFeatureInput(featureStatus, event); const $testStore = createStore({}); @@ -80,7 +80,7 @@ describe('attachToFeatureInput', () => { const scope = fork(); const $input = createStore('hello'); const event = createEvent(); - const featureStatus = createFeature({ name: 'test', input: $input }); + const featureStatus = createFeature({ name: 'test/test', input: $input }); const combinedEvent = attachToFeatureInput(featureStatus, event); const $testStore = createStore({}); @@ -100,7 +100,7 @@ describe('attachToFeatureInput', () => { const scope = fork(); const $input = createStore('hello'); const event = createEvent(); - const featureStatus = createFeature({ name: 'test', input: $input }); + const featureStatus = createFeature({ name: 'test/test', input: $input }); const combinedEvent = attachToFeatureInput(featureStatus, event); const $updates = createStore(0); diff --git a/src/renderer/shared/effector/createFeature.test.tsx b/src/renderer/shared/effector/createFeature.test.tsx index 459001ff5d..5213d3c977 100644 --- a/src/renderer/shared/effector/createFeature.test.tsx +++ b/src/renderer/shared/effector/createFeature.test.tsx @@ -10,7 +10,7 @@ describe('createFeature', () => { it('should work', async () => { const scope = fork(); const $input = createStore<{ ready: true } | null>(null); - const featureStatus = createFeature({ name: 'test', input: $input }); + const featureStatus = createFeature({ name: 'test/test', input: $input }); await allSettled(featureStatus.start, { scope }); @@ -33,12 +33,12 @@ describe('createFeature', () => { expect(scope.getState(featureStatus.state)).toEqual({ status: 'running', data: { ready: true } }); - await allSettled(featureStatus.fail, { scope, params: { type: 'fatal', error: new Error('test') } }); + await allSettled(featureStatus.fail, { scope, params: { type: 'fatal', error: new Error('test/test') } }); expect(scope.getState(featureStatus.state)).toEqual({ status: 'failed', type: 'fatal', - error: new Error('test'), + error: new Error('test/test'), data: { ready: true }, }); @@ -51,7 +51,7 @@ describe('createFeature', () => { const scope = fork(); const pipeline = createPipeline(); - const featureStatus = createFeature({ name: 'test', scope }); + const featureStatus = createFeature({ name: 'test/test', scope }); await allSettled(featureStatus.start, { scope }); featureStatus.inject(pipeline, (list, meta) => list.concat('1', meta)); @@ -63,7 +63,7 @@ describe('createFeature', () => { const scope = fork(); const slot = createSlot(); - const featureStatus = createFeature({ name: 'test', scope }); + const featureStatus = createFeature({ name: 'test/test', scope }); featureStatus.inject(slot, () => feature); featureStatus.inject(slot, { @@ -88,7 +88,7 @@ describe('createFeature', () => { const slot = createSlot(); const $input = createStore<{ ready: true }>({ ready: true }); - const featureStatus = createFeature({ name: 'test', input: $input, scope }); + const featureStatus = createFeature({ name: 'test/test', input: $input, scope }); featureStatus.inject(slot, () => feature); @@ -106,7 +106,7 @@ describe('createFeature', () => { const slot = createSlot(); const $input = createStore<{ ready: true }>({ ready: true }); - const featureStatus = createFeature({ name: 'test', input: $input, enable: createStore(false), scope }); + const featureStatus = createFeature({ name: 'test/test', input: $input, enable: createStore(false), scope }); featureStatus.inject(slot, () => feature); diff --git a/src/renderer/shared/effector/createFeature.ts b/src/renderer/shared/effector/createFeature.ts index bd3b94b6b8..8d71e3b51f 100644 --- a/src/renderer/shared/effector/createFeature.ts +++ b/src/renderer/shared/effector/createFeature.ts @@ -17,7 +17,7 @@ import { nonNullable, nullable } from '@/shared/lib/utils'; // TODO implement features dependency graph (and somehow merge it with input store, i like that idea). type Params = { - name: string; + name: `${Uncapitalize}/${Uncapitalize}`; enable?: Store; input?: Store; filter?: (input: T) => IdleState | Omit, 'data'> | null; @@ -273,6 +273,8 @@ export const createFeature = ({ // Combine return { + name, + status: readonly($status), state: readonly($state), input: readonly($input), diff --git a/src/renderer/shared/effector/index.ts b/src/renderer/shared/effector/index.ts index 9a39b47a4f..2644628a2f 100644 --- a/src/renderer/shared/effector/index.ts +++ b/src/renderer/shared/effector/index.ts @@ -2,5 +2,6 @@ export { createDataSource } from './createDataSource'; export { createFeature } from './createFeature'; export { createDataSubscription, createPagesHandler } from './createDataSubscription'; export { attachToFeatureInput } from './attachToFeatureInput'; +export { registerFeatures } from './registerFeatures'; export { series } from './helpers/series'; diff --git a/src/renderer/shared/effector/registerFeatures.ts b/src/renderer/shared/effector/registerFeatures.ts new file mode 100644 index 0000000000..9d636d135c --- /dev/null +++ b/src/renderer/shared/effector/registerFeatures.ts @@ -0,0 +1,32 @@ +import { type Feature } from './createFeature'; + +export const registerFeatures = (features: Feature[]) => { + // Basically groupBy + const domains = features.reduce[]>>((acc, feature) => { + const name = feature.name.split('/').at(0) ?? 'unknown'; + + if (!acc[name]) { + acc[name] = []; + } + + acc[name].push(feature); + + return acc; + }, {}); + + const sorted = Object.entries(domains) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([domain, features]) => { + return [domain, features.sort((a, b) => a.name.localeCompare(b.name))] as const; + }); + + console.groupCollapsed('Registered features'); + for (const [domain, features] of sorted) { + console.groupCollapsed(domain); + for (const feature of features) { + console.log(feature.name.split('/').at(1) ?? 'unknown'); + } + console.groupEnd(); + } + console.groupEnd(); +}; diff --git a/src/renderer/shared/i18n/locales/en.json b/src/renderer/shared/i18n/locales/en.json index c10c0b722f..1aae4ccff8 100644 --- a/src/renderer/shared/i18n/locales/en.json +++ b/src/renderer/shared/i18n/locales/en.json @@ -128,6 +128,7 @@ "contactsTab": "Contacts", "continueButton": "Continue", "create": "Create", + "createMultisigWallet": "Create multisig wallet", "createionStatusTitle": "Mulitisig creation", "disabledError": { "differentAccounts": "Wallets with different accounts for chains are not supported", @@ -138,14 +139,14 @@ "duplicateSignatoryErrorTitle": "Duplicate signatory", "duplicateSignatoryErrorText": "The list must consist of unique signatories", "errorMessage": "Something went wrong", - "multiChain": { - "description": "Creation is free, the wallet works on all networks", - "featureFour": "Custom voting threshold", - "featureOne": "Any chain", - "featureThree": "Mutliple signers", - "featureTwo": "Some wallets are not supported", - "title": "Multi-chain multisig" - }, + "flexibleMultisig": { + "flexible": "Flexible multisig", + "notEnoughMultisigTokens": "Not enough tokens for paying multisig deposit, proxy deposit and network fee on a selected account", + "proxyDeposit": "Proxy deposit:", + "signatoryThresholdDescription": "Assign the signatories and set the signatory threshold for your flexible multisig.", + "title": "Create flexible multisig" + }, + "multisig": "Multisig", "multisigCreationFeeLabel": "Additional cost", "multisigDeposit": "Multisig deposit:", "multisigExistText": "A multisig wallet with the selected signatories and threshold already exists. You can try to create a multisig on another network.", @@ -174,6 +175,17 @@ "ownAccountSelection": "Address", "restoreButton": "Restore", "searchContactPlaceholder": "Search", + "selectMultisigDescription": { + "flexibleDescription": "Can modify signatories", + "regularDescription": "Regular multisig wallet", + "featureOne": "Multiple Signatories required to approve transactions", + "featureTwo": "Set custom signatory threshold which must be met for transactions to be executed", + "featureThree": "Delegate authority over certain operations", + "featureFour": "Change signatories and threshold at any time", + "featureFive": "Address does not change when changing signatories", + "flexibleNote": "Pure Proxy deposit will be reserved on your account. This wallet only works on the network it was created on.", + "regularNote": "Only multisig deposit is required. This wallet only works on the network it was created on." + }, "selectSigner": "Select signer", "selectedSignatoriesTitle": "Selected signatories", "signatoriesDescription": "Add signatories to your multisig account", @@ -185,14 +197,6 @@ "signatoryTitle": "Select signatories", "signingWallet": "Signing wallet", "signingWith": "Signing with", - "singleChain": { - "description": "Creation is free, the wallet can work on one network only", - "featureFour": "Custom voting threshold", - "featureOne": "Single chain", - "featureThree": "Mutliple signers", - "featureTwo": "Use any wallet", - "title": "Single-chain multisig" - }, "successMessage": "Wallet added", "thresholdDescription": "Set the threshold of signatories required to execute a transaction", "thresholdErrorTitle": "Invalid threshold", @@ -296,6 +300,7 @@ "addNewAccountButton": "Add new account", "createAccount": "Create a new account", "createMultisig": "Create a new multisig wallet", + "createFlexibleMultisig": "Create a new flexible multisig wallet", "createOrImportAccount": "Create a new account or import an existing one" }, "fallbackScreen": { @@ -404,11 +409,6 @@ "signerLabel": "Select signer", "transferableLabel": "Transferable" }, - "passwordInput": { - "passwordLabel": "Password", - "passwordPlaceholder": "Enter password", - "passwordVisibilityButton": "Show password" - }, "title": { "appName": "Nova Spektr", "inactiveNetwork": "Network is inactive, please check the connection in network settings" @@ -781,7 +781,8 @@ "proxyCreatedTitle": "New delegated authority wallet added", "proxyRemovedDetails": "  is no longer available for your {name}  to control {operations}", "proxyRemovedTitle": "Delegated authority wallet has been removed", - "proxyWalletAction": "{name}  with
    {address}
    " + "proxyWalletAction": "{name}  with
    {address}
    ", + "flexibleMultisigWalletSignAction": "Sign operation" }, "noNotificationsDescription": "You don't have notifications yet", "title": "Notifications" @@ -1155,7 +1156,8 @@ "identityJudgement": "Judgement operations", "nominationPools": "Nominations operations", "nonTransfer": "All, except transfer operations", - "staking": "Staking operations" + "staking": "Staking operations", + "unknown": "Unknown operations" }, "operations": { "any": "all operations", @@ -1659,10 +1661,14 @@ "walletRemoved": "Wallet removed" }, "multisig": { + "accountGroup": "Account", + "accountTab": "Account and signatories", "accountsGroup": "Your accounts", "contactsGroup": "Contacts", "networksTab": "Networks", + "signatoriesGroup": "Signatories { amount }", "signatoriesTab": "Signatories", + "singleChainTitle": "on with threshold { threshold } out of { signatories } signatories", "thresholdLabel": "Threshold { min } out of { max }", "walletsGroup": "Your wallets" }, @@ -1701,6 +1707,7 @@ "disconnectedLabel": "Disconnected", "multishardLabel": "Multishard", "multisigLabel": "Multisig", + "flexibleMultisigLabel": "Flexible Multisig", "novaWalletLabel": "Nova Wallet", "paritySignerLabel": "Polkadot Vault", "proxiedLabel": "Delegated to you (Proxied)", diff --git a/src/renderer/shared/lib/hooks/useLooseRef.ts b/src/renderer/shared/lib/hooks/useLooseRef.ts index f91016329b..ccb4dfe5dd 100644 --- a/src/renderer/shared/lib/hooks/useLooseRef.ts +++ b/src/renderer/shared/lib/hooks/useLooseRef.ts @@ -1,8 +1,8 @@ import { useMemo, useRef } from 'react'; /** - * Saves value outside react rendering cycle and returns getter function. - * Similar to useRef + `ref.current = value` with simplier api. + * Saves value outside of React rendering cycle and returns getter function. + * Similar to useRef + `ref.current = value` with simpler api. * * @param value - Value to save, will rewrite older value on each rerender. * @param fn - Optional mapping function. diff --git a/src/renderer/shared/lib/utils/__tests__/arrays.test.ts b/src/renderer/shared/lib/utils/__tests__/arrays.test.ts index 58a82a4caa..b738d370dd 100644 --- a/src/renderer/shared/lib/utils/__tests__/arrays.test.ts +++ b/src/renderer/shared/lib/utils/__tests__/arrays.test.ts @@ -1,6 +1,6 @@ -import { addUnique, merge, splice } from '../arrays'; +import { addUnique, dictionary, merge, splice } from '../arrays'; -describe('shared/lib/onChainUtils/arrays', () => { +describe('Arrays utils', () => { test('should insert element in the beginning', () => { const array = splice([1, 2, 3], 100, 0); expect(array).toEqual([100, 2, 3]); @@ -23,126 +23,204 @@ describe('shared/lib/onChainUtils/arrays', () => { expect(array1).toEqual([100]); expect(array2).toEqual([100]); }); +}); + +describe('addUniq', () => { + test('should replace element', () => { + const array = addUnique([1, 2, 3], 2); + expect(array).toEqual([1, 2, 3]); + }); + + test('should add new element', () => { + const array = addUnique([1, 2, 3], 4); + expect(array).toEqual([1, 2, 3, 4]); + }); - describe('addUniq', () => { - test('should replace element', () => { - const array = addUnique([1, 2, 3], 2); - expect(array).toEqual([1, 2, 3]); + test('should replace element according to compare function', () => { + const array = addUnique([{ id: 1 }, { id: 2 }], { id: 2, name: 'test' }, (x) => x.id); + expect(array).toEqual([{ id: 1 }, { id: 2, name: 'test' }]); + }); + + test('should add element according to compare function', () => { + const array = addUnique([{ id: 1 }, { id: 2 }], { id: 3 }, (x) => x.id); + expect(array).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); +}); + +describe('merge', () => { + test('should array of strings', () => { + const list1 = ['1', '2', '3', '4']; + const list2 = ['2', '5']; + + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s, + }); + expect(res).toEqual(['1', '2', '3', '4', '5']); + }); + + test('should return firrt array if second is empty', () => { + const list1 = ['1', '2', '3', '4']; + + const res = merge({ + a: list1, + b: [], + mergeBy: (s) => s, }); + expect(res).toBe(list1); + }); + + test('should return second array if first is empty', () => { + const list2 = ['1', '2', '3', '4']; - test('should add new element', () => { - const array = addUnique([1, 2, 3], 4); - expect(array).toEqual([1, 2, 3, 4]); + const res = merge({ + a: [], + b: list2, + mergeBy: (s) => s, }); + expect(res).toBe(list2); + }); + + test('should sort', () => { + const list1 = [2, 4, 3]; + const list2 = [1, 5]; - test('should replace element according to compare function', () => { - const array = addUnique([{ id: 1 }, { id: 2 }], { id: 2, name: 'test' }, (x) => x.id); - expect(array).toEqual([{ id: 1 }, { id: 2, name: 'test' }]); + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s, + sort: (a, b) => a - b, }); + expect(res).toEqual([1, 2, 3, 4, 5]); + }); - test('should add element according to compare function', () => { - const array = addUnique([{ id: 1 }, { id: 2 }], { id: 3 }, (x) => x.id); - expect(array).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + test('should merge objects', () => { + const list1 = [{ id: 1 }, { id: 4 }, { id: 5 }]; + const list2 = [{ id: 3 }, { id: 2 }, { id: 3, test: true }, { id: 6 }, { id: 7 }]; + + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s.id, }); + expect(res).toEqual([{ id: 1 }, { id: 2 }, { id: 3, test: true }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }]); }); - describe('merge', () => { - it('should array of strings', () => { - const list1 = ['1', '2', '3', '4']; - const list2 = ['2', '5']; + test('should merge and sort objects', () => { + const list1 = [{ id: 1 }, { id: 5 }, { id: 4 }]; + const list2 = [{ id: 3 }, { id: 2 }]; - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s, - }); - expect(res).toEqual(['1', '2', '3', '4', '5']); + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s.id, + sort: (a, b) => b.id - a.id, }); + expect(res).toEqual([{ id: 5 }, { id: 4 }, { id: 3 }, { id: 2 }, { id: 1 }]); + }); - it('should return firrt array if second is empty', () => { - const list1 = ['1', '2', '3', '4']; + test('should sort objects by complex value', () => { + const list1 = [ + { id: 1, date: new Date(1) }, + { id: 5, date: new Date(5) }, + { id: 4, date: new Date(4) }, + ]; + const list2 = [ + { id: 3, date: new Date(3) }, + { id: 2, date: new Date(2) }, + ]; + + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s.id, + sort: (a, b) => a.date.getTime() - b.date.getTime(), + }); + expect(res).toEqual([ + { id: 1, date: new Date(1) }, + { id: 2, date: new Date(2) }, + { id: 3, date: new Date(3) }, + { id: 4, date: new Date(4) }, + { id: 5, date: new Date(5) }, + ]); + }); +}); - const res = merge({ - a: list1, - b: [], - mergeBy: (s) => s, - }); - expect(res).toBe(list1); +describe('dictionary', () => { + type TestData = { + id: number; + name: string; + value?: string; + }; + + const data: TestData[] = [ + { id: 1, name: 'Alice', value: 'Developer' }, + { id: 2, name: 'Bob', value: 'Designer' }, + { id: 3, name: 'Charlie', value: 'Manager' }, + ]; + + test('should create a dictionary with no transformer provided', () => { + const result = dictionary(data, 'id'); + + expect(result).toEqual({ + 1: { id: 1, name: 'Alice', value: 'Developer' }, + 2: { id: 2, name: 'Bob', value: 'Designer' }, + 3: { id: 3, name: 'Charlie', value: 'Manager' }, }); + }); - it('should return second array if first is empty', () => { - const list2 = ['1', '2', '3', '4']; + test('should create a dictionary using transformer function', () => { + const result = dictionary(data, 'id', (item) => item.name); - const res = merge({ - a: [], - b: list2, - mergeBy: (s) => s, - }); - expect(res).toBe(list2); + expect(result).toEqual({ + 1: 'Alice', + 2: 'Bob', + 3: 'Charlie', }); + }); + + test('should create a dictionary with a plain value as transformer', () => { + const result = dictionary(data, 'id', 'constant value'); - it('should sort', () => { - const list1 = [2, 4, 3]; - const list2 = [1, 5]; - - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s, - sort: (a, b) => a - b, - }); - expect(res).toEqual([1, 2, 3, 4, 5]); + expect(result).toEqual({ + 1: 'constant value', + 2: 'constant value', + 3: 'constant value', }); + }); + + test('should skip items where the key is undefined or missing', () => { + const incompleteData = [ + { id: undefined, name: 'Alice', value: 'Developer' }, // undefined 'id' + { name: 'Bob', value: 'Designer' }, // Missing 'id' + { id: 3, name: 'Charlie' }, + ] as TestData[]; - it('should merge objects', () => { - const list1 = [{ id: 1 }, { id: 4 }, { id: 5 }]; - const list2 = [{ id: 3 }, { id: 2 }, { id: 3, test: true }, { id: 6 }, { id: 7 }]; + const result = dictionary(incompleteData, 'id', 'constant value'); - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s.id, - }); - expect(res).toEqual([{ id: 1 }, { id: 2 }, { id: 3, test: true }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }]); + expect(result).toEqual({ + 3: 'constant value', }); + }); + + test('should handle cases where transformer is undefined', () => { + const result = dictionary(data, 'id', undefined); - it('should merge and sort objects', () => { - const list1 = [{ id: 1 }, { id: 5 }, { id: 4 }]; - const list2 = [{ id: 3 }, { id: 2 }]; - - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s.id, - sort: (a, b) => b.id - a.id, - }); - expect(res).toEqual([{ id: 5 }, { id: 4 }, { id: 3 }, { id: 2 }, { id: 1 }]); + expect(result).toEqual({ + 1: { id: 1, name: 'Alice', value: 'Developer' }, + 2: { id: 2, name: 'Bob', value: 'Designer' }, + 3: { id: 3, name: 'Charlie', value: 'Manager' }, }); + }); + + test('should handle complex transformer functions', () => { + const result = dictionary(data, 'id', (item) => `${item.name}: ${item.value}`); - it('should sort objects by complex value', () => { - const list1 = [ - { id: 1, date: new Date(1) }, - { id: 5, date: new Date(5) }, - { id: 4, date: new Date(4) }, - ]; - const list2 = [ - { id: 3, date: new Date(3) }, - { id: 2, date: new Date(2) }, - ]; - - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s.id, - sort: (a, b) => a.date.getTime() - b.date.getTime(), - }); - expect(res).toEqual([ - { id: 1, date: new Date(1) }, - { id: 2, date: new Date(2) }, - { id: 3, date: new Date(3) }, - { id: 4, date: new Date(4) }, - { id: 5, date: new Date(5) }, - ]); + expect(result).toEqual({ + 1: 'Alice: Developer', + 2: 'Bob: Designer', + 3: 'Charlie: Manager', }); }); }); diff --git a/src/renderer/shared/lib/utils/arrays.ts b/src/renderer/shared/lib/utils/arrays.ts index b76e16a5c0..2e29369b7b 100644 --- a/src/renderer/shared/lib/utils/arrays.ts +++ b/src/renderer/shared/lib/utils/arrays.ts @@ -16,34 +16,37 @@ export function splice(collection: T[], item: T, position: number): T[] { } /** - * Create dictionary with given key and value Keys can only be type of string, - * number or symbol + * Create dictionary with given key and transformer value. Keys can only be type + * of string, number or symbol * * @param collection Array of items - * @param property Field to be used as key - * @param predicate Transformer function + * @param key Property to be used as key + * @param transformer Transformer function or plain value * * @returns {Object} */ -export function dictionary, K extends KeysOfType>( +export function dictionary, K extends KeysOfType, R = T>( collection: T[], - property: K, - predicate?: (item: T) => any, -): Record { - return collection.reduce( - (acc, item) => { - const element = item[property]; - - if (predicate) { - acc[element] = predicate(item); - } else { - acc[element] = item; - } - - return acc; - }, - {} as Record, - ); + key: K, + transformer?: ((item: T) => R) | R, +): Record { + const result: Record = {} as Record; + + for (const item of collection) { + const element = item[key]; + + if (!element) continue; + + if (!transformer) { + result[element] = item as unknown as R; + } else if (typeof transformer === 'function') { + result[element] = (transformer as (item: T) => R)(item); + } else { + result[element] = transformer as R; + } + } + + return result; } export function getRepeatedIndex(index: number, base: number): number { diff --git a/src/renderer/shared/lib/utils/step.ts b/src/renderer/shared/lib/utils/step.ts index ce7730d72c..8cae1f84c5 100644 --- a/src/renderer/shared/lib/utils/step.ts +++ b/src/renderer/shared/lib/utils/step.ts @@ -6,9 +6,14 @@ export const enum Step { SIGN, SUBMIT, BASKET, + // Delegation LIST, SELECT_TRACK, CUSTOM_DELEGATION, + // Multisig + NAME_NETWORK, + SIGNATORIES_THRESHOLD, + SIGNER_SELECTION, } /** diff --git a/src/renderer/shared/lib/utils/strings.ts b/src/renderer/shared/lib/utils/strings.ts index 3ce492aecb..5bfa36117d 100644 --- a/src/renderer/shared/lib/utils/strings.ts +++ b/src/renderer/shared/lib/utils/strings.ts @@ -92,7 +92,7 @@ export const includesMultiple = (values: (string | undefined)[], searchString = * * @returns {String} */ -export const truncate = (text: string, start = 5, end = 5): string => { +export const truncate = (text: string, start = 5, end = start): string => { if (text.length <= start + end) return text; return `${text.slice(0, start)}...${text.slice(-1 * end)}`; diff --git a/src/renderer/shared/mocks/index.ts b/src/renderer/shared/mocks/index.ts index 5964b5d414..e538148ed4 100644 --- a/src/renderer/shared/mocks/index.ts +++ b/src/renderer/shared/mocks/index.ts @@ -13,7 +13,6 @@ import { type PolkadotVaultWallet, type ProxiedAccount, type ProxiedWallet, - ProxyType, ProxyVariant, type ShardAccount, SigningType, @@ -94,7 +93,7 @@ export const createProxiedAccount = (id: number = Math.round(Math.random() * 10) accountId: createAccountId(`Proxied account ${id}`), proxyAccountId: createAccountId(`Random account ${id}`), delay: 0, - proxyType: ProxyType.ANY, + proxyType: 'Any', proxyVariant: ProxyVariant.REGULAR, chainId: polkadotChainId, cryptoType: CryptoType.SR25519, diff --git a/src/renderer/shared/pallet/identity/storage.ts b/src/renderer/shared/pallet/identity/storage.ts index 6b207d72ec..78e770a656 100644 --- a/src/renderer/shared/pallet/identity/storage.ts +++ b/src/renderer/shared/pallet/identity/storage.ts @@ -1,4 +1,5 @@ import { type ApiPromise } from '@polkadot/api'; +import { zipWith } from 'lodash'; import { z } from 'zod'; import { substrateRpcPool } from '@/shared/api/substrate-helpers'; @@ -56,14 +57,10 @@ export const storage = { return substrateRpcPool .call(() => getQuery(api, 'identityOf').multi(accounts)) - .then(response => { - const data = schema.parse(response); - - return accounts.map((account, index) => ({ account, identity: data[index] ?? null })); - }); + .then(schema.parse) + .then(response => zipWith(accounts, response, (account, identity) => ({ account, identity }))); }, - // TODO implement /** * Usernames that an authority has granted, but that the account controller * has not confirmed that they want it. Used primarily in cases where the 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/pallet/proxy/consts.ts b/src/renderer/shared/pallet/proxy/consts.ts new file mode 100644 index 0000000000..a94969f57b --- /dev/null +++ b/src/renderer/shared/pallet/proxy/consts.ts @@ -0,0 +1,71 @@ +import { type ApiPromise } from '@polkadot/api'; + +import { pjsSchema } from '@/shared/polkadotjs-schemas'; + +const getPallet = (api: ApiPromise) => { + const pallet = api.consts['proxy']; + if (!pallet) { + throw new TypeError('proxy pallet not found'); + } + + return pallet; +}; + +export const consts = { + /** + * The base amount of currency needed to reserve for creating an announcement. + * + * This is held when a new storage item holding a `Balance` is created + * (typically 16 bytes). + */ + announcementDepositBase(api: ApiPromise) { + return pjsSchema.u128.parse(getPallet(api)['announcementDepositBase']); + }, + + /** + * The amount of currency needed per announcement made. + * + * This is held for adding an `AccountId`, `Hash` and `BlockNumber` (typically + * 68 bytes) into a pre-existing storage value. + */ + announcementDepositFactor(api: ApiPromise) { + return pjsSchema.u128.parse(getPallet(api)['announcementDepositFactor']); + }, + + /** + * The maximum amount of time-delayed announcements that are allowed to be + * pending. + */ + maxPending(api: ApiPromise) { + return pjsSchema.u32.parse(getPallet(api)['maxPending']); + }, + + /** + * The maximum amount of proxies allowed for a single account. + */ + maxProxies(api: ApiPromise) { + return pjsSchema.u32.parse(getPallet(api)['maxProxies']); + }, + + /** + * The base amount of currency needed to reserve for creating a proxy. + * + * This is held for an additional storage item whose value size is + * `sizeof(Balance)` bytes and whose key size is `sizeof(AccountId)` bytes. + */ + proxyDepositBase(api: ApiPromise) { + return pjsSchema.u128.parse(getPallet(api)['proxyDepositBase']); + }, + + /** + * The amount of currency needed per proxy added. + * + * This is held for adding 32 bytes plus an instance of `ProxyType` more into + * a pre-existing storage value. Thus, when configuring `ProxyDepositFactor` + * one should take into account `32 + proxy_type.encode().len()` bytes of + * data. + */ + proxyDepositFactor(api: ApiPromise) { + return pjsSchema.u128.parse(getPallet(api)['proxyDepositFactor']); + }, +}; diff --git a/src/renderer/shared/pallet/proxy/index.ts b/src/renderer/shared/pallet/proxy/index.ts new file mode 100644 index 0000000000..58633fb4c5 --- /dev/null +++ b/src/renderer/shared/pallet/proxy/index.ts @@ -0,0 +1,11 @@ +import { consts } from './consts'; +import * as schema from './schema'; +import { storage } from './storage'; + +export const proxyPallet = { + consts, + schema, + storage, +}; + +export type { KitchensinkRuntimeProxyType, ProxyProxyDefinition, ProxyAnnouncement } from './schema'; diff --git a/src/renderer/shared/pallet/proxy/schema.ts b/src/renderer/shared/pallet/proxy/schema.ts new file mode 100644 index 0000000000..5b46970d47 --- /dev/null +++ b/src/renderer/shared/pallet/proxy/schema.ts @@ -0,0 +1,46 @@ +import { type z } from 'zod'; + +import { pjsSchema } from '@/shared/polkadotjs-schemas'; + +export type KitchensinkRuntimeProxyType = z.infer; +export const kitchensinkRuntimeProxyType = pjsSchema.enumType( + 'Any', + 'NonTransfer', + 'NonCritical', + 'NonFungibile', + 'Governance', + 'Staking', + 'Identity', + 'IdentityJudgement', + 'Society', + 'Senate', + 'Triumvirate', + 'Transfer', + 'Assets', + 'AssetOwner', + 'AssetManager', + 'Collator', + 'Nomination', + 'NominationPools', + 'Auction', + 'CancelProxy', + 'Registration', + 'SudoBalances', + 'Balances', + 'AuthorMapping', + 'Spokesperson', +); + +export type ProxyProxyDefinition = z.infer; +export const proxyProxyDefinition = pjsSchema.object({ + delegate: pjsSchema.accountId, + delay: pjsSchema.blockHeight, + proxyType: kitchensinkRuntimeProxyType, +}); + +export type ProxyAnnouncement = z.infer; +export const proxyAnnouncement = pjsSchema.object({ + real: pjsSchema.accountId, + callHash: pjsSchema.hex, + height: pjsSchema.blockHeight, +}); diff --git a/src/renderer/shared/pallet/proxy/storage.ts b/src/renderer/shared/pallet/proxy/storage.ts new file mode 100644 index 0000000000..e4caf6137c --- /dev/null +++ b/src/renderer/shared/pallet/proxy/storage.ts @@ -0,0 +1,83 @@ +import { type ApiPromise } from '@polkadot/api'; +import { zipWith } from 'lodash'; +import { z } from 'zod'; + +import { substrateRpcPool } from '@/shared/api/substrate-helpers'; +import { type AccountId, pjsSchema } from '@/shared/polkadotjs-schemas'; + +import { proxyAnnouncement, proxyProxyDefinition } from './schema'; + +const getQuery = (api: ApiPromise, name: string) => { + const pallet = api.query['proxy']; + if (!pallet) { + throw new TypeError(`proxy 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 = { + /** + * The announcements made by the proxy (key). + */ + announcements(api: ApiPromise, accounts?: AccountId[]) { + const recordSchema = pjsSchema.tupleMap( + ['announcements', pjsSchema.vec(proxyAnnouncement)], + ['deposit', pjsSchema.u64], + ); + + if (accounts && accounts.length > 0) { + const schema = pjsSchema.vec(recordSchema); + + return substrateRpcPool + .call(() => getQuery(api, 'announcements').multi(accounts)) + .then(schema.parse) + .then(result => zipWith(accounts, result, (account, value) => ({ account, value }))); + } + + const schema = pjsSchema.vec( + pjsSchema.tupleMap( + ['account', pjsSchema.storageKey(pjsSchema.accountId).transform(([account]) => account)], + ['value', recordSchema], + ), + ); + + return substrateRpcPool.call(() => getQuery(api, 'announcements').entries()).then(schema.parse); + }, + + /** + * The set of account proxies. Maps the account which has delegated to the + * accounts which are being delegated to, together with the amount held on + * deposit. + */ + proxies(api: ApiPromise, accounts?: AccountId[]) { + const recordSchema = pjsSchema.tupleMap( + ['accounts', pjsSchema.vec(proxyProxyDefinition)], + ['deposit', z.union([pjsSchema.u128, pjsSchema.u64])], + ); + + if (accounts && accounts.length > 0) { + const schema = pjsSchema.vec(recordSchema); + + return substrateRpcPool + .call(() => getQuery(api, 'proxies').entries()) + .then(schema.parse) + .then(result => zipWith(accounts, result, (account, value) => ({ account, value }))); + } + + const schema = pjsSchema.vec( + pjsSchema.tupleMap( + ['account', pjsSchema.storageKey(pjsSchema.accountId).transform(([accountId]) => accountId)], + ['value', recordSchema], + ), + ); + + return substrateRpcPool.call(() => getQuery(api, 'proxies').entries()).then(schema.parse); + }, +}; diff --git a/src/renderer/shared/pallet/referenda/storage.ts b/src/renderer/shared/pallet/referenda/storage.ts index ebe48077ab..a6a3002c66 100644 --- a/src/renderer/shared/pallet/referenda/storage.ts +++ b/src/renderer/shared/pallet/referenda/storage.ts @@ -1,4 +1,5 @@ import { type ApiPromise } from '@polkadot/api'; +import { zipWith } from 'lodash'; import { substrateRpcPool } from '@/shared/api/substrate-helpers'; import { polkadotjsHelpers } from '@/shared/polkadotjs-helpers'; @@ -46,7 +47,7 @@ export const storage = { if (ids) { const schemaWithIds = pjsSchema .vec(pjsSchema.optional(referendaReferendumInfoConvictionVotingTally)) - .transform(items => items.map((item, index) => ({ info: item, id: ids[index]! }))); + .transform(items => zipWith(ids, items, (id, info) => ({ id, info }))); return substrateRpcPool.call(() => getQuery(type, api, 'referendumInfoFor').multi(ids)).then(schemaWithIds.parse); } else { 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 511ec5e6ff..f68f0a4691 100644 --- a/src/renderer/shared/polkadotjs-schemas/index.ts +++ b/src/renderer/shared/polkadotjs-schemas/index.ts @@ -1,4 +1,4 @@ -import { isCorrectAccountId } from '@/shared/lib/utils'; +import { isCorrectAccountId, isEthereumAccountId } from '@/shared/lib/utils'; import { type AccountId, @@ -10,6 +10,7 @@ import { bytesSchema, bytesString, dataStringSchema, + hexSchema, i64Schema, nullSchema, perbillSchema, @@ -56,6 +57,8 @@ export const pjsSchema = { blockHeight: blockHeightSchema, structHex: structHexSchema, dataString: dataStringSchema, + hex: hexSchema, + u8Array: hexSchema, object: objectSchema, optional: optionalSchema, @@ -68,7 +71,7 @@ export const pjsSchema = { helpers: { toAccountId: (value: string) => { - if (isCorrectAccountId(value as AccountId)) { + if (isCorrectAccountId(value as AccountId) || isEthereumAccountId(value as AccountId)) { return value as AccountId; } diff --git a/src/renderer/shared/polkadotjs-schemas/primitives.ts b/src/renderer/shared/polkadotjs-schemas/primitives.ts index 3792d73b0b..b135025268 100644 --- a/src/renderer/shared/polkadotjs-schemas/primitives.ts +++ b/src/renderer/shared/polkadotjs-schemas/primitives.ts @@ -1,10 +1,26 @@ -import { Bytes, Data, Null, StorageKey, Struct, Text, bool, i64, u128, u16, u32, u64, u8 } from '@polkadot/types'; -import { GenericAccountId } from '@polkadot/types/generic/AccountId'; +import { + Bytes, + Data, + GenericAccountId, + GenericEthereumAccountId, + Null, + Raw, + StorageKey, + Struct, + Text, + bool, + i64, + u128, + u16, + u32, + u64, + u8, +} from '@polkadot/types'; import { type Perbill, type Permill } from '@polkadot/types/interfaces'; -import { BN, u8aToString } from '@polkadot/util'; +import { BN, u8aToHex, u8aToString } from '@polkadot/util'; import { z } from 'zod'; -import { isCorrectAccountId } from '@/shared/lib/utils'; +import { isCorrectAccountId, isEthereumAccountId } from '@/shared/lib/utils'; export const storageKeySchema = (...schema: T) => { const argsSchema = z.tuple(schema); @@ -56,16 +72,18 @@ export const dataStringSchema = z .instanceof(Data) .transform((value) => (value.isRaw ? u8aToString(value.asRaw) : value.value.toString())); +export const hexSchema = z.instanceof(Raw).transform((value) => u8aToHex(value.hash)); + export type BlockHeight = z.infer; export const blockHeightSchema = u32Schema.describe('blockHeight').brand('blockHeight'); export type AccountId = z.infer; export const accountIdSchema = z - .instanceof(GenericAccountId) + .union([z.instanceof(GenericAccountId), z.instanceof(GenericEthereumAccountId)]) .transform((value, ctx) => { const account = value.toHex(); if (account.startsWith('0x')) { - if (isCorrectAccountId(account)) { + if (isCorrectAccountId(account) || isEthereumAccountId(account)) { return account; } diff --git a/src/renderer/shared/transactions/createDepositCalculator.ts b/src/renderer/shared/transactions/createDepositCalculator.ts new file mode 100644 index 0000000000..391c1646ba --- /dev/null +++ b/src/renderer/shared/transactions/createDepositCalculator.ts @@ -0,0 +1,52 @@ +import { type ApiPromise } from '@polkadot/api'; +import { BN, BN_ZERO } from '@polkadot/util'; +import { type Store, combine, createEffect, createStore, sample } from 'effector'; + +import { nonNullable, nullable } from '@/shared/lib/utils'; +import { transactionService } from '@/entities/transaction'; + +type Params = { + $threshold: Store; + $api: Store; +}; + +type DepositParams = { + api: ApiPromise; + threshold: number; +}; + +export const createDepositCalculator = ({ $threshold, $api }: Params) => { + const $source = combine({ threshold: $threshold, api: $api }, ({ threshold, api }) => { + if (nullable(threshold) || nullable(api)) return null; + + return { threshold, api }; + }); + + const $deposit = createStore(BN_ZERO); + + const getMultisigDepositFx = createEffect(({ api, threshold }: DepositParams): string => { + return transactionService.getMultisigDeposit(threshold, api); + }); + + sample({ + clock: $source, + filter: nullable, + fn: () => BN_ZERO, + target: $deposit, + }); + + sample({ + clock: $source, + filter: nonNullable, + target: getMultisigDepositFx, + }); + + sample({ + clock: getMultisigDepositFx.doneData, + filter: nonNullable, + fn: (deposit) => new BN(deposit), + target: $deposit, + }); + + return { $deposit, $pending: getMultisigDepositFx.pending }; +}; diff --git a/src/renderer/shared/transactions/index.ts b/src/renderer/shared/transactions/index.ts index f9c232da04..b110c63a11 100644 --- a/src/renderer/shared/transactions/index.ts +++ b/src/renderer/shared/transactions/index.ts @@ -1,2 +1,3 @@ export { createFeeCalculator } from './createFeeCalculator'; +export { createDepositCalculator } from './createDepositCalculator'; export { createTxStore } from './createTxStore'; diff --git a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx index 8c480cbeab..2c4aa8c1ef 100644 --- a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx +++ b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx @@ -5,22 +5,20 @@ import { FootnoteText } from '@/shared/ui'; import { AccountExplorers } from './AccountExplorers'; -const testAccountId = '0xd180LUV5yfqBC9i8Lfssufw2434ef24f3f7AhBDDcaHEF03a8'; -const testChain: Chain = { - name: 'Polkadot', - specName: 'polkadot', - addressPrefix: 0, - chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', - icon: '', - options: [], - nodes: [], - assets: [], +const testAccountId = '0x9e9bf57d2420cc050723e9609afd5a1c326aceaf6b3f4175fda2eb26044d1f64'; + +const kusamaChain = { + name: 'Kusama Asset Hub', + addressPrefix: 2, + chainId: '0x48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a', explorers: [ { name: 'Subscan', - extrinsic: 'https://polkadot.subscan.io/extrinsic/{hash}', - account: 'https://polkadot.subscan.io/account/{address}', - multisig: 'https://polkadot.subscan.io/multisig_extrinsic/{index}?call_hash={callHash}', + account: 'https://assethub-kusama.subscan.io/account/{address}', + }, + { + name: 'Statescan', + account: 'https://statemine.statescan.io/#/accounts/{address}', }, { name: 'Sub.ID', @@ -34,7 +32,7 @@ const meta: Meta = { component: AccountExplorers, args: { accountId: testAccountId, - chain: testChain, + chain: kusamaChain as Chain, }, parameters: { layout: 'centered', diff --git a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx index 8563fcb6ad..e56cfbee83 100644 --- a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx +++ b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx @@ -15,8 +15,9 @@ type Props = PropsWithChildren<{ export const AccountExplorers = memo(({ accountId, chain, children, testId }: Props) => { const { t } = useI18n(); - const { explorers } = chain; - const address = toAddress(accountId, { prefix: chain.addressPrefix }); + + const { explorers, addressPrefix } = chain; + const address = toAddress(accountId, { prefix: addressPrefix }); return ( diff --git a/src/renderer/shared/ui-entities/Address/Address.tsx b/src/renderer/shared/ui-entities/Address/Address.tsx index 5f77381c97..2ff6b6bdee 100644 --- a/src/renderer/shared/ui-entities/Address/Address.tsx +++ b/src/renderer/shared/ui-entities/Address/Address.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { type Address as AddressType, type XOR } from '@/shared/core'; import { cnTw } from '@/shared/lib/utils'; -import { Identicon } from '@/shared/ui/Identicon/Identicon'; +import { Identicon } from '@/shared/ui'; import { Hash } from '../Hash/Hash'; type IconProps = XOR<{ diff --git a/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.stories.tsx b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.stories.tsx new file mode 100644 index 0000000000..4636665e44 --- /dev/null +++ b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.stories.tsx @@ -0,0 +1,30 @@ +import { type Meta, type StoryObj } from '@storybook/react'; + +import { FootnoteText } from '@/shared/ui'; + +import { RootExplorers } from './RootExplorers'; + +const testAccountId = '0x9e9bf57d2420cc050723e9609afd5a1c326aceaf6b3f4175fda2eb26044d1f64'; + +const meta: Meta = { + title: 'Design System/entities/RootExplorers', + component: RootExplorers, + args: { + accountId: testAccountId, + }, + parameters: { + layout: 'centered', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithAdditionalContent: Story = { + args: { + children: Derivation path: //polkadot//pub, + }, +}; diff --git a/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.tsx b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.tsx new file mode 100644 index 0000000000..9166504c61 --- /dev/null +++ b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.tsx @@ -0,0 +1,58 @@ +import { type PropsWithChildren, memo } from 'react'; + +import { type AccountId } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { SS58_DEFAULT_PREFIX, copyToClipboard, getAccountExplorer, toAddress } from '@/shared/lib/utils'; +import { ExplorerLink, FootnoteText, HelpText, IconButton, Separator } from '@/shared/ui'; +import { Box, Popover } from '@/shared/ui-kit'; +import { Hash } from '../Hash/Hash'; + +export const EXPLORERS = [ + { name: 'Subscan', account: 'https://subscan.io/account/{address}' }, + { name: 'Sub.ID', account: 'https://sub.id/{address}' }, +]; + +type Props = PropsWithChildren<{ + accountId: AccountId; +}>; + +export const RootExplorers = memo(({ accountId, children }: Props) => { + const { t } = useI18n(); + + const address = toAddress(accountId, { prefix: SS58_DEFAULT_PREFIX }); + + return ( + + + e.stopPropagation()} /> + + + + + {t('general.explorers.addressTitle')} + + + + + copyToClipboard(address)} /> + + + + {children ? ( + <> + + {children} + + ) : null} + + +
    + {EXPLORERS.map((explorer) => ( + + ))} +
    +
    +
    +
    + ); +}); diff --git a/src/renderer/shared/ui-entities/index.ts b/src/renderer/shared/ui-entities/index.ts index d35ac5b09a..fbc081f785 100644 --- a/src/renderer/shared/ui-entities/index.ts +++ b/src/renderer/shared/ui-entities/index.ts @@ -5,5 +5,6 @@ export { Account } from './Account/Account'; export { AssetIcon } from './AssetIcon/AssetIcon'; export { AccountSelectModal } from './AccountSelectModal/AccountSelectModal'; export { AccountExplorers } from './AccountExplorer/AccountExplorers'; +export { RootExplorers } from './RootExplorer/RootExplorers'; export { TransactionDetails } from './TransactionDetails/TransactionDetails'; export { RankedAccount } from './RankedAccount/RankedAccount'; diff --git a/src/renderer/shared/ui/Inputs/Input/Input.test.tsx b/src/renderer/shared/ui-kit/Input/Input.test.tsx similarity index 92% rename from src/renderer/shared/ui/Inputs/Input/Input.test.tsx rename to src/renderer/shared/ui-kit/Input/Input.test.tsx index e07cdf5b8c..4622d59c6e 100644 --- a/src/renderer/shared/ui/Inputs/Input/Input.test.tsx +++ b/src/renderer/shared/ui-kit/Input/Input.test.tsx @@ -19,6 +19,6 @@ describe('ui/Inputs/Input', () => { const input = screen.getByRole('textbox'); await user.type(input, 'x'); - expect(spyChange).toBeCalledWith('x'); + expect(spyChange).toHaveBeenCalledWith('x'); }); }); diff --git a/src/renderer/shared/ui-kit/Input/types.ts b/src/renderer/shared/ui-kit/Input/types.ts new file mode 100644 index 0000000000..d95c7dd64e --- /dev/null +++ b/src/renderer/shared/ui-kit/Input/types.ts @@ -0,0 +1,10 @@ +export type HTMLInputProps = + | 'value' + | 'required' + | 'disabled' + | 'placeholder' + | 'name' + | 'autoFocus' + | 'type' + | 'tabIndex' + | 'spellCheck'; diff --git a/src/renderer/shared/ui/Inputs/InputFile/InputFile.test.tsx b/src/renderer/shared/ui-kit/InputFile/InputFile.test.tsx similarity index 100% rename from src/renderer/shared/ui/Inputs/InputFile/InputFile.test.tsx rename to src/renderer/shared/ui-kit/InputFile/InputFile.test.tsx diff --git a/src/renderer/shared/ui-kit/InputFile/type.ts b/src/renderer/shared/ui-kit/InputFile/type.ts new file mode 100644 index 0000000000..453f8a1537 --- /dev/null +++ b/src/renderer/shared/ui-kit/InputFile/type.ts @@ -0,0 +1,11 @@ +export type HTMLInputFileProps = + | 'value' + | 'required' + | 'disabled' + | 'placeholder' + | 'name' + | 'autoFocus' + | 'type' + | 'tabIndex' + | 'spellCheck' + | 'accept'; diff --git a/src/renderer/shared/ui-kit/Modal/Modal.tsx b/src/renderer/shared/ui-kit/Modal/Modal.tsx index c6bb11f217..b371b036da 100644 --- a/src/renderer/shared/ui-kit/Modal/Modal.tsx +++ b/src/renderer/shared/ui-kit/Modal/Modal.tsx @@ -25,13 +25,18 @@ const Root = ({ isOpen, size = 'md', height = 'fit', children, onToggle }: Props }); const modalNodes = triggerNode ? arrayChildren.filter((child) => child !== triggerNode) : arrayChildren; + const hasTitle = + modalNodes.find((child) => { + return nonNullable(child) && isObject(child) && 'type' in child && child.type === Title; + }) !== null; + return ( {triggerNode} + {hasTitle ? null : diff --git a/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx b/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx index b33fd0b742..d49b970bbe 100644 --- a/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx +++ b/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx @@ -12,7 +12,7 @@ type Props = PropsWithChildren< } >; -export const ScrollArea = ({ onScroll, orientation = 'vertical', children }: Props) => ( +export const ScrollArea = ({ orientation = 'vertical', children, onScroll }: Props) => ( {children} diff --git a/src/renderer/shared/ui-kit/TextArea/TextArea.stories.tsx b/src/renderer/shared/ui-kit/TextArea/TextArea.stories.tsx index bd12fbf1dd..7ee570f57b 100644 --- a/src/renderer/shared/ui-kit/TextArea/TextArea.stories.tsx +++ b/src/renderer/shared/ui-kit/TextArea/TextArea.stories.tsx @@ -11,7 +11,6 @@ const meta: Meta = { component: TextArea, args: { value: LONG_TEXT, - testId: 'text-area', }, }; @@ -29,7 +28,7 @@ export const Default: Story = { async play({ args, canvasElement }) { const canvas = within(canvasElement); - const textArea = await canvas.findByTestId('text-area'); + const textArea = await canvas.findByTestId('TextArea'); expect(textArea.value).toEqual(args.value); expect(textArea.placeholder).toEqual(args.placeholder); }, @@ -42,33 +41,31 @@ export const Filled: Story = { async play({ args, canvasElement }) { const canvas = within(canvasElement); - const textArea = await canvas.findByTestId('text-area'); + const textArea = await canvas.findByTestId('TextArea'); expect(textArea.value).toEqual(args.value); }, }; export const Invalid: Story = { args: { - rows: 1, invalid: true, }, async play({ canvasElement }) { const canvas = within(canvasElement); - const textArea = await canvas.findByTestId('text-area'); + const textArea = await canvas.findByTestId('TextArea'); expect(textArea).toHaveClass('border-filter-border-negative'); }, }; export const Disabled: Story = { args: { - rows: 1, disabled: true, }, async play({ args, canvasElement }) { const canvas = within(canvasElement); - const textArea = await canvas.findByTestId('text-area'); + const textArea = await canvas.findByTestId('TextArea'); expect(textArea.disabled).toEqual(args.disabled); }, }; diff --git a/src/renderer/shared/ui-kit/TextArea/TextArea.tsx b/src/renderer/shared/ui-kit/TextArea/TextArea.tsx index 3f295ed37e..71e78c2265 100644 --- a/src/renderer/shared/ui-kit/TextArea/TextArea.tsx +++ b/src/renderer/shared/ui-kit/TextArea/TextArea.tsx @@ -20,7 +20,7 @@ type Props = Pick, HTMLTextAreaProps> & { }; export const TextArea = forwardRef( - ({ invalid, disabled, testId, value, onChange, ...props }, ref) => { + ({ invalid, disabled, testId = 'TextArea', value, onChange, ...props }, ref) => { return (