diff --git a/.eslintrc.js b/.eslintrc.js index d14a69c4a2..302c821724 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -141,6 +141,7 @@ module.exports = { rules: { 'react/jsx-no-useless-fragment': 'error', 'react/jsx-no-constructed-context-values': 'error', + // TODO make children: never 'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'ignore' }], 'react/no-array-index-key': 'warn', 'react/display-name': 'off', 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/package.json b/package.json index 88f10f25fa..38a9237a9e 100644 --- a/package.json +++ b/package.json @@ -77,14 +77,15 @@ "@polkadot/util": "13.2.3", "@polkadot/util-crypto": "13.2.3", "@radix-ui/react-accordion": "1.2.1", - "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-popover": "1.1.2", - "@radix-ui/react-scroll-area": "1.2.0", + "@radix-ui/react-progress": "1.1.0", + "@radix-ui/react-scroll-area": "1.2.1", "@radix-ui/react-select": "2.1.2", "@radix-ui/react-slider": "1.2.1", - "@radix-ui/react-tooltip": "1.1.3", + "@radix-ui/react-tooltip": "1.1.4", "@react-spring/web": "9.7.5", "@remote-ui/rpc": "^1.4.4", "@substrate/connect": "2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8bd0ee51c..6dddf396e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,7 +42,7 @@ importers: specifier: 1.2.1 version: 1.2.1(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': - specifier: ^1.1.2 + specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: 1.1.2 @@ -53,9 +53,12 @@ importers: '@radix-ui/react-popover': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: 1.1.0 + version: 1.1.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': - specifier: 1.2.0 - version: 1.2.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 1.2.1 + version: 1.2.1(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: 2.1.2 version: 2.1.2(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -63,8 +66,8 @@ importers: specifier: 1.2.1 version: 1.2.1(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': - specifier: 1.1.3 - version: 1.1.3(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 1.1.4 + version: 1.1.4(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-spring/web': specifier: 9.7.5 version: 9.7.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2481,6 +2484,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.0': + resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.0': resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} peerDependencies: @@ -2494,8 +2510,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.0': - resolution: {integrity: sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==} + '@radix-ui/react-scroll-area@1.2.1': + resolution: {integrity: sha512-FnM1fHfCtEZ1JkyfH/1oMiTcFBQvHKl4vD9WnpwkLgtF+UmnXMCad6ECPTaAjcDjam+ndOEJWgHyKDGNteWSHw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2542,8 +2558,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-tooltip@1.1.3': - resolution: {integrity: sha512-Z4w1FIS0BqVFI2c1jZvb/uDVJijJjJ2ZMuPV81oVgTZ7g3BZxobplnMVvXtFWgtozdvYJ+MFWtwkM5S2HnAong==} + '@radix-ui/react-tooltip@1.1.4': + resolution: {integrity: sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -13009,6 +13025,16 @@ snapshots: '@types/react': 18.0.14 '@types/react-dom': 18.0.5 + '@radix-ui/react-progress@1.1.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.0(@types/react@18.0.14)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.0.14 + '@types/react-dom': 18.0.5 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -13026,7 +13052,7 @@ snapshots: '@types/react': 18.0.14 '@types/react-dom': 18.0.5 - '@radix-ui/react-scroll-area@1.2.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-scroll-area@1.2.1(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 '@radix-ui/primitive': 1.1.0 @@ -13098,7 +13124,7 @@ snapshots: optionalDependencies: '@types/react': 18.0.14 - '@radix-ui/react-tooltip@1.1.3(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-tooltip@1.1.4(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.0.14)(react@18.3.1) 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 854cdad6ff..2b0528f7c5 100644 --- a/src/renderer/app/modelInit.ts +++ b/src/renderer/app/modelInit.ts @@ -1,23 +1,24 @@ import { kernelModel } from '@/shared/core'; import { basketModel } from '@/entities/basket'; import { governanceModel } from '@/entities/governance'; +import { multisigsModel } from '@/entities/multisig'; import { networkModel } from '@/entities/network'; import { notificationModel } from '@/entities/notification'; import { proxyModel } from '@/entities/proxy'; import { walletModel } from '@/entities/wallet'; -import { multisigsModel } from '@/processes/multisigs'; import { assetsSettingsModel } from '@/features/assets'; import { assetsNavigationFeature } from '@/features/assets-navigation'; import { basketNavigationFeature } from '@/features/basket-navigation'; import { contactsNavigationFeature } from '@/features/contacts-navigation'; import { fellowshipNavigationFeature } from '@/features/fellowship-navigation'; +import { flexibleMultisigNavigationFeature } from '@/features/flexible-multisig-navigation'; import { governanceNavigationFeature } from '@/features/governance-navigation'; import { notificationsNavigationFeature } from '@/features/notifications-navigation'; 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 = () => { assetsNavigationFeature.start(); @@ -29,8 +30,9 @@ export const initModel = () => { notificationsNavigationFeature.start(); settingsNavigationFeature.start(); basketNavigationFeature.start(); + flexibleMultisigNavigationFeature.start(); - walletsSelectFeature.start(); + walletSelectFeature.feature.start(); kernelModel.events.appStarted(); governanceModel.events.governanceStarted(); @@ -41,5 +43,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/index.ts b/src/renderer/entities/multisig/index.ts index e50cff086f..58cd9a6a34 100644 --- a/src/renderer/entities/multisig/index.ts +++ b/src/renderer/entities/multisig/index.ts @@ -1,2 +1,3 @@ export * from './lib'; export * from './api'; +export { multisigsModel } from './model/multisigs-model'; diff --git a/src/renderer/entities/multisig/lib/mulitisigs-utils.ts b/src/renderer/entities/multisig/lib/mulitisigs-utils.ts new file mode 100644 index 0000000000..65d0874699 --- /dev/null +++ b/src/renderer/entities/multisig/lib/mulitisigs-utils.ts @@ -0,0 +1,90 @@ +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 = { + 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: 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 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/entities/multisig/model/__tests__/multisigs-model.test.ts similarity index 84% rename from src/renderer/processes/multisigs/model/__tests__/multisigs-model.test.ts rename to src/renderer/entities/multisig/model/__tests__/multisigs-model.test.ts index a40065f1e9..d32261af70 100644 --- a/src/renderer/processes/multisigs/model/__tests__/multisigs-model.test.ts +++ b/src/renderer/entities/multisig/model/__tests__/multisigs-model.test.ts @@ -1,9 +1,9 @@ import { allSettled, fork } from 'effector'; import { AccountType, ChainOptions, ConnectionType, ExternalType, WalletType } from '@/shared/core'; -import { multisigService } from '@/entities/multisig'; import { networkModel } from '@/entities/network'; import { walletModel } from '@/entities/wallet'; +import { multisigService } from '../../api'; import { multisigsModel } from '../multisigs-model'; const mockChains = { @@ -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/entities/multisig/model/multisigs-model.ts b/src/renderer/entities/multisig/model/multisigs-model.ts new file mode 100644 index 0000000000..c46f0a96d1 --- /dev/null +++ b/src/renderer/entities/multisig/model/multisigs-model.ts @@ -0,0 +1,283 @@ +import { combine, createEffect, createEvent, createStore, sample } from 'effector'; +import { GraphQLClient } from 'graphql-request'; +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 ProxyAccount, + SigningType, + WalletType, +} from '@/shared/core'; +import { series } from '@/shared/effector'; +import { nonNullable, nullable, toAddress } from '@/shared/lib/utils'; +import { networkModel, networkUtils } from '@/entities/network'; +import { notificationModel } from '@/entities/notification'; +import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; +import { multisigService } from '../api'; +import { multisigUtils } from '../lib/mulitisigs-utils'; + +const MULTISIG_DISCOVERY_TIMEOUT = 30000; + +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) => { + const isMultisigSupported = networkUtils.isMultisigSupported(chain.options); + const hasIndexerUrl = chain.externalApi?.[ExternalType.PROXY]?.at(0)?.url; + + return isMultisigSupported && hasIndexerUrl; + }); +}); + +type GetMultisigsParams = { + chains: Chain[]; + accounts: Account[]; + multisigAccounts: Account[]; + proxies: Record; +}; + +type MultisigResponse = { + type: 'multisig'; + account: NoID>; + chain: Chain; +}; + +type FlexibleMultisigResponse = { + type: 'flexibleMultisig'; + account: NoID>; + chain: Chain; +}; + +type GetMultisigResponse = MultisigResponse | FlexibleMultisigResponse; + +const getMultisigsFx = createEffect( + ({ chains, accounts, proxies, multisigAccounts }: GetMultisigsParams): Promise => { + const requests = chains.flatMap(async (chain) => { + const multisigIndexer = networkUtils.getProxyExternalApi(chain); + + if (nullable(multisigIndexer) || accounts.length === 0) return []; + + const client = new GraphQLClient(multisigIndexer.url); + const accountIds = uniq( + accounts.filter((a) => accountUtils.isChainIdMatch(a, chain.chainId)).map((account) => account.accountId), + ); + + if (accountIds.length === 0) return []; + + 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 check if it's a pure proxy + (proxiesList.find((p) => p.chainId === chain.chainId && p.proxyType === 'Any') ?? null) + : null; + + // TODO check if there's a multisig with no proxy and only one ongoing operation 'create pure proxy' - build flexible shell + 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, + }; + }) + ); + }); + + return Promise.all(requests).then((res) => res.flat()); + }, +); + +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, + }; + + return { + wallet, + accounts: [account], + external: true, + }; +}); + +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: [updateRequested, request], + source: { + multisigAccounts: $multisigAccounts, + chains: $multisigChains, + // TODO uncomment when we're ready to work with flexible multisig. + // proxies: proxyModel.$proxies, + proxies: createStore({}), + connections: networkModel.$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 { + chains: filteredChains, + multisigAccounts, + accounts, + proxies, + }; + }, + target: getMultisigsFx, +}); + +const populateWallet = createEvent(); + +sample({ + 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: notificationModel.events.notificationsAdded, +}); + +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, +}); + +export const multisigsModel = { + events: { + subscribe, + request, + }, +}; diff --git a/src/renderer/entities/network/lib/network-utils.ts b/src/renderer/entities/network/lib/network-utils.ts index d9199d97c8..47d86db88a 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, @@ -90,6 +92,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 6b65a9a52a..edee8e6268 100644 --- a/src/renderer/entities/network/model/network-model.ts +++ b/src/renderer/entities/network/model/network-model.ts @@ -471,6 +471,7 @@ export const networkModel = { networkStarted, chainConnected, chainDisconnected, + connectionsPopulated: populateConnectionsFx.doneData, }, output: { 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/signatory/ui/SignatoryCard/SignatoryCard.tsx b/src/renderer/entities/signatory/ui/SignatoryCard/SignatoryCard.tsx index 685219fe27..052f5c1ea0 100644 --- a/src/renderer/entities/signatory/ui/SignatoryCard/SignatoryCard.tsx +++ b/src/renderer/entities/signatory/ui/SignatoryCard/SignatoryCard.tsx @@ -2,20 +2,24 @@ import { type PropsWithChildren } from 'react'; import { type AccountId, type Explorer, type SigningStatus } from '@/shared/core'; import { cnTw } from '@/shared/lib/utils'; -import { Icon } from '@/shared/ui'; +import { Icon, type IconNames } from '@/shared/ui'; import { ExplorersPopover } from '@/entities/wallet'; -const IconProps = { - SIGNED: { className: 'group-hover:hidden text-text-positive', name: 'checkmarkOutline' }, - CANCELLED: { className: 'group-hover:hidden text-text-negative', name: 'closeOutline' }, -} as const; +const IconProps: Record = { + SIGNED: { className: 'text-text-positive', name: 'checkmarkOutline' }, + CANCELLED: { className: 'text-text-negative', name: 'closeOutline' }, + ERROR_SIGNED: { className: 'text-text-negative', name: 'checkmarkOutline' }, + ERROR_CANCELLED: { className: 'text-text-negative', name: 'closeOutline' }, + PENDING_CANCELLED: { className: 'text-text-warning', name: 'closeOutline' }, + PENDING_SIGNED: { className: 'text-text-warning', name: 'checkmarkOutline' }, +}; type Props = { className?: string; accountId: AccountId; explorers?: Explorer[]; addressPrefix?: number; - status?: SigningStatus; + status?: SigningStatus | null; }; export const SignatoryCard = ({ @@ -26,6 +30,8 @@ export const SignatoryCard = ({ status, children, }: PropsWithChildren) => { + const statusProps = status ? IconProps[status] : null; + const button = (
{children} - {status && status in IconProps && } + {statusProps ? ( + + ) : null}
); diff --git a/src/renderer/entities/transaction/lib/transactionBuilder.ts b/src/renderer/entities/transaction/lib/transactionBuilder.ts index 7d9011f4c0..8b0816e155 100644 --- a/src/renderer/entities/transaction/lib/transactionBuilder.ts +++ b/src/renderer/entities/transaction/lib/transactionBuilder.ts @@ -4,16 +4,19 @@ import { camelCase } from 'lodash'; 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'; @@ -39,6 +42,8 @@ export const transactionBuilder = { buildRemoveVote, buildRemoveVotes, buildRejectMultisigTx, + buildCreatePureProxy, + buildCreateFlexibleMultisig, buildBatchAll, splitBatchAll, @@ -485,7 +490,6 @@ function buildRemoveVotes({ chain, accountId, votes }: RemoveVotesParams): Trans return buildBatchAll({ chain, accountId, transactions }); } - type RejectTxParams = { chain: Chain; signerAddress: Address; @@ -509,3 +513,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/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/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/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..e2c5c2f7ad --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/FlexibleMultisigWallet.tsx @@ -0,0 +1,78 @@ +import { useForm } from 'effector-forms'; +import { useGate, useUnit } from 'effector-react'; +import { type ComponentProps } from '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'; + +const MODAL_SIZE: Record, 'size' | 'height'>> = { + [Step.NAME_NETWORK]: { size: 'lg', height: 'full' }, + [Step.SIGNATORIES_THRESHOLD]: { size: 'lg', height: 'full' }, + [Step.SIGNER_SELECTION]: { size: 'sm', height: 'fit' }, + [Step.SIGN]: { size: 'md', height: 'fit' }, + [Step.CONFIRM]: { size: 'md', height: 'fit' }, + [Step.SUBMIT]: { size: 'md', height: 'fit' }, +}; + +type Props = { + onClose: () => void; + onGoBack: () => void; +}; + +export const FlexibleMultisigWallet = ({ 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..29fd9a5610 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/MultisigFees.tsx @@ -0,0 +1,64 @@ +import { useUnit } from 'effector-react'; +import { memo } from 'react'; + +import { type Asset } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { FootnoteText, IconButton } from '@/shared/ui'; +import { Tooltip } from '@/shared/ui-kit'; +import { AssetBalance } from '@/entities/asset'; +import { priceProviderModel } from '@/entities/price'; +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 fiatFlag = useUnit(priceProviderModel.$fiatFlag); + 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')} + +
+
+
+
+
+ + +
+ ); +}); 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..a83f664c86 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/NameNetworkSelection.tsx @@ -0,0 +1,106 @@ +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; + +import { type ChainId } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { Step, nonNullable } from '@/shared/lib/utils'; +import { Button, FootnoteText, InputHint, SmallTitleText } from '@/shared/ui'; +import { Box, Field, Input, Modal, 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')} + +
+ +
+
+ + + +
+ {nonNullable(asset) ? : null} + + +
+
+
+ + ); +}; 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..2871825542 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/SelectSignatoriesThreshold.tsx @@ -0,0 +1,157 @@ +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; +import { type FormEvent } from 'react'; + +import { useI18n } from '@/shared/i18n'; +import { Step, nonNullable } from '@/shared/lib/utils'; +import { Alert, Button, Icon, InputHint, SmallTitleText } from '@/shared/ui'; +import { Box, Field, Modal, 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 { Signatory } from './Signatory'; + +export const SelectSignatoriesThreshold = () => { + const { t } = useI18n(); + + 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 duplicateSignatories = useUnit(signatoryModel.$duplicateSignatories); + const canSubmit = useUnit(formModel.$canSubmit); + const wrongChainTypes = useUnit(formModel.$invalidAddresses); + + const onSubmit = (event: FormEvent) => { + signatoryModel.events.getSignatoriesBalance(ownedSignatoriesWallets); + + if (ownedSignatoriesWallets.length > 1) { + flexibleMultisigModel.events.stepChanged(Step.SIGNER_SELECTION); + } else { + const account = ownedSignatoriesWallets.at(0)?.accounts.at(0); + + if (!account) return; + + flexibleMultisigModel.events.signerSelected(account); + event.preventDefault(); + submit(); + } + }; + + const asset = chain?.assets.at(0); + + return ( + <> + + + {t('createMultisigAccount.multisigStep', { step: 2 })}{' '} + {t('createMultisigAccount.flexibleMultisig.signatoryThresholdDescription')} + + + + {signatories.map((signer, index) => ( + + ))} + + + +
+ +
+ + + + + + + {t('createMultisigAccount.thresholdHint')} + +
+ + + {t('createMultisigAccount.multisigHiddenExistText')} + + + + + + + {t('createMultisigAccount.multisigExistText')} + +
+
+ + + + + +
+ {nonNullable(asset) ? : 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..09317930b4 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectThreshold/Signatory.tsx @@ -0,0 +1,238 @@ +import { useUnit } from 'effector-react'; +import { useEffect, useMemo, useState } from 'react'; + +import { type Address as AccountAddress, type ID, type WalletFamily } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { includesMultiple, performSearch, toAccountId, toAddress } from '@/shared/lib/utils'; +import { CaptionText, Combobox, IconButton, Identicon, InputHint } 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 { 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'; + +const { services, constants } = walletSelectFeature; + +type Props = { + isOwnAccount?: boolean; + isDuplicate: boolean; + isInvalidAddress: boolean; + signatoryName: string; + signatoryAddress: AccountAddress; + signatoryIndex: number; + selectedWalletId?: string; + onDelete?: (index: number) => void; +}; + +export const Signatory = ({ + signatoryIndex, + isDuplicate, + isInvalidAddress, + isOwnAccount = false, + signatoryName, + signatoryAddress, + selectedWalletId, + onDelete, +}: 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 filteredContacts = useMemo(() => { + if (isOwnAccount) return []; + + 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; + }, [isOwnAccount, ownAccountName, contactAccountName]); + + // Wallets + useEffect(() => { + if (!isOwnAccount || wallets.length === 0 || !chain) return; + + const filteredWallets = walletUtils.getWalletsFilteredAccounts(wallets, { + walletFn: walletUtils.isValidSignatory, + accountFn: (account, wallet) => { + const isChainMatch = accountUtils.isChainAndCryptoMatch(account, chain); + const isCorrectAccount = accountUtils.isNonBaseVaultAccount(account, wallet); + const address = toAddress(account.accountId, { prefix: chain.addressPrefix }); + const queryPass = includesMultiple([account.name, address], query); + + return isChainMatch && isCorrectAccount && queryPass; + }, + }); + + const walletByGroup = services.walletSelect.getWalletByGroups(filteredWallets || []); + const walletsOptions: ComboboxOption[] = []; + + for (const [walletFamily, walletsGroup] of Object.entries(walletByGroup)) { + if (walletsGroup.length === 0) continue; + + const accountOptions: ComboboxOption[] = []; + for (const wallet of walletsGroup) { + for (const account of wallet.accounts) { + const address = toAddress(account.accountId, { prefix: chain.addressPrefix }); + + accountOptions.push({ + id: account.walletId.toString(), + value: { address, walletId: account.walletId }, + element:
, + }); + } + } + + walletsOptions.push( + { + id: walletFamily, + value: undefined, + disabled: true, + element: ( +
+ + + {t(constants.GROUP_LABELS[walletFamily as WalletFamily])} + +
+ ), + }, + ...accountOptions, + ); + } + + setOptions(walletsOptions); + }, [query, wallets, isOwnAccount]); + + // Contacts + useEffect(() => { + if (isOwnAccount || contacts.length === 0) return; + + const contactsOptions: ComboboxOption[] = []; + for (const contact of filteredContacts) { + const displayAddress = toAddress(contact.accountId, { prefix: chain?.addressPrefix }); + + contactsOptions.push({ + id: signatoryIndex.toString(), + element:
, + value: { address: displayAddress }, + }); + } + + setOptions(contactsOptions); + }, [query, isOwnAccount, contacts, filteredContacts]); + + // initiate the query form in case of not own account + useEffect(() => { + if (isOwnAccount || contacts.length === 0) return; + + filterModel.events.formInitiated(); + }, [isOwnAccount, contacts]); + + useEffect(() => { + if (!displayName || displayName === signatoryName) return; + + onNameChange(displayName); + }, [displayName]); + + const onNameChange = (newName: string) => { + signatoryModel.events.changeSignatory({ + index: signatoryIndex, + name: newName, + address: signatoryAddress, + walletId: selectedWalletId, + }); + }; + + const onAddressChange = (address: AccountAddress, walletId?: ID) => { + signatoryModel.events.changeSignatory({ + index: signatoryIndex, + name: signatoryName, + address: address, + walletId: walletId?.toString(), // will be undefined for contact + }); + }; + + return ( +
+ + + +
+ + + + } + onChange={({ value }) => onAddressChange(value.address, value.walletId)} + onInput={setQuery} + /> + + + {t('createMultisigAccount.duplicateSignatoryAddress')} + + + + {!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..7c19fcce3c --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SelectedSignatoriesModal.tsx @@ -0,0 +1,68 @@ +import { useUnit } from 'effector-react'; + +import { type Chain } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { toAccountId, toShortAddress } from '@/shared/lib/utils'; +import { HeaderTitleText, HelpText } from '@/shared/ui'; +import { AccountExplorers } from '@/shared/ui-entities'; +import { Modal } from '@/shared/ui-kit'; +import { ContactItem, WalletCardMd, walletModel, walletUtils } from '@/entities/wallet'; + +interface SignatoryInfo { + index: number; + name: string; + address: string; + walletId?: string; +} + +type Props = { + chain: Chain; + signatories: Omit[]; + children: React.ReactNode; +}; + +export const SelectedSignatoriesModal = ({ signatories, chain, children }: Props) => { + const { t } = useI18n(); + + const wallets = useUnit(walletModel.$wallets); + + return ( + + {children} + + {t('createMultisigAccount.selectedSignatoriesTitle')} + + +
    + {signatories.map(({ name, address, walletId }) => { + if (!walletId) { + return ( +
  • + + + +
  • + ); + } + + const wallet = walletUtils.getWalletById(wallets, Number(walletId)); + if (!wallet) return null; + + return ( +
  • + {toShortAddress(address, 12)} + } + > + + +
  • + ); + })} +
+
+
+ ); +}; 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..3a91f939ce --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/Signer.tsx @@ -0,0 +1,46 @@ +import { BN_ZERO } from '@polkadot/util'; +import { type FormEvent } from 'react'; + +import { type Account, type Chain, type Wallet } from '@/shared/core'; +import { nonNullable, transferableAmount } from '@/shared/lib/utils'; +import { BodyText, Icon } from '@/shared/ui'; +import { AccountExplorers } from '@/shared/ui-entities'; +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; + wallet: Wallet; + chain: Chain; +} + +export const Signer = ({ account, wallet, onSubmit, chain }: Props) => { + const balance = useBalance({ + accountId: account.accountId, + chainId: chain.chainId, + assetId: chain.assets.at(0)?.assetId.toString() || '', + }); + + return ( +
  • onSubmit(e, account)} + > +
    + + {wallet.name && {wallet.name}} + +
    + {nonNullable(chain.assets.at(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..e41c6485e0 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/components/SignerSelection.tsx @@ -0,0 +1,69 @@ +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; +import { type FormEvent } from 'react'; + +import { type Account } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { Step } from '@/shared/lib/utils'; +import { Button } from '@/shared/ui'; +import { Box, Modal } from '@/shared/ui-kit'; +import { accountUtils } from '@/entities/wallet'; +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((wallet) => { + if (!chain) return null; + + const account = wallet.accounts.find((account) => { + return accountUtils.isBaseAccount(account) || account.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..2fe326f251 --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/__tests__/form-model.test.ts @@ -0,0 +1,109 @@ +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 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(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.$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..0cccae856d --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/form-model.ts @@ -0,0 +1,195 @@ +import { combine, sample } from 'effector'; +import { createForm } from 'effector-forms'; + +import { type Address, type Chain, type ChainId, CryptoType } from '@/shared/core'; +import { addUnique, nonNullable, nullable, toAccountId, validateAddress } from '@/shared/lib/utils'; +import { networkModel, networkUtils } from '@/entities/network'; +import { accountUtils, walletModel, walletUtils } from '@/entities/wallet'; + +import { signatoryModel } from './signatory-model'; + +const MIN_THRESHOLD = 2; +const DEFAULT_CHAIN: ChainId = '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3'; // Polkadot + +type FormParams = { + threshold: number; + chainId: ChainId; + name: string; +}; + +const $createMultisigForm = createForm({ + fields: { + name: { + init: '', + rules: [ + { + name: 'notEmpty', + validator: (name) => name !== '', + }, + ], + }, + chainId: { + init: DEFAULT_CHAIN, + }, + threshold: { + init: 0, + }, + }, + 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]; + }, +); + +const $invalidAddresses = combine( + { + chain: $chain, + signatories: signatoryModel.$signatories, + }, + ({ chain, signatories }) => { + if (!chain) return []; + + let badSignatories: Address[] = []; + + for (const signer of signatories) { + if (!signer.address || validateAddress(signer.address, chain)) continue; + + badSignatories = addUnique(badSignatories, signer.address); + } + + return badSignatories; + }, +); + +const $canSubmit = combine( + { + hasEmptySignatories: signatoryModel.$hasEmptySignatories, + hasEmptySignatoryName: signatoryModel.$hasEmptySignatoryName, + hasDuplicateSignatories: signatoryModel.$hasDuplicateSignatories, + multisigAlreadyExists: $multisigAlreadyExists, + invalidAddresses: $invalidAddresses, + hiddenMultisig: $hiddenMultisig, + threshold: $createMultisigForm.fields.threshold.$value, + }, + ({ invalidAddresses, threshold, ...params }) => { + if (invalidAddresses.length > 0 || threshold < MIN_THRESHOLD) return false; + + return Object.values(params).every((param) => nullable(param) || !param); + }, +); + +sample({ + clock: $createMultisigForm.fields.chainId.changed, + target: [$createMultisigForm.fields.threshold.reset, signatoryModel.events.resetSignatories], +}); + +sample({ + clock: signatoryModel.events.deleteSignatory, + target: $createMultisigForm.fields.threshold.reset, +}); + +export const formModel = { + $createMultisigForm, + $multisigAccountId, + $multisigAlreadyExists, + $hiddenMultisig, + $availableAccounts, + $chain, + $invalidAddresses, + $canSubmit, + + 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..ff09a64f1d --- /dev/null +++ b/src/renderer/features/flexible-multisig-create/model/signatory-model.ts @@ -0,0 +1,134 @@ +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 resetSignatories = createEvent(); + +const $signatories = createStore[]>([{ name: '', address: '', walletId: '' }]); +const $duplicateSignatories = combine($signatories, (signatories) => { + const duplicates: Record = {}; + + for (const [index, signer] of signatories.entries()) { + if (!signer.address) continue; + + if (duplicates[signer.address]) { + duplicates[signer.address].push(index); + } else { + duplicates[signer.address] = []; + } + } + + return duplicates; +}); + +const $hasDuplicateSignatories = $duplicateSignatories.map((signatories) => { + return Object.values(signatories).some((duplicates) => duplicates.length > 0); +}); + +const $hasEmptySignatories = $signatories.map((signatories) => { + return signatories.some(({ address }) => !address.trim()); +}); + +const $hasEmptySignatoryName = $signatories.map((signatories) => { + return signatories.some(({ name }) => !name.trim()); +}); + +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, +}); + +sample({ + clock: resetSignatories, + target: $signatories.reinit, +}); + +export const signatoryModel = { + $signatories, + $ownedSignatoriesWallets, + $duplicateSignatories, + $hasDuplicateSignatories, + $hasEmptySignatories, + $hasEmptySignatoryName, + events: { + addSignatory, + changeSignatory, + deleteSignatory, + getSignatoriesBalance, + resetSignatories, + }, +}; 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..13ed822208 --- /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/flexible-multisig-navigation/index.tsx b/src/renderer/features/flexible-multisig-navigation/index.tsx new file mode 100644 index 0000000000..9d740770c4 --- /dev/null +++ b/src/renderer/features/flexible-multisig-navigation/index.tsx @@ -0,0 +1,40 @@ +import { useUnit } from 'effector-react'; + +import { $features } from '@/shared/config/features'; +import { createFeature } from '@/shared/effector'; +import { Paths } from '@/shared/routes'; +import { walletModel, walletUtils } from '@/entities/wallet'; +import { navigationTopLinksPipeline } from '@/features/app-shell'; +import { assetsNavigationFeature } from '@/features/assets-navigation'; +import { contactsNavigationFeature } from '@/features/contacts-navigation'; +import { fellowshipNavigationFeature } from '@/features/fellowship-navigation'; +import { governanceNavigationFeature } from '@/features/governance-navigation'; +import { navigationModel } from '@/features/navigation'; +import { operationsNavigationFeature } from '@/features/operations-navigation'; +import { stakingNavigationFeature } from '@/features/staking-navigation'; + +const isNavFeaturesReady = + stakingNavigationFeature.isRunning && + operationsNavigationFeature.isRunning && + contactsNavigationFeature.isRunning && + governanceNavigationFeature.isRunning && + assetsNavigationFeature.isRunning && + fellowshipNavigationFeature.isRunning; + +export const flexibleMultisigNavigationFeature = createFeature({ + name: 'flexible/navigation', + enable: $features.map(({ flexible }) => flexible) && isNavFeaturesReady, +}); + +flexibleMultisigNavigationFeature.inject(navigationTopLinksPipeline, (items) => { + const wallet = useUnit(walletModel.$activeWallet); + + //TODO check what to use here after linking proxy and flexible + if (wallet && walletUtils.isFlexibleMultisig(wallet) && !wallet.accounts.at(0)?.proxyAccountId) { + navigationModel.events.navigateTo(Paths.OPERATIONS); + + return items.filter((item) => item.title === 'navigation.mstOperationLabel'); + } + + return items; +}); 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/multisig-operations/constants.ts b/src/renderer/features/multisig-operations/constants.ts new file mode 100644 index 0000000000..c70a793a5a --- /dev/null +++ b/src/renderer/features/multisig-operations/constants.ts @@ -0,0 +1,3 @@ +export const ERROR = { + networkDisabled: 'Network disabled', +}; diff --git a/src/renderer/features/multisig-operations/index.ts b/src/renderer/features/multisig-operations/index.ts new file mode 100644 index 0000000000..f0c5368ab5 --- /dev/null +++ b/src/renderer/features/multisig-operations/index.ts @@ -0,0 +1,8 @@ +import { operationsModel } from './model/list'; + +export const multisigOperationsFeature = { + model: { + operations: operationsModel, + }, + views: {}, +}; diff --git a/src/renderer/features/multisig-operations/model/list.ts b/src/renderer/features/multisig-operations/model/list.ts new file mode 100644 index 0000000000..f651616de4 --- /dev/null +++ b/src/renderer/features/multisig-operations/model/list.ts @@ -0,0 +1,24 @@ +import { sample } from 'effector'; + +import { multisigDomain } from '@/domains/multisig'; + +import { multisigOperationsFeatureStatus } from './status'; + +sample({ + clock: multisigOperationsFeatureStatus.running, + target: [multisigDomain.multisigs.request, multisigDomain.multisigs.subscribe], +}); + +sample({ + clock: multisigOperationsFeatureStatus.stopped, + target: multisigDomain.multisigs.unsubscribe, +}); + +const $operations = multisigDomain.multisigs.$multisigOperations.map((list) => list ?? {}); + +export const operationsModel = { + $operations, + + $pending: multisigOperationsFeatureStatus.isStarting, + $fulfilled: multisigOperationsFeatureStatus.isRunning, +}; diff --git a/src/renderer/features/multisig-operations/model/status.ts b/src/renderer/features/multisig-operations/model/status.ts new file mode 100644 index 0000000000..ae440aa6fb --- /dev/null +++ b/src/renderer/features/multisig-operations/model/status.ts @@ -0,0 +1,86 @@ +import { type ApiPromise } from '@polkadot/api'; +import { combine, createStore, sample } from 'effector'; +import { debounce } from 'patronum'; + +import { type ChainId } from '@/shared/core'; +import { createFeature } from '@/shared/effector'; +import { nullable } from '@/shared/lib/utils'; +import { type AccountId } from '@/shared/polkadotjs-schemas'; +import { networkModel, networkUtils } from '@/entities/network'; +import { walletModel, walletUtils } from '@/entities/wallet'; + +const $trigger = createStore(''); +const $debouncedApis = createStore>({}); + +sample({ + clock: debounce(networkModel.$apis, 2000), + source: networkModel.$chains, + fn: (chains, apis) => { + const multisigChains = Object.values(chains) + .filter((chain) => apis[chain.chainId] && networkUtils.isMultisigSupported(chain.options)) + .map((c) => c.chainId); + + return multisigChains.join(','); + }, + target: $trigger, +}); + +sample({ + clock: $trigger, + source: networkModel.$apis, + target: $debouncedApis, +}); + +const $input = combine( + { + apis: $debouncedApis, + chains: networkModel.$chains, + wallet: walletModel.$activeWallet, + }, + ({ apis, chains, wallet }) => { + if (nullable(wallet) || !walletUtils.isMultisig(wallet)) return null; + + const input = []; + + for (const account of wallet.accounts) { + if (account.chainId) { + const api = apis[account.chainId]; + + if (api) { + input.push({ + api, + accountId: account.accountId as AccountId, + }); + } + } else { + const multisigChains = Object.values(chains).filter((chain) => networkUtils.isMultisigSupported(chain.options)); + + for (const chain of multisigChains) { + const api = apis[chain.chainId]; + + if (api) { + input.push({ + api, + accountId: account.accountId as AccountId, + }); + } + } + } + } + + return input; + }, +); + +export const multisigOperationsFeatureStatus = createFeature({ + name: 'multisigOperations', + input: $input, +}); + +multisigOperationsFeatureStatus.start(); + +sample({ + clock: walletModel.$activeWallet, + filter: walletUtils.isMultisig, + target: multisigOperationsFeatureStatus.restore, +}); diff --git a/src/renderer/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/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/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 d9087a8ee4..71b47dd90b 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, @@ -31,7 +32,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; @@ -54,6 +54,8 @@ type FormSubmitEvent = { }; }; +const flow = createGate<{ wallet: Wallet | null }>({ defaultState: { wallet: null } }); + const formInitiated = createEvent(); const formSubmitted = createEvent(); const proxyQueryChanged = createEvent(); @@ -64,6 +66,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); @@ -146,7 +150,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, @@ -207,7 +211,7 @@ const $proxyWallet = combine( const $proxyChains = combine( { chains: networkModel.$chains, - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, }, ({ chains, wallet }) => { if (!wallet) return []; @@ -227,7 +231,7 @@ const $proxyChains = combine( const $proxiedAccounts = combine( { - wallet: walletSelectModel.$walletForDetails, + wallet: $wallet, chain: $proxyForm.fields.chain.$value, balances: balanceModel.$balances, }, @@ -256,7 +260,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, @@ -322,9 +326,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']; }, ); @@ -367,7 +369,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 }, @@ -405,7 +407,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 }, @@ -471,19 +473,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, }; }, @@ -527,6 +529,7 @@ sample({ }); export const formModel = { + $wallet, $proxyForm, $proxyChains, $proxiedAccounts, @@ -549,6 +552,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 d7fd15d47d..dc11a3ad6c 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, transferableAmount } from '@/shared/lib/utils'; +import { nonNullable, nullable, toAddress, transferableAmount } 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 16581d4b3f..9cb1a3f954 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, transferableAmount } from '@/shared/lib/utils'; +import { nonNullable, nullable, toAccountId, toAddress, transferableAmount } 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/staking/Validators/ui/Validators.tsx b/src/renderer/features/staking/Validators/ui/Validators.tsx index f9e6b5a9f3..996af621bb 100644 --- a/src/renderer/features/staking/Validators/ui/Validators.tsx +++ b/src/renderer/features/staking/Validators/ui/Validators.tsx @@ -5,8 +5,8 @@ import { type Validator } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { cnTw, nullable, toAccountId } from '@/shared/lib/utils'; import { type AccountId } from '@/shared/polkadotjs-schemas'; -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 { identityDomain } from '@/domains/identity'; import { ValidatorsTable } from '@/entities/staking'; import { validatorsModel } from '../model/validators-model'; @@ -45,12 +45,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..c4bfa30fb0 --- /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: 'Wallets 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 75% rename from src/renderer/features/wallets/WalletSelect/ui/WalletGroup.tsx rename to src/renderer/features/wallet-select/components/WalletGroup.tsx index 38d8c56d8a..ee5b36a042 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,14 +36,14 @@ export const WalletGroup = ({ type, wallets }: Props) => {
    - {t(GroupLabels[type as WalletFamily])} + {t(GROUP_LABELS[type as WalletFamily])} {wallets.length} {walletUtils.isProxied(wallets[0]) && }
    -
      +
        {wallets.map((wallet) => (
      • { ) } 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..2fa51e7be6 --- /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: 'Wallets 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..0d5468473e 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')} -
      + )} /> validateAddress(address) }} 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/Operations.test.tsx b/src/renderer/pages/Operations/Operations.test.tsx deleted file mode 100644 index 1a48e0ca26..0000000000 --- a/src/renderer/pages/Operations/Operations.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import { fork } from 'effector'; -import { Provider } from 'effector-react'; - -import { TEST_ACCOUNTS } from '@/shared/lib/utils'; -import { networkModel } from '@/entities/network'; - -import { Operations } from './Operations'; - -jest.mock('@/shared/i18n', () => ({ - useI18n: jest.fn().mockReturnValue({ - t: (key: string) => key, - }), -})); - -jest.mock('@/entities/multisig', () => ({ - useMultisigTx: jest.fn().mockReturnValue({ - getLiveAccountMultisigTxs: () => [{ name: 'Test Wallet', accountId: TEST_ACCOUNTS[0], chainId: '0x00' }], - }), - useMultisigEvent: jest.fn().mockReturnValue({ - getLiveEventsByKeys: jest.fn().mockResolvedValue([]), - }), -})); - -jest.mock('./components/Operation', () => () => 'Operation'); -jest.mock('@/features/operations', () => ({ - OperationsFilter: () => 'filter', -})); - -// TODO: Find way to mock effector gate -describe.skip('pages/Operations', () => { - test('should render component', async () => { - const scope = fork({ - values: new Map().set(networkModel.$chains, { - '0x00': { - name: 'Westend', - }, - }), - }); - - await act(async () => { - render( - - - , - ); - }); - - const title = screen.getByText('operations.title'); - const filter = screen.getByText('filter'); - - expect(title).toBeInTheDocument(); - expect(filter).toBeInTheDocument(); - }); -}); diff --git a/src/renderer/pages/Operations/Operations.tsx b/src/renderer/pages/Operations/Operations.tsx index 65b718b241..fb00996fbe 100644 --- a/src/renderer/pages/Operations/Operations.tsx +++ b/src/renderer/pages/Operations/Operations.tsx @@ -2,33 +2,31 @@ import { useUnit } from 'effector-react'; import groupBy from 'lodash/groupBy'; import { useEffect, useState } from 'react'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; import { type MultisigEvent, type MultisigTransactionKey } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { sortByDateDesc } from '@/shared/lib/utils'; +import { nullable } from '@/shared/lib/utils/functions'; import { FootnoteText, Header } from '@/shared/ui'; -import { networkModel } from '@/entities/network'; import { operationsModel } from '@/entities/operations'; import { priceProviderModel } from '@/entities/price'; -import { accountUtils, walletModel } from '@/entities/wallet'; +import { accountUtils } from '@/entities/wallet'; import { OperationsFilter } from '@/features/operations'; import EmptyOperations from './components/EmptyState/EmptyOperations'; +import { FlexibleMultisigShell } from './components/FlexibleMultisigShell'; import Operation from './components/Operation'; +import { operationsContextModel } from './model/context'; export const Operations = () => { const { t, formatDate } = useI18n(); - const activeWallet = useUnit(walletModel.$activeWallet); - const chains = useUnit(networkModel.$chains); - const allTxs = useUnit(operationsModel.$multisigTransactions); const events = useUnit(operationsModel.$multisigEvents); + const account = useUnit(operationsContextModel.$account); + const txs = useUnit(operationsContextModel.$availableTransaction); + const incompleteFlexibleMultisigTx = useUnit(operationsContextModel.$incompleteFlexibleMultisigTx); - const activeAccount = activeWallet?.accounts.at(0); - const account = activeAccount && accountUtils.isMultisigAccount(activeAccount) ? activeAccount : undefined; - - const [txs, setTxs] = useState([]); - const [filteredTxs, setFilteredTxs] = useState([]); + const [filteredTxs, setFilteredTxs] = useState([]); const getEventsByTransaction = (tx: MultisigTransactionKey): MultisigEvent[] => { return events.filter((e) => { @@ -43,7 +41,16 @@ export const Operations = () => { }; const groupedTxs = groupBy(filteredTxs, (tx) => { - const date = tx.dateCreated || getEventsByTransaction(tx)[0]?.dateCreated || Date.now(); + let date = tx.dateCreated; + + if (nullable(date)) { + const events = getEventsByTransaction(tx); + date = events.at(0)?.dateCreated; + } + + if (nullable(date)) { + date = Date.now(); + } return formatDate(new Date(date), 'PP'); }); @@ -52,21 +59,27 @@ export const Operations = () => { priceProviderModel.events.assetsPricesRequested({ includeRates: true }); }, []); - useEffect(() => { - setTxs(allTxs.filter((tx) => chains[tx.chainId])); - }, [allTxs]); - useEffect(() => { setFilteredTxs([]); - }, [activeAccount]); + }, [account]); + + if (incompleteFlexibleMultisigTx && account && accountUtils.isFlexibleMultisigAccount(account)) { + return ( + + ); + } return (
      - {Boolean(txs.length) && } + {txs.length > 0 && } + + {filteredTxs.length === 0 && ( + + )} - {Boolean(filteredTxs.length) && ( + {filteredTxs.length > 0 && (
      {Object.entries(groupedTxs) .sort(sortByDateDesc) @@ -86,10 +99,6 @@ export const Operations = () => { ))}
      )} - - {filteredTxs.length === 0 && ( - - )}
      ); }; diff --git a/src/renderer/pages/Operations/common/utils.ts b/src/renderer/pages/Operations/common/utils.ts index e85707e12e..a635eebe42 100644 --- a/src/renderer/pages/Operations/common/utils.ts +++ b/src/renderer/pages/Operations/common/utils.ts @@ -291,3 +291,17 @@ const getCoreTx = (tx: MultisigTransaction): Transaction | DecodedTransaction | return tx.transaction; }; + +export const getSignatoryStatus = (events: MultisigEvent[], signatory: AccountId) => { + const cancelEvent = events.find((e) => e.status === 'CANCELLED' && e.accountId === signatory); + if (cancelEvent) { + return cancelEvent.status; + } + + const signedEvent = events.find((e) => e.status === 'SIGNED' && e.accountId === signatory); + if (signedEvent) { + return signedEvent.status; + } + + return null; +}; diff --git a/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx b/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx index 143d14d07e..114fa7fe01 100644 --- a/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx +++ b/src/renderer/pages/Operations/components/ActionSteps/Confirmation.tsx @@ -1,12 +1,21 @@ +import { type ApiPromise } from '@polkadot/api'; import { useStoreMap, 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 Chain, + 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'; import { getTransactionFromMultisigTx } from '@/entities/multisig'; -import { type ExtendedChain, networkModel } from '@/entities/network'; +import { networkModel } from '@/entities/network'; import { SignButton } from '@/entities/operations'; import { priceProviderModel } from '@/entities/price'; import { @@ -23,14 +32,15 @@ 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; + chain: Chain; + api: ApiPromise; feeTx?: Transaction; onSign: () => void; }; -export const Confirmation = ({ tx, account, chainConnection, signAccount, feeTx, onSign }: Props) => { +export const Confirmation = ({ api, tx, account, chain, signAccount, feeTx, onSign }: Props) => { const { t } = useI18n(); const [isFeeLoaded, setIsFeeLoaded] = useState(false); const fiatFlag = useUnit(priceProviderModel.$fiatFlag); @@ -42,7 +52,7 @@ export const Confirmation = ({ tx, account, chainConnection, signAccount, feeTx, }); const xcmConfig = useUnit(xcmTransferModel.$config); - const asset = getAssetById(tx.transaction?.args.assetId, chainConnection.assets) || chainConnection.assets[0]; + const asset = getAssetById(tx.transaction?.args.assetId, chain.assets) || chain.assets[0]; const transaction = getTransactionFromMultisigTx(tx); @@ -70,22 +80,22 @@ export const Confirmation = ({ tx, account, chainConnection, signAccount, feeTx, {tx.transaction && }
      -
      - {signAccount && chainConnection?.api && ( +
      + {signAccount && api && ( )} - {chainConnection?.api && feeTx ? ( + {api && feeTx ? ( setIsFeeLoaded(Boolean(fee))} /> diff --git a/src/renderer/pages/Operations/components/Details.tsx b/src/renderer/pages/Operations/components/Details.tsx index 0c24f06506..73a2f76022 100644 --- a/src/renderer/pages/Operations/components/Details.tsx +++ b/src/renderer/pages/Operations/components/Details.tsx @@ -1,3 +1,4 @@ +import { type ApiPromise } from '@polkadot/api'; import { useStoreMap, useUnit } from 'effector-react'; import { useEffect, useMemo, useState } from 'react'; import { Trans } from 'react-i18next'; @@ -5,6 +6,9 @@ import { Trans } from 'react-i18next'; import { type Account, type Address, + type Chain, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, type MultisigAccount, type MultisigTransaction, type Transaction, @@ -23,7 +27,7 @@ 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 { networkModel, networkUtils } from '@/entities/network'; import { proxyUtils } from '@/entities/proxy'; import { SelectedValidatorsModal, useValidatorsMap } from '@/entities/staking'; import { @@ -55,15 +59,21 @@ import { } from '../common/utils'; type Props = { - tx: MultisigTransaction; - account?: MultisigAccount; + tx: MultisigTransaction | FlexibleMultisigTransaction; + account?: MultisigAccount | FlexibleMultisigAccount; signatory?: Account; - extendedChain?: ExtendedChain; + chain: Chain; + api: ApiPromise; }; -export const Details = ({ tx, account, extendedChain, signatory }: Props) => { +export const Details = ({ api, tx, account, chain, signatory }: Props) => { const { t } = useI18n(); + const connection = useStoreMap({ + store: networkModel.$connections, + keys: [chain.chainId], + fn: (connections, [chainId]) => connections[chainId] ?? null, + }); const activeWallet = useUnit(walletModel.$activeWallet); const wallets = useUnit(walletModel.$wallets); const chains = useUnit(networkModel.$chains); @@ -94,8 +104,6 @@ export const Details = ({ tx, account, extendedChain, signatory }: Props) => { const signatoryWallet = wallets.find((w) => w.id === signatory?.walletId); - const api = extendedChain?.api; - useEffect(() => { if (isUndelegateTransaction(transaction)) { setIsUndelegationLoading(true); @@ -110,10 +118,9 @@ export const Details = ({ tx, account, extendedChain, signatory }: Props) => { }); }, [api, tx]); - const connection = extendedChain?.connection; - const defaultAsset = extendedChain?.assets[0]; - const addressPrefix = extendedChain?.addressPrefix; - const explorers = extendedChain?.explorers; + const defaultAsset = chain?.assets[0]; + const addressPrefix = chain?.addressPrefix; + const explorers = chain?.explorers; const validatorsMap = useValidatorsMap(api, connection && networkUtils.isLightClientConnection(connection)); @@ -213,7 +220,7 @@ export const Details = ({ tx, account, extendedChain, signatory }: Props) => { {signatoryWallet.name} - {extendedChain ? : null} + {chain ? : null} )} diff --git a/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.test.tsx b/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.test.tsx index 9b56c328db..3c172def55 100644 --- a/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.test.tsx +++ b/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.test.tsx @@ -10,7 +10,7 @@ jest.mock('@/shared/i18n', () => ({ describe('pages/Operations/components/EmptyState/EmptyOperations', () => { test('should render component', () => { - render(); + render(); const label = screen.getByText('operations.noOperationsWalletNotMulti'); diff --git a/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.tsx b/src/renderer/pages/Operations/components/EmptyState/EmptyOperations.tsx index 67f3483659..1c81aaf654 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 | null; isEmptyFromFilters: boolean; }; diff --git a/src/renderer/pages/Operations/components/FlexibleMultisigShell.tsx b/src/renderer/pages/Operations/components/FlexibleMultisigShell.tsx new file mode 100644 index 0000000000..91a4ebb3eb --- /dev/null +++ b/src/renderer/pages/Operations/components/FlexibleMultisigShell.tsx @@ -0,0 +1,195 @@ +import { useUnit } from 'effector-react'; +import { memo } from 'react'; + +import { type FlexibleMultisigTransactionDS } from '@/shared/api/storage'; +import { + type Chain, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, + type MultisigEvent, + type MultisigTransaction, + type Signatory, + type SigningStatus, + type Wallet, +} from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { BodyText, Button, Header, Plate, SmallTitleText } from '@/shared/ui'; +import { Address } from '@/shared/ui-entities'; +import { Accordion, Box, Progress } from '@/shared/ui-kit'; +import { contactModel } from '@/entities/contact'; +import { useMultisigEvent } from '@/entities/multisig'; +import { type ExtendedChain, useNetworkData } from '@/entities/network'; +import { SignatoryCard, signatoryUtils } from '@/entities/signatory'; +import { WalletIcon, permissionUtils, walletModel } from '@/entities/wallet'; +import { getSignatoryName, getSignatoryStatus } from '../common/utils'; + +import { OperationAdvancedDetails } from './OperationAdvancedDetails'; +import { Status } from './Status'; +import ApproveTxModal from './modals/ApproveTx'; +import RejectTxModal from './modals/RejectTx'; + +type Props = { + tx: FlexibleMultisigTransactionDS; + account: FlexibleMultisigAccount; +}; + +export const FlexibleMultisigShell = memo(({ tx, account }: Props) => { + const { t } = useI18n(); + const { connection, chain, api, extendedChain } = useNetworkData(tx.chainId); + + const wallets = useUnit(walletModel.$wallets); + const { getLiveEventsByKeys } = useMultisigEvent({}); + + const events = getLiveEventsByKeys([tx]); + const approvals = events?.filter((e) => e.status === 'SIGNED') || []; + + const isRejectAvailable = wallets.some((wallet) => { + const hasDepositor = wallet.accounts.some((account) => account.accountId === tx.depositor); + + return hasDepositor && permissionUtils.canRejectMultisigTx(wallet); + }); + + return ( +
      +
      + + + + + + + + {t('operation.createFlexibleMultisig.title')} + + + + + {t('operation.createFlexibleMultisig.description')} + + + +
      + {connection && isRejectAvailable && ( + + + + )} + {connection && ( + + + + )} +
      + + +
      + +
      + ); +}); + +type SignatoriesParams = { + signatories: Signatory[]; + connection: ExtendedChain; + events: MultisigEvent[]; +}; +type WalletSignatory = Signatory & { + wallet: Wallet; + status: SigningStatus | null; +}; + +const Signatories = memo(({ signatories, connection, events }: SignatoriesParams) => { + const { t } = useI18n(); + + const wallets = useUnit(walletModel.$wallets); + const contacts = useUnit(contactModel.$contacts); + + const walletSignatories = signatories + .reduce((acc, signatory) => { + const signatoryWallet = signatoryUtils.getSignatoryWallet(wallets, signatory.accountId); + const status = getSignatoryStatus(events, signatory.accountId); + + if (signatoryWallet) { + acc.push({ ...signatory, wallet: signatoryWallet, status }); + } + + return acc; + }, []) + .sort((wallet) => (wallet.status === 'SIGNED' ? -1 : 1)); + + const walletSignatoriesIds = walletSignatories.map((a) => a.accountId); + const contactSignatories = signatories.filter((s) => !walletSignatoriesIds.includes(s.accountId)); + + return ( + + {t('operation.signatoriesTitleCount', { count: signatories.length })} + +
      + {walletSignatories.length > 0 && ( +
        + {walletSignatories.map((signatory) => ( + +
        + +
        +
        +
        + ))} + + {contactSignatories.map((signatory) => ( + +
        + + ))} +
      + )} +
      +
      +
      + ); +}); + +const Details = ({ tx, chain }: { tx: MultisigTransaction | FlexibleMultisigTransaction; chain: Chain }) => { + const { t } = useI18n(); + const wallets = useUnit(walletModel.$wallets); + + return ( + + {t('operation.detailsTitle')} + +
      + +
      +
      +
      + ); +}; diff --git a/src/renderer/pages/Operations/components/LogModal.tsx b/src/renderer/pages/Operations/components/LogModal.tsx index 23111cef60..e1b5b3169e 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, @@ -26,8 +27,8 @@ 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; @@ -115,7 +116,7 @@ const LogModal = ({ isOpen, onClose, tx, account, connection, contacts }: Props) {asset && amount && } - +
      diff --git a/src/renderer/pages/Operations/components/Operation.tsx b/src/renderer/pages/Operations/components/Operation.tsx index e59212c597..518f6860aa 100644 --- a/src/renderer/pages/Operations/components/Operation.tsx +++ b/src/renderer/pages/Operations/components/Operation.tsx @@ -1,6 +1,6 @@ import { chainsService } from '@/shared/api/network'; -import { type MultisigTransactionDS } from '@/shared/api/storage'; -import { type MultisigAccount } from '@/shared/core'; +import { type FlexibleMultisigTransactionDS, type MultisigTransactionDS } from '@/shared/api/storage'; +import { type FlexibleMultisigAccount, type MultisigAccount } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { getAssetById } from '@/shared/lib/utils'; import { Accordion, FootnoteText } from '@/shared/ui'; @@ -13,8 +13,8 @@ import { OperationFullInfo } from './OperationFullInfo'; import { Status } from './Status'; type Props = { - tx: MultisigTransactionDS; - account?: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account: MultisigAccount | FlexibleMultisigAccount | null; }; const Operation = ({ tx, account }: Props) => { diff --git a/src/renderer/pages/Operations/components/OperationAdvancedDetails.tsx b/src/renderer/pages/Operations/components/OperationAdvancedDetails.tsx new file mode 100644 index 0000000000..b656709c97 --- /dev/null +++ b/src/renderer/pages/Operations/components/OperationAdvancedDetails.tsx @@ -0,0 +1,123 @@ +import { type Chain, type FlexibleMultisigTransaction, type MultisigTransaction, type Wallet } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { cnTw, copyToClipboard, truncate } from '@/shared/lib/utils'; +import { DetailRow, FootnoteText, Icon } from '@/shared/ui'; +import { Box } from '@/shared/ui-kit'; +import { AssetBalance } from '@/entities/asset'; +import { signatoryUtils } from '@/entities/signatory'; +import { AddressWithExplorers, ExplorersPopover, WalletCardSm } from '@/entities/wallet'; +import { AddressStyle, InteractionStyle } from '../common/constants'; +import { getMultisigExtrinsicLink } from '../common/utils'; + +type Props = { + tx: MultisigTransaction | FlexibleMultisigTransaction; + wallets: Wallet[]; + chain: Chain; +}; + +export const OperationAdvancedDetails = ({ tx, wallets, chain }: Props) => { + const { t } = useI18n(); + const { signatories, indexCreated, blockCreated, deposit, depositor, callHash, callData } = tx; + const valueClass = 'text-text-secondary'; + + const extrinsicLink = getMultisigExtrinsicLink(callHash, indexCreated, blockCreated, chain.explorers); + const depositorSignatory = signatories.find((s) => s.accountId === depositor); + const depositorWallet = + depositorSignatory && signatoryUtils.getSignatoryWallet(wallets, depositorSignatory.accountId); + + const defaultAsset = chain.assets.at(0); + + if (!defaultAsset) { + return null; + } + + return ( + + {callHash && ( + + + + )} + + {callData && ( + + + + )} + + {deposit && defaultAsset && depositorSignatory &&
      } + + {depositorSignatory && ( + +
      + {depositorWallet ? ( + } + address={depositorSignatory.accountId} + explorers={chain.explorers} + addressPrefix={chain.addressPrefix} + /> + ) : ( + + )} +
      +
      + )} + + {deposit && defaultAsset && ( + + + + )} + + {deposit && defaultAsset && depositorSignatory &&
      } + + {indexCreated && blockCreated && ( + + {extrinsicLink ? ( + + + {blockCreated}-{indexCreated} + + + + ) : ( + `${blockCreated}-${indexCreated}` + )} + + )} +
      + ); +}; diff --git a/src/renderer/pages/Operations/components/OperationCardDetails.tsx b/src/renderer/pages/Operations/components/OperationCardDetails.tsx index 565982e813..eeecd552e9 100644 --- a/src/renderer/pages/Operations/components/OperationCardDetails.tsx +++ b/src/renderer/pages/Operations/components/OperationCardDetails.tsx @@ -5,6 +5,8 @@ import { Trans } from 'react-i18next'; import { chainsService } from '@/shared/api/network'; import { type Address, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, type MultisigAccount, type MultisigTransaction, type Transaction, @@ -13,7 +15,7 @@ import { } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useToggle } from '@/shared/lib/hooks'; -import { cnTw, copyToClipboard, getAssetById, toAccountId, truncate } from '@/shared/lib/utils'; +import { cnTw, getAssetById, nonNullable, toAccountId } from '@/shared/lib/utils'; import { type AccountId } from '@/shared/polkadotjs-schemas'; import { Button, DetailRow, FootnoteText, Icon } from '@/shared/ui'; import { Skeleton } from '@/shared/ui-kit'; @@ -24,7 +26,6 @@ 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, @@ -43,7 +44,6 @@ import { getDelegationVotes, getDestination, getDestinationChain, - getMultisigExtrinsicLink, getPayee, getProxyType, getReferendumId, @@ -53,9 +53,11 @@ import { // eslint-disable-next-line import-x/max-dependencies } from '../common/utils'; +import { OperationAdvancedDetails } from './OperationAdvancedDetails'; + type Props = { - tx: MultisigTransaction; - account?: MultisigAccount; + tx: MultisigTransaction | FlexibleMultisigTransaction; + account: MultisigAccount | FlexibleMultisigAccount | null; extendedChain?: ExtendedChain; }; @@ -113,8 +115,6 @@ export const OperationCardDetails = ({ tx, account, extendedChain }: Props) => { 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)); @@ -139,14 +139,10 @@ export const OperationCardDetails = ({ tx, account, extendedChain }: Props) => { 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 (
      @@ -408,94 +404,8 @@ export const OperationCardDetails = ({ tx, account, extendedChain }: Props) => { {t('operation.advanced')} - {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}` - )} - - )} - + {isAdvancedShown && nonNullable(account) && nonNullable(extendedChain) && ( + )}
      ); diff --git a/src/renderer/pages/Operations/components/OperationFullInfo.tsx b/src/renderer/pages/Operations/components/OperationFullInfo.tsx index 6f885ceb57..aff389270f 100644 --- a/src/renderer/pages/Operations/components/OperationFullInfo.tsx +++ b/src/renderer/pages/Operations/components/OperationFullInfo.tsx @@ -1,8 +1,8 @@ import { useStoreMap, 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 { useI18n } from '@/shared/i18n'; import { useToggle } from '@/shared/lib/hooks'; import { validateCallData } from '@/shared/lib/utils'; @@ -20,8 +20,8 @@ import CallDataModal from './modals/CallDataModal'; import RejectTxModal from './modals/RejectTx'; type Props = { - tx: MultisigTransactionDS; - account?: MultisigAccount; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account: MultisigAccount | FlexibleMultisigAccount | null; }; export const OperationFullInfo = ({ tx, account }: Props) => { @@ -99,14 +99,14 @@ export const OperationFullInfo = ({ tx, account }: Props) => {
      {connection && isRejectAvailable && account && ( - + )} {account && isApproveAvailable && connection && ( - + )} diff --git a/src/renderer/pages/Operations/components/OperationSignatories.tsx b/src/renderer/pages/Operations/components/OperationSignatories.tsx index 358e79e74f..67bb9194f2 100644 --- a/src/renderer/pages/Operations/components/OperationSignatories.tsx +++ b/src/renderer/pages/Operations/components/OperationSignatories.tsx @@ -2,12 +2,12 @@ import { useUnit } from 'effector-react'; import { useEffect, useState } from 'react'; import { - type AccountId, + type FlexibleMultisigAccount, + type FlexibleMultisigTransaction, type MultisigAccount, type MultisigEvent, type MultisigTransaction, type Signatory, - type SigningStatus, type Wallet, } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; @@ -19,16 +19,16 @@ import { useMultisigEvent } from '@/entities/multisig'; import { type ExtendedChain } from '@/entities/network'; import { SignatoryCard, signatoryUtils } from '@/entities/signatory'; import { AddressWithName, WalletIcon, walletModel } from '@/entities/wallet'; -import { getSignatoryName } from '../common/utils'; +import { getSignatoryName, getSignatoryStatus } from '../common/utils'; 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) => { @@ -78,16 +78,6 @@ export const OperationSignatories = ({ tx, connection, account }: Props) => { setSignatories([...new Set([...tempCancellation, ...tempApprovals, ...signatories])]); }, [signatories.length, approvals.length, cancellation.length]); - const getSignatoryStatus = (signatory: AccountId): SigningStatus | undefined => { - const cancelEvent = events.find((e) => e.status === 'CANCELLED' && e.accountId === signatory); - if (cancelEvent) { - return cancelEvent.status; - } - const signedEvent = events.find((e) => e.status === 'SIGNED' && e.accountId === signatory); - - return signedEvent?.status; - }; - return (
      @@ -121,7 +111,7 @@ export const OperationSignatories = ({ tx, connection, account }: Props) => { key={signatory.accountId} accountId={signatory.accountId} addressPrefix={connection.addressPrefix} - status={getSignatoryStatus(signatory.accountId)} + status={getSignatoryStatus(events, signatory.accountId)} explorers={connection.explorers} > @@ -143,7 +133,7 @@ export const OperationSignatories = ({ tx, connection, account }: Props) => { key={signatory.accountId} accountId={signatory.accountId} addressPrefix={connection.addressPrefix} - status={getSignatoryStatus(signatory.accountId)} + status={getSignatoryStatus(events, signatory.accountId)} explorers={connection.explorers} > = { type Props = { status: MultisigTransaction['status']; - signed?: number; - threshold?: number; - className?: string; + signed: number; + threshold: number; }; -export const Status = ({ status, signed, threshold, className }: Props) => { +export const Status = ({ status, signed, threshold }: Props) => { const { t } = useI18n(); - const text = - status === 'SIGNING' ? t('operation.signing', { signed, threshold: threshold || 0 }) : t(StatusTitle[status]); + const text = status === 'SIGNING' ? t('operation.signing', { signed, threshold }) : t(StatusTitle[status]); return ( - + {text} ); diff --git a/src/renderer/pages/Operations/components/modals/ApproveTx.tsx b/src/renderer/pages/Operations/components/modals/ApproveTx.tsx index 77f76d2056..65297f2fd5 100644 --- a/src/renderer/pages/Operations/components/modals/ApproveTx.tsx +++ b/src/renderer/pages/Operations/components/modals/ApproveTx.tsx @@ -1,12 +1,15 @@ +import { type ApiPromise } from '@polkadot/api'; import { type Weight } from '@polkadot/types/interfaces'; 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 Chain, + type FlexibleMultisigAccount, type HexString, type MultisigAccount, type Timepoint, @@ -21,7 +24,7 @@ import { Modal } from '@/shared/ui-kit'; import { balanceModel, balanceUtils } from '@/entities/balance'; import { OperationTitle } from '@/entities/chain'; import { useMultisigEvent } from '@/entities/multisig'; -import { type ExtendedChain, networkModel } from '@/entities/network'; +import { networkModel } from '@/entities/network'; import { priceProviderModel } from '@/entities/price'; import { MAX_WEIGHT, @@ -41,9 +44,10 @@ import { Submit } from '../ActionSteps/Submit'; import { SignatorySelectModal } from './SignatorySelectModal'; type Props = { - tx: MultisigTransactionDS; - account: MultisigAccount; - connection: ExtendedChain; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account: MultisigAccount | FlexibleMultisigAccount; + chain: Chain; + api: ApiPromise; children: React.ReactNode; }; @@ -55,7 +59,7 @@ const enum Step { const AllSteps = [Step.CONFIRMATION, Step.SIGNING, Step.SUBMIT]; -const ApproveTxModal = ({ tx, account, connection, children }: Props) => { +const ApproveTxModal = ({ tx, account, api, chain, children }: Props) => { const { t } = useI18n(); const wallets = useUnit(walletModel.$wallets); const balances = useUnit(balanceModel.$balances); @@ -80,8 +84,8 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { const transactionTitle = getMultisigSignOperationTitle(isXcmTransaction(tx.transaction), t, feeTx?.type, tx); - const nativeAsset = connection.assets[0]; - const asset = getAssetById(tx.transaction?.args.assetId, connection.assets); + const nativeAsset = chain.assets[0]; + const asset = getAssetById(tx.transaction?.args.assetId, chain.assets); const availableAccounts = wallets.reduce((acc, wallet) => { if (permissionUtils.canApproveMultisigTx(wallet)) { @@ -108,16 +112,16 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { const initWeight = async () => { let weight; try { - if (!tx.callData || !connection.api) return; + if (!tx.callData || !api) return; - const transaction = getTxFromCallData(connection.api, tx.callData); + const transaction = getTxFromCallData(api, tx.callData); weight = await transactionService.getExtrinsicWeight(transaction); } catch { - if (tx.transaction?.args && connection.api) { - weight = await transactionService.getTxWeight(tx.transaction as Transaction, connection.api); + if (tx.transaction?.args && api) { + weight = await transactionService.getTxWeight(tx.transaction as Transaction, api); } else { - weight = connection.api?.createType('Weight', MAX_WEIGHT); + weight = api.createType('Weight', MAX_WEIGHT); } } @@ -126,7 +130,7 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { useEffect(() => { initWeight(); - }, [tx.transaction, connection.api]); + }, [tx.transaction, api]); const goBack = () => { setActiveStep(AllSteps.indexOf(activeStep) - 1); @@ -143,10 +147,10 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { }; const getMultisigTx = (signer: Address): Transaction => { - const signerAddress = toAddress(signer, { prefix: connection?.addressPrefix }); + const signerAddress = toAddress(signer, { prefix: chain?.addressPrefix }); const otherSignatories = account.signatories.reduce((acc, s) => { - const signatoryAddress = toAddress(s.accountId, { prefix: connection?.addressPrefix }); + const signatoryAddress = toAddress(s.accountId, { prefix: chain?.addressPrefix }); if (signerAddress !== signatoryAddress) { acc.push(signatoryAddress); @@ -176,15 +180,15 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { }; const validateBalanceForFee = async (signAccount: Account): Promise => { - if (!connection.api || !feeTx || !signAccount.accountId || !nativeAsset) { + if (!api || !feeTx || !signAccount.accountId || !nativeAsset) { return false; } - const fee = await transactionService.getTransactionFee(feeTx, connection.api); + const fee = await transactionService.getTransactionFee(feeTx, api); const balance = balanceUtils.getBalance( balances, signAccount.accountId, - connection.chainId, + chain.chainId, nativeAsset.assetId.toString(), ); @@ -219,7 +223,7 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { const checkBalance = () => validateBalance({ - api: connection.api, + api, chainId: tx.chainId, transaction: approveTx, assetId: nativeAsset.assetId.toString(), @@ -238,11 +242,11 @@ const ApproveTxModal = ({ tx, account, connection, children }: Props) => { } const isSubmitStep = activeStep === Step.SUBMIT && approveTx && signAccount && signature && txPayload; - if (isSubmitStep && connection.api) { + if (isSubmitStep && api) { return ( { )} - {activeStep === Step.SIGNING && approveTx && connection.api && signAccount && ( + {activeStep === Step.SIGNING && approveTx && api && signAccount && ( w.id === signAccount.walletId)} apis={apis} signingPayloads={[ { - chain: connection, + chain: chain, account: signAccount, transaction: approveTx, signatory: signAccount, @@ -291,7 +296,7 @@ const ApproveTxModal = ({ tx, account, connection, children }: 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..88968c4a88 100644 --- a/src/renderer/pages/Operations/components/modals/RejectTx.tsx +++ b/src/renderer/pages/Operations/components/modals/RejectTx.tsx @@ -1,10 +1,19 @@ +import { type ApiPromise } from '@polkadot/api'; import { BN } from '@polkadot/util'; 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 Chain, + 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'; @@ -13,7 +22,7 @@ import { Button } from '@/shared/ui'; import { Modal } from '@/shared/ui-kit'; import { balanceModel, balanceUtils } from '@/entities/balance'; import { OperationTitle } from '@/entities/chain'; -import { type ExtendedChain, networkModel } from '@/entities/network'; +import { networkModel } from '@/entities/network'; import { priceProviderModel } from '@/entities/price'; import { OperationResult, @@ -29,9 +38,10 @@ import { Confirmation } from '../ActionSteps/Confirmation'; import { Submit } from '../ActionSteps/Submit'; type Props = { - tx: MultisigTransactionDS; - account: MultisigAccount; - connection: ExtendedChain; + tx: MultisigTransactionDS | FlexibleMultisigTransactionDS; + account: MultisigAccount | FlexibleMultisigAccount; + chain: Chain; + api: ApiPromise; children: React.ReactNode; }; @@ -43,7 +53,7 @@ const enum Step { const AllSteps = [Step.CONFIRMATION, Step.SIGNING, Step.SUBMIT]; -const RejectTxModal = ({ tx, account, connection, children }: Props) => { +const RejectTxModal = ({ api, tx, account, chain, children }: Props) => { const { t } = useI18n(); const wallets = useUnit(walletModel.$wallets); @@ -66,8 +76,8 @@ const RejectTxModal = ({ tx, account, connection, children }: Props) => { tx, ); - const nativeAsset = connection.assets[0]; - const asset = getAssetById(tx.transaction?.args.assetId, connection.assets); + const nativeAsset = chain.assets[0]; + const asset = getAssetById(tx.transaction?.args.assetId, chain.assets); const signAccount = walletUtils.getWalletFilteredAccounts(wallets, { walletFn: walletUtils.isValidSignatory, @@ -76,7 +86,7 @@ const RejectTxModal = ({ tx, account, connection, children }: Props) => { const checkBalance = () => validateBalance({ - api: connection.api, + api, chainId: tx.chainId, transaction: rejectTx, assetId: nativeAsset.assetId.toString(), @@ -109,10 +119,10 @@ const RejectTxModal = ({ tx, account, connection, children }: Props) => { }; const getMultisigTx = (signer: Address): Transaction => { - const signerAddress = toAddress(signer, { prefix: connection?.addressPrefix }); + const signerAddress = toAddress(signer, { prefix: chain?.addressPrefix }); const otherSignatories = account.signatories.reduce((acc, s) => { - const signatoryAddress = toAddress(s.accountId, { prefix: connection?.addressPrefix }); + const signatoryAddress = toAddress(s.accountId, { prefix: chain?.addressPrefix }); if (signerAddress !== signatoryAddress) { acc.push(signatoryAddress); @@ -122,7 +132,7 @@ const RejectTxModal = ({ tx, account, connection, children }: Props) => { }, []); return transactionBuilder.buildRejectMultisigTx({ - chain: connection, + chain: chain, signerAddress, threshold: account.threshold, otherSignatories: sortBy(otherSignatories), @@ -131,15 +141,15 @@ const RejectTxModal = ({ tx, account, connection, children }: Props) => { }; const validateBalanceForFee = async (signAccount: Account): Promise => { - if (!connection.api || !rejectTx || !signAccount.accountId || !nativeAsset) { + if (!api || !rejectTx || !signAccount.accountId || !nativeAsset) { return false; } - const fee = await transactionService.getTransactionFee(rejectTx, connection.api); + const fee = await transactionService.getTransactionFee(rejectTx, api); const balance = balanceUtils.getBalance( balances, signAccount.accountId, - connection.chainId, + chain.chainId, nativeAsset.assetId.toString(), ); @@ -167,12 +177,12 @@ const RejectTxModal = ({ tx, account, connection, children }: Props) => { const isSubmitStep = activeStep === Step.SUBMIT && rejectTx && signAccount && signature && txPayload; - if (isSubmitStep && connection.api) { + if (isSubmitStep && api) { return ( { )} - {activeStep === Step.SIGNING && rejectTx && connection.api && signAccount && ( + {activeStep === Step.SIGNING && rejectTx && api && signAccount && ( w.id === signAccount.walletId)} apis={apis} signingPayloads={[ { - chain: connection, + chain: chain, account: signAccount, transaction: rejectTx, signatory: signAccount, diff --git a/src/renderer/pages/Operations/model/context.ts b/src/renderer/pages/Operations/model/context.ts new file mode 100644 index 0000000000..5f55f2dc4a --- /dev/null +++ b/src/renderer/pages/Operations/model/context.ts @@ -0,0 +1,34 @@ +import { combine } from 'effector'; + +import { nonNullable, nullable } from '@/shared/lib/utils'; +import { networkModel } from '@/entities/network'; +import { operationsModel } from '@/entities/operations'; +import { isCreatePureProxyTransaction } from '@/entities/transaction'; +import { accountUtils, walletModel } from '@/entities/wallet'; + +const $availableTransaction = combine(operationsModel.$multisigTransactions, networkModel.$chains, (txs, chains) => { + return txs.filter((tx) => tx.chainId in chains); +}); + +const $account = walletModel.$activeWallet.map((x) => x?.accounts.find(accountUtils.isMultisigAccount) ?? null); + +const $incompleteFlexibleMultisigTx = combine($account, $availableTransaction, (account, txs) => { + const signingTransactions = txs.filter((tx) => tx.status === 'SIGNING'); + + if ( + nonNullable(account) && + accountUtils.isFlexibleMultisigAccount(account) && + nullable(account.proxyAccountId) && + signingTransactions.length === 1 + ) { + return signingTransactions.find((tx) => isCreatePureProxyTransaction(tx.transaction)) ?? null; + } + + return null; +}); + +export const operationsContextModel = { + $account, + $incompleteFlexibleMultisigTx, + $availableTransaction, +}; 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 642239ac37..8193cafc6c 100644 --- a/src/renderer/pages/Staking/ui/Staking.tsx +++ b/src/renderer/pages/Staking/ui/Staking.tsx @@ -26,7 +26,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'; @@ -37,6 +37,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(); @@ -60,6 +64,7 @@ export const Staking = () => { const [selectedNominators, setSelectedNominators] = useState([]); const [selectedStash, setSelectedStash] = useState
      (''); + const [showWalletDetails, setShowWalletDetails] = useState(false); const identities = useStoreMap({ store: identityDomain.identity.$list, @@ -347,7 +352,7 @@ export const Staking = () => { {networkIsActive && activeWallet && accounts.length === 0 && ( }> {walletUtils.isPolkadotVault(activeWallet) && ( - )} @@ -369,6 +374,12 @@ export const Staking = () => { onClose={toggleNominators} /> + setShowWalletDetails(false)} + /> + diff --git a/src/renderer/processes/multisigs/index.ts b/src/renderer/processes/multisigs/index.ts deleted file mode 100644 index 1dc90446aa..0000000000 --- a/src/renderer/processes/multisigs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { multisigsModel } from './model/multisigs-model'; diff --git a/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts b/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts deleted file mode 100644 index 91587676c8..0000000000 --- a/src/renderer/processes/multisigs/lib/mulitisigs-utils.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AccountType, type Chain, ChainType, CryptoType, SigningType, WalletType } from '@/shared/core'; -import { isEthereumAccountId, toAddress } from '@/shared/lib/utils'; - -export const multisigUtils = { - buildMultisig, -}; - -type BuildMultisigParams = { - threshold: number; - accountId: `0x${string}`; - signatories: string[]; - chain: Chain; -}; - -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, - }, - ], - }; -} diff --git a/src/renderer/processes/multisigs/model/multisigs-model.ts b/src/renderer/processes/multisigs/model/multisigs-model.ts deleted file mode 100644 index 0d6aeb2fc3..0000000000 --- a/src/renderer/processes/multisigs/model/multisigs-model.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { combine, createEffect, createEvent, sample, scopeBind } from 'effector'; -import { GraphQLClient } from 'graphql-request'; -import { interval, once } from 'patronum'; - -import { - type Chain, - ExternalType, - type MultisigAccount, - type MultisigCreated, - type NoID, - NotificationType, - type Wallet, -} from '@/shared/core'; -import { nullable } from '@/shared/lib/utils'; -import { type MultisigResult, 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 $multisigChains = combine(networkModel.$chains, (chains) => { - return Object.values(chains).filter((chain) => { - const isMultisigSupported = networkUtils.isMultisigSupported(chain.options); - const hasIndexerUrl = chain.externalApi?.[ExternalType.PROXY]?.at(0)?.url; - - return isMultisigSupported && hasIndexerUrl; - }); -}); - -type GetMultisigsParams = { - chains: Chain[]; - wallets: Wallet[]; -}; - -type GetMultisigsResult = { - 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; - - const filteredWallets = walletUtils.getWalletsFilteredAccounts(wallets, { - walletFn: (w) => !walletUtils.isMultisig(w) && !walletUtils.isWatchOnly(w) && !walletUtils.isProxied(w), - accountFn: (a) => accountUtils.isChainIdMatch(a, chain.chainId), - }); - - 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 && accountUtils.isChainIdMatch(a, chain.chainId), - }); - if (existingWallet) continue; - - walletsToSave.push(multisig); - } - - if (walletsToSave.length > 0) { - boundMultisigSaved({ indexedMultisigs: walletsToSave, chain }); - } - }) - .catch(console.error); - } -}); - -const saveMultisigFx = createEffect((multisigsToSave: SaveMultisigParams[]) => { - for (const multisig of multisigsToSave) { - walletModel.events.multisigCreated(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, - ]); - } -}); - -const { tick: multisigsDiscoveryTriggered } = interval({ - start: multisigsDiscoveryStarted, - timeout: MULTISIG_DISCOVERY_TIMEOUT, -}); - -sample({ - clock: [multisigsDiscoveryTriggered, once(networkModel.$connections)], - source: { - chains: $multisigChains, - wallets: walletModel.$allWallets, - connections: networkModel.$connections, - }, - fn: ({ chains, wallets, connections }) => { - const filteredChains = chains.filter((chain) => { - if (nullable(connections[chain.chainId])) return false; - - return !networkUtils.isDisabledConnection(connections[chain.chainId]); - }); - - return { wallets, chains: filteredChains }; - }, - target: getMultisigsFx, -}); - -sample({ - clock: multisigSaved, - fn: ({ indexedMultisigs, chain }) => { - return indexedMultisigs.map( - ({ threshold, accountId, signatories }) => - ({ - ...multisigUtils.buildMultisig({ threshold, accountId, signatories, chain }), - external: true, - }) as SaveMultisigParams, - ); - }, - target: saveMultisigFx, -}); - -export const multisigsModel = { - events: { - multisigsDiscoveryStarted, - }, - - _test: { - saveMultisigFx, - }, -}; diff --git a/src/renderer/shared/api/balances/service/balanceService.ts b/src/renderer/shared/api/balances/service/balanceService.ts index 9575aae020..49786c3455 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 { camelCase } from 'lodash'; import noop from 'lodash/noop'; import uniq from 'lodash/uniq'; @@ -26,6 +26,7 @@ type NoIdBalance = Omit; export const balanceService = { subscribeBalances, subscribeLockBalances, + getExistentialDeposit, }; /** @@ -324,3 +325,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/config/features/index.ts b/src/renderer/shared/config/features/index.ts index bd817bcb5f..34eb7519cc 100644 --- a/src/renderer/shared/config/features/index.ts +++ b/src/renderer/shared/config/features/index.ts @@ -17,6 +17,7 @@ export const $features = createStore({ contacts: true, notifications: true, settings: true, + flexible: true, }); persist({ 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/i18n/locales/en.json b/src/renderer/shared/i18n/locales/en.json index c10c0b722f..909f631b5a 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", @@ -135,22 +136,21 @@ "unsupportedType": "This type of wallet is not supported for the Multisig wallet creation" }, "disabledLabel": "Not supported", - "duplicateSignatoryErrorTitle": "Duplicate signatory", - "duplicateSignatoryErrorText": "The list must consist of unique signatories", + "duplicateSignatoryAddress": "Signatory address already exist", "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.", - "multisigHiddenExistText": "A multisig wallet with the selected signatories and threshold already exists but was previously removed by you. Would you like to restore it now?", "multisigExistTitle": "Multisig wallet already exists", + "multisigHiddenExistText": "A multisig wallet with the selected signatories and threshold already exists but was previously removed by you.
      Would you like to restore it now?", "multisigStep": "Step { step }.", "nameDescription": "Give a name to your multisig account", "nameLabel": "Account", @@ -163,44 +163,46 @@ "noOwnSignatoryTitle": "No owned account", "noWallets": "Looks like you don't have any non-watch only wallets", "noWalletsLabel": "The wallet list is empty", - "notEnoughTokensTitle": "Not enough tokens", - "notEnoughMultisigTokens": "Not enough tokens for paying multisig deposit and network fee on a selected account", - "notEnoughSignatories": "You need to select at least 2 signatories", - "notEnoughSignatoriesTitle": "Not enough signatories", - "notEmptySignatoryTitle": "Empty signatory", - "notEmptySignatoryNameTitle": "Empty signatory name", "notEmptySignatory": "No empty signatory allowed", "notEmptySignatoryName": "No empty signatory name allowed", - "ownAccountSelection": "Address", + "notEmptySignatoryNameTitle": "Empty signatory name", + "notEmptySignatoryTitle": "Empty signatory", + "notEnoughMultisigTokens": "Selected wallets do not have sufficient balance to pay for the proxy deposit, multisig deposit, and network fees", + "notEnoughSignatories": "You need to select at least 2 signatories", + "notEnoughSignatoriesTitle": "Not enough signatories", + "notEnoughTokensTitle": "Not enough tokens", "restoreButton": "Restore", "searchContactPlaceholder": "Search", - "selectSigner": "Select signer", + "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 signing wallet", "selectedSignatoriesTitle": "Selected signatories", "signatoriesDescription": "Add signatories to your multisig account", "signatoriesLabel": "Signatories", - "signatoryAddress": "Signatory address", + "signatoryAddress": "Address", "signatoryNameLabel": "Signatory name", "signatorySelection": "Choose or enter address", "signatoryThresholdDescription": "Assign the signatories and set the signatory threshold for your multisig.", "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", "thresholdErrorDescription": "Threshold must be set and superior or equal to { minThreshold }", + "thresholdErrorTitle": "Invalid threshold", "thresholdHint": "Minimum number of signatories needed to approve a transaction.", "thresholdName": "Threshold", "thresholdOutOf": "{ threshold } out of { signatoriesLength }", - "thresholdPlaceholder": "Select threshold", + "thresholdPlaceholder": "Select number", "title": "Create multisig", "titleOn": "on", "walletFormTitle": "Set up your wallet", @@ -296,6 +298,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 +407,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 +779,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" @@ -929,6 +928,10 @@ "cancelTitle": "Reject", "contactSignatoriesTitle": "Contacts", "continueButton": "Continue", + "createFlexibleMultisig": { + "title": "Flexible multisig is creating...", + "description": "Wait for the signing of the operation from all the signatories of the wallet. You can see the operation status below." + }, "details": { "accessType": "Access type", "account": "Account", @@ -990,6 +993,7 @@ "walletConnect": "Sign with WalletConnect" }, "signatoriesTitle": "Signatories", + "signatoriesTitleCount": "Signatories { count }", "signing": "{ signed } of { threshold } signed", "status": { "cancelled": "Rejected", @@ -1155,7 +1159,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 +1664,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 +1710,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__/address.test.ts b/src/renderer/shared/lib/utils/__tests__/address.test.ts index 865007ee6b..62dc126bf1 100644 --- a/src/renderer/shared/lib/utils/__tests__/address.test.ts +++ b/src/renderer/shared/lib/utils/__tests__/address.test.ts @@ -1,7 +1,8 @@ +import { type Chain, ChainOptions } from '@/shared/core'; import { toAddress, validateAddress } from '../address'; import { TEST_ACCOUNTS, TEST_ADDRESS } from '../constants'; -describe('shared/lib/onChainUtils/address', () => { +describe('toAddress', () => { test('should convert address to Polkadot', () => { const address = toAddress(TEST_ACCOUNTS[0], { prefix: 0 }); expect(address).toEqual(TEST_ADDRESS); @@ -21,44 +22,65 @@ describe('shared/lib/onChainUtils/address', () => { const address = toAddress(TEST_ADDRESS, { prefix: 0 }); expect(address).toEqual(TEST_ADDRESS); }); +}); + +describe('validateAddress', () => { + const substrateChain = {} as Chain; + const evmChain = { options: [ChainOptions.ETHEREUM_BASED] } as Chain; test('should fail validation for short address', () => { - const result = validateAddress('0x00'); + const result = validateAddress('0x00', substrateChain); expect(result).toEqual(false); }); test('should fail validation for invalid public key', () => { - const result = validateAddress('0xf5d5714c08vc112843aca74f8c498da06cc5a2d63153b825189baa51043b1f0b'); + const result = validateAddress( + '0xf5d5714c08vc112843aca74f8c498da06cc5a2d63153b825189baa51043b1f0b', + substrateChain, + ); expect(result).toEqual(false); }); test('should fail validation for incorrect ss58 address', () => { - const result = validateAddress('16fL8yLyXv3V3L3z9ofR1ovFLziyXaN1DPq4yffMAZ9czzBD'); + const result = validateAddress('16fL8yLyXv3V3L3z9ofR1ovFLziyXaN1DPq4yffMAZ9czzBD', substrateChain); expect(result).toEqual(false); }); - test('should pass validation for valid public', () => { - const result = validateAddress('0xf5d5714c084c112843aca74f8c498da06cc5a2d63153b825189baa51043b1f0b'); + test('should pass validation for valid public key', () => { + const result = validateAddress( + '0xf5d5714c084c112843aca74f8c498da06cc5a2d63153b825189baa51043b1f0b', + substrateChain, + ); expect(result).toEqual(true); }); test('should pass validation for valid ss58 address', () => { - const result = validateAddress('16ZL8yLyXv3V3L3z9ofR1ovFLziyXaN1DPq4yffMAZ9czzBD'); + const result = validateAddress('16ZL8yLyXv3V3L3z9ofR1ovFLziyXaN1DPq4yffMAZ9czzBD', substrateChain); + expect(result).toEqual(true); + }); + + test('should pass validation for valid H160 address', () => { + const result = validateAddress('0x629C0eC6B23D0E3A2f67c2753660971faa9A1907', evmChain); + expect(result).toEqual(true); + }); + + test('should pass validation for non-normalized H160 address', () => { + const result = validateAddress('0x4c2ab98b646ce36df6a4a4407ab9fcee1c90549a', evmChain); expect(result).toEqual(true); }); - test('should fail validation for random set of bytes', () => { - const result = validateAddress('0x00010200102'); + test('should fail validation for short random set of bytes', () => { + const result = validateAddress('0x00010200102', substrateChain); expect(result).toEqual(false); }); test('should fail validation for invalid set of chars', () => { - const result = validateAddress('randomaddress'); + const result = validateAddress('randomaddress', substrateChain); expect(result).toEqual(false); }); test('short address is not valid', () => { - const result = validateAddress('F7NZ'); + const result = validateAddress('F7NZ', substrateChain); expect(result).toEqual(false); }); }); diff --git a/src/renderer/shared/lib/utils/__tests__/arrays.test.ts b/src/renderer/shared/lib/utils/__tests__/arrays.test.ts index a568836bc0..073e61031d 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, groupBy, merge, splice } from '../arrays'; +import { addUnique, dictionary, groupBy, 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]); + }); + + 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('addUniq', () => { - test('should replace element', () => { - const array = addUnique([1, 2, 3], 2); - expect(array).toEqual([1, 2, 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']; - test('should add new element', () => { - const array = addUnique([1, 2, 3], 4); - expect(array).toEqual([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 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: [], + b: list2, + mergeBy: (s) => s, }); + expect(res).toBe(list2); + }); + + test('should sort', () => { + const list1 = [2, 4, 3]; + const list2 = [1, 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 }]); + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s, + sort: (a, b) => a - b, }); + expect(res).toEqual([1, 2, 3, 4, 5]); }); - describe('merge', () => { - it('should array of strings', () => { - const list1 = ['1', '2', '3', '4']; - const list2 = ['2', '5']; + 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, - }); - expect(res).toEqual(['1', '2', '3', '4', '5']); + 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 }]); + }); - it('should return firrt array if second is empty', () => { - const list1 = ['1', '2', '3', '4']; + 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: [], - mergeBy: (s) => s, - }); - expect(res).toBe(list1); + 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 second array if first is empty', () => { - const list2 = ['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: [], - b: list2, - mergeBy: (s) => s, - }); - expect(res).toBe(list2); +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 sort', () => { - const list1 = [2, 4, 3]; - const list2 = [1, 5]; + test('should create a dictionary using transformer function', () => { + const result = dictionary(data, 'id', (item) => item.name); - 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: 'Alice', + 2: 'Bob', + 3: 'Charlie', }); + }); - 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 }]; + test('should create a dictionary with a plain value as transformer', () => { + const result = dictionary(data, '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({ + 1: 'constant value', + 2: 'constant value', + 3: 'constant value', }); + }); - it('should merge and sort objects', () => { - const list1 = [{ id: 1 }, { id: 5 }, { id: 4 }]; - const list2 = [{ id: 3 }, { id: 2 }]; + 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[]; - 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 }]); + const result = dictionary(incompleteData, 'id', 'constant value'); + + expect(result).toEqual({ + 3: 'constant 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) }, - ]); + test('should handle cases where transformer is undefined', () => { + const result = dictionary(data, 'id', undefined); + + 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}`); + + expect(result).toEqual({ + 1: 'Alice: Developer', + 2: 'Bob: Designer', + 3: 'Charlie: Manager', }); }); diff --git a/src/renderer/shared/lib/utils/address.ts b/src/renderer/shared/lib/utils/address.ts index f6a2964748..4104163c79 100644 --- a/src/renderer/shared/lib/utils/address.ts +++ b/src/renderer/shared/lib/utils/address.ts @@ -1,8 +1,9 @@ import { hexToU8a, isHex, isU8a, u8aToHex, u8aToU8a } from '@polkadot/util'; import { base58Decode, checkAddressChecksum, decodeAddress, encodeAddress } from '@polkadot/util-crypto'; -import { type AccountId, type Address } from '@/shared/core'; +import { type AccountId, type Address, type Chain } from '@/shared/core'; +import { isEvmChain } from './chains'; import { ADDRESS_ALLOWED_ENCODED_LENGTHS, ETHEREUM_PUBLIC_KEY_LENGTH_BYTES, @@ -48,17 +49,6 @@ export const toShortAddress = (address: Address, chunk = 6): string => { return address.length < 13 ? address : truncate(address, chunk, chunk); }; -/** - * Check is account's address valid - * - * @param address Account's address - * - * @returns {Boolean} - */ -export const validateAddress = (address?: Address | AccountId): boolean => { - return validateEthereumAddress(address) || validateSubstrateAddress(address); -}; - /** * Try to get account id of the address * @@ -99,28 +89,26 @@ export const isEthereumAccountId = (accountId?: AccountId): boolean => { } }; -export const validateEthereumAddress = (address?: Address | AccountId): boolean => { - if (!address) return false; - - if (isU8a(address) || isHex(address)) { - return ETHEREUM_PUBLIC_KEY_LENGTH_BYTES === u8aToU8a(address).length; - } - - return false; -}; - /** * Check is account's address valid * * @param address Account's address + * @param chain Chain to operate * * @returns {Boolean} */ -export const validateSubstrateAddress = (address?: Address | AccountId): boolean => { - if (!address) return false; +export const validateAddress = (address: Address | AccountId, chain?: Chain): boolean => { + // TODO: Only to support previous version. Make `chain` mandatory after refactoring all places of use + if (!chain) { + return validateEvmAddress(address) || validateSubstrateAddress(address); + } + return isEvmChain(chain) ? validateEvmAddress(address) : validateSubstrateAddress(address); +}; + +const validateSubstrateAddress = (address: Address | AccountId): boolean => { if (isU8a(address) || isHex(address)) { - return PUBLIC_KEY_LENGTH_BYTES === u8aToU8a(address).length; + return u8aToU8a(address).length === PUBLIC_KEY_LENGTH_BYTES; } try { @@ -134,3 +122,9 @@ export const validateSubstrateAddress = (address?: Address | AccountId): boolean return false; } }; + +const validateEvmAddress = (address: Address | AccountId): boolean => { + if (!isU8a(address) && !isHex(address)) return false; + + return u8aToU8a(address).length === ETHEREUM_PUBLIC_KEY_LENGTH_BYTES; +}; diff --git a/src/renderer/shared/lib/utils/arrays.ts b/src/renderer/shared/lib/utils/arrays.ts index 40212d614b..263755f982 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/chains.ts b/src/renderer/shared/lib/utils/chains.ts index 8aa411807f..8ef6d83591 100644 --- a/src/renderer/shared/lib/utils/chains.ts +++ b/src/renderer/shared/lib/utils/chains.ts @@ -1,6 +1,14 @@ import { WellKnownChain } from '@substrate/connect'; -import { type AccountId, type Address, type ChainId, type Explorer, type HexString } from '@/shared/core'; +import { + type AccountId, + type Address, + type Chain, + type ChainId, + ChainOptions, + type Explorer, + type HexString, +} from '@/shared/core'; import { toAddress } from './address'; import { RelayChains, SS58_DEFAULT_PREFIX } from './constants'; @@ -60,3 +68,14 @@ export function getKnownChain(chainId: ChainId): WellKnownChain | undefined { [RelayChains.ROCOCO]: WellKnownChain.rococo_v2_2, }[chainId]; } + +/** + * Check whether chain is evm or not + * + * @param chain Value to check + * + * @returns {Boolean} + */ +export function isEvmChain(chain: Chain): boolean { + return chain.options?.includes(ChainOptions.ETHEREUM_BASED) ?? false; +} 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 2ba74932e3..918959d366 100644 --- a/src/renderer/shared/lib/utils/strings.ts +++ b/src/renderer/shared/lib/utils/strings.ts @@ -93,7 +93,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/lib/utils/twMerge.ts b/src/renderer/shared/lib/utils/twMerge.ts index 6353ef3cc6..af62e9effe 100644 --- a/src/renderer/shared/lib/utils/twMerge.ts +++ b/src/renderer/shared/lib/utils/twMerge.ts @@ -14,7 +14,7 @@ const colors = Object.keys(additionalColors as Record); const twMerge = extendTailwindMerge({ extend: { classGroups: { - w: [{ w: ['90', 'modal', 'modal-sm', 'modal-xl'] }], + w: [{ w: ['90', '92', 'modal', 'modal-sm', 'modal-xl'] }], h: [{ h: ['modal'] }], 'font-size': [{ text: fonts }], 'font-weight': [{ text: fonts }], 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/Account/Account.tsx b/src/renderer/shared/ui-entities/Account/Account.tsx index a41dad0661..2e836eaf2e 100644 --- a/src/renderer/shared/ui-entities/Account/Account.tsx +++ b/src/renderer/shared/ui-entities/Account/Account.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { type AccountId, type Chain } from '@/shared/core'; import { toAddress } from '@/shared/lib/utils'; -import { AccountExplorers } from '../AccountExplorer/AccountExplorers'; +import { AccountExplorers } from '../AccountExplorers/AccountExplorers'; import { Address } from '../Address/Address'; type Props = { diff --git a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx b/src/renderer/shared/ui-entities/AccountExplorers/AccountExplorers.stories.tsx similarity index 60% rename from src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx rename to src/renderer/shared/ui-entities/AccountExplorers/AccountExplorers.stories.tsx index 8c480cbeab..2c4aa8c1ef 100644 --- a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx +++ b/src/renderer/shared/ui-entities/AccountExplorers/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/AccountExplorers/AccountExplorers.tsx similarity index 92% rename from src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx rename to src/renderer/shared/ui-entities/AccountExplorers/AccountExplorers.tsx index 8563fcb6ad..d9b946814e 100644 --- a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx +++ b/src/renderer/shared/ui-entities/AccountExplorers/AccountExplorers.tsx @@ -15,11 +15,12 @@ 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 ( - + e.stopPropagation()} /> diff --git a/src/renderer/shared/ui-entities/AccountSelectModal/AccountSelectModal.tsx b/src/renderer/shared/ui-entities/AccountSelectModal/AccountSelectModal.tsx index e74c883233..48489ecfbf 100644 --- a/src/renderer/shared/ui-entities/AccountSelectModal/AccountSelectModal.tsx +++ b/src/renderer/shared/ui-entities/AccountSelectModal/AccountSelectModal.tsx @@ -5,7 +5,7 @@ import { type Account, type Asset, type Chain } from '@/shared/core'; import { cnTw, formatBalance, toAddress } from '@/shared/lib/utils'; import { BodyText, Icon } from '@/shared/ui'; import { Box, Modal } from '@/shared/ui-kit'; -import { AccountExplorers } from '../AccountExplorer/AccountExplorers'; +import { AccountExplorers } from '../AccountExplorers/AccountExplorers'; import { Address } from '../Address/Address'; type AccountOption = { account: Account; balance?: BN; title?: string }; 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/RankedAccount/RankedAccount.tsx b/src/renderer/shared/ui-entities/RankedAccount/RankedAccount.tsx index 6572f77d14..948f831085 100644 --- a/src/renderer/shared/ui-entities/RankedAccount/RankedAccount.tsx +++ b/src/renderer/shared/ui-entities/RankedAccount/RankedAccount.tsx @@ -4,7 +4,7 @@ import { type AccountId, type Chain } from '@/shared/core'; import { cnTw, toAddress } from '@/shared/lib/utils'; import { Identicon } from '@/shared/ui'; import { Label } from '@/shared/ui-kit'; -import { AccountExplorers } from '../AccountExplorer/AccountExplorers'; +import { AccountExplorers } from '../AccountExplorers/AccountExplorers'; import { Address } from '../Address/Address'; type Props = { 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/TransactionDetails/TransactionDetails.tsx b/src/renderer/shared/ui-entities/TransactionDetails/TransactionDetails.tsx index 1625125af1..e83983e254 100644 --- a/src/renderer/shared/ui-entities/TransactionDetails/TransactionDetails.tsx +++ b/src/renderer/shared/ui-entities/TransactionDetails/TransactionDetails.tsx @@ -9,7 +9,7 @@ import { Box } from '@/shared/ui-kit'; import { AccountsModal } from '@/entities/staking'; import { WalletIcon, walletUtils } from '@/entities/wallet'; import { Account as AccountComponent } from '../Account/Account'; -import { AccountExplorers } from '../AccountExplorer/AccountExplorers'; +import { AccountExplorers } from '../AccountExplorers/AccountExplorers'; type Props = PropsWithChildren<{ wallets: Wallet[]; diff --git a/src/renderer/shared/ui-entities/index.ts b/src/renderer/shared/ui-entities/index.ts index d35ac5b09a..4d775ff231 100644 --- a/src/renderer/shared/ui-entities/index.ts +++ b/src/renderer/shared/ui-entities/index.ts @@ -4,6 +4,7 @@ export { Address } from './Address/Address'; export { Account } from './Account/Account'; export { AssetIcon } from './AssetIcon/AssetIcon'; export { AccountSelectModal } from './AccountSelectModal/AccountSelectModal'; -export { AccountExplorers } from './AccountExplorer/AccountExplorers'; +export { AccountExplorers } from './AccountExplorers/AccountExplorers'; +export { RootExplorers } from './RootExplorer/RootExplorers'; export { TransactionDetails } from './TransactionDetails/TransactionDetails'; export { RankedAccount } from './RankedAccount/RankedAccount'; diff --git a/src/renderer/shared/ui-kit/Combobox/Combobox.tsx b/src/renderer/shared/ui-kit/Combobox/Combobox.tsx index c6f0f85ca5..b9cd0c6af2 100644 --- a/src/renderer/shared/ui-kit/Combobox/Combobox.tsx +++ b/src/renderer/shared/ui-kit/Combobox/Combobox.tsx @@ -84,7 +84,6 @@ const Trigger = ({ placeholder, ...inputProps }: InputProps) => {
      } 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..086b3aef43 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/Progress/Progress.stories.tsx b/src/renderer/shared/ui-kit/Progress/Progress.stories.tsx new file mode 100644 index 0000000000..ddfc8c2e5e --- /dev/null +++ b/src/renderer/shared/ui-kit/Progress/Progress.stories.tsx @@ -0,0 +1,28 @@ +import { type Meta, type StoryObj } from '@storybook/react'; + +import { Box } from '../Box/Box'; + +import { Progress } from './Progress'; + +const meta: Meta = { + title: 'Design System/kit/Progress', + component: Progress, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 35, + max: 100, + }, +}; diff --git a/src/renderer/shared/ui-kit/Progress/Progress.tsx b/src/renderer/shared/ui-kit/Progress/Progress.tsx new file mode 100644 index 0000000000..69a6789d97 --- /dev/null +++ b/src/renderer/shared/ui-kit/Progress/Progress.tsx @@ -0,0 +1,26 @@ +import * as RadixProgress from '@radix-ui/react-progress'; +import { memo } from 'react'; + +const PRECISION = 10 ** 4; + +type Props = { + value: number; + max: number; +}; + +export const Progress = memo(({ value, max = 100 }: Props) => { + const progress = ((PRECISION / (max * PRECISION)) * (value * PRECISION)) / (PRECISION / 100); + + return ( + + + + ); +}); 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/Select/Select.tsx b/src/renderer/shared/ui-kit/Select/Select.tsx index 9d9327f933..b907e22d1f 100644 --- a/src/renderer/shared/ui-kit/Select/Select.tsx +++ b/src/renderer/shared/ui-kit/Select/Select.tsx @@ -89,7 +89,13 @@ const Button = ({ placeholder }: TriggerProps) => { )} >
      - {placeholder}} /> + + {placeholder} + + } + />
      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 (