diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 810cd97b2c..8ba958bc6b 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -16,6 +16,7 @@ import { TransactionStatusProvider } from '../src/common/providers/transactionSt import { MockProvidersDecorator, MockRouterDecorator } from '../src/mocks/providers' import { i18next } from '../src/services/i18n' import { KeyringContext } from '../src/common/providers/keyring/context' +import { ValidatorContextProvider } from '../src/validators/providers/provider' import { Keyring } from '@polkadot/ui-keyring' configure({ testIdAttribute: 'id' }) @@ -56,11 +57,13 @@ const ModalDecorator: Decorator = (Story) => ( - - - - - + + + + + + + diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index b09248ba0a..3aaa81a2d7 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -1,5 +1,6 @@ import { metadataToBytes } from '@joystream/js/utils' import { MembershipMetadata } from '@joystream/metadata-protobuf' +import { SubmittableExtrinsic } from '@polkadot/api/types' import { expect } from '@storybook/jest' import { Meta, StoryContext, StoryObj } from '@storybook/react' import { userEvent, waitFor, within } from '@storybook/testing-library' @@ -9,7 +10,12 @@ import { createGlobalStyle } from 'styled-components' import { Page, Screen } from '@/common/components/page/Page' import { Colors } from '@/common/constants' import { EMAIL_VERIFICATION_TOKEN_SEARCH_PARAM } from '@/memberships/constants' -import { GetMemberDocument } from '@/memberships/queries' +import { + GetMemberActionDetailsDocument, + GetMemberDocument, + GetMembersCountDocument, + GetMembersWithDetailsDocument, +} from '@/memberships/queries' import { ConfirmBackendEmailDocument, GetBackendMemberExistsDocument, @@ -40,6 +46,8 @@ type Args = { onTransfer: jest.Mock onSubscribeEmail: jest.Mock onConfirmEmail: jest.Mock + onAddStakingAccount: jest.Mock + onConfirmStakingAccount: jest.Mock } type Story = StoryObj> @@ -47,6 +55,7 @@ type Story = StoryObj> const alice = member('alice') const bob = member('bob') const charlie = member('charlie') +const dave = member('dave') const NEW_MEMBER_DATA = { id: alice.id, // we set this to alice's ID so that after member is created, member with same ID can be found in MembershipContext @@ -73,6 +82,8 @@ export default { onTransfer: { action: 'BalanceTransfer' }, onSubscribeEmail: { action: 'SubscribeEmail' }, onConfirmEmail: { action: 'ConfirmEmail' }, + onAddStakingAccount: { action: 'AddStakingAccount' }, + onConfirmStakingAccount: { action: 'ConfirmStakingAccount' }, }, args: { @@ -99,7 +110,10 @@ export default { return { accounts: { active: args.isLoggedIn ? 'alice' : undefined, - list: args.hasMemberships || args.hasAccounts ? [account(alice), account(bob), account(charlie)] : [], + list: + args.hasMemberships || args.hasAccounts + ? [account(alice), account(bob), account(charlie), account(dave)] + : [], hasWallet: args.hasWallet, }, @@ -110,6 +124,71 @@ export default { members: { membershipPrice: joy(20) }, council: { stage: { stage: { isIdle: true }, changedAt: 123 } }, referendum: { stage: {} }, + staking: { + bonded: { + multi: [ + 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', + 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', + 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', + 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', + 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', + 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', + 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', + 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', + 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', + 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', + 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', + ], + }, + validators: { + entries: [ + [ + { args: ['5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + [ + { args: ['5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy'] }, + { commission: 0.1 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz'] }, + { commission: 0.15 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa'] }, + { commission: 0.2 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN'] }, + { commission: 0.01 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP'] }, + { commission: 0.03 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + [ + { args: ['j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + ], + }, + }, }, tx: { @@ -124,7 +203,27 @@ export default { event: 'MembershipBought', data: [NEW_MEMBER_DATA.id], onSend: args.onBuyMembership, - failure: parameters.txFailure, + failure: parameters.buyMembershipTxFailure, + }, + addStakingAccountCandidate: { + event: 'StakingAccountAdded', + data: [NEW_MEMBER_DATA.id], + onSend: args.onAddStakingAccount, + failure: parameters.addStakingAccountTxFailure, + }, + confirmStakingAccount: { + event: 'StakingAccountConfirmed', + data: [NEW_MEMBER_DATA.id], + onSend: args.onConfirmStakingAccount, + failure: parameters.confirmStakingAccountTxFailure, + }, + }, + utility: { + batch: { + event: 'TxBatch', + onSend: (transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend('')), + failure: parameters.batchTxFailure, }, }, }, @@ -140,6 +239,35 @@ export default { query: GetBackendMemberExistsDocument, data: { memberExist: args.hasRegisteredEmail }, }, + { + query: GetMemberDocument, + data: { membershipByUniqueInput: member('alice') }, + }, + { + query: GetMembersCountDocument, + data: { membershipsConnection: { totalCount: 0 } }, + }, + { + query: GetMembersWithDetailsDocument, + data: { memberships: [member('alice'), member('bob'), member('charlie'), member('dave')] }, + }, + { + query: GetMemberActionDetailsDocument, + data: { + stakeSlashedEventsConnection: { + totalCount: 2, + }, + terminatedLeaderEventsConnection: { + totalCount: 3, + }, + terminatedWorkerEventsConnection: { + totalCount: 4, + }, + memberInvitedEventsConnection: { + totalCount: 0, + }, + }, + }, ], mutations: [ { @@ -479,7 +607,7 @@ export const BuyMembershipNotEnoughFund: Story = { export const BuyMembershipTxFailure: Story = { args: { hasMemberships: false, isLoggedIn: false }, - parameters: { txFailure: 'Some error message' }, + parameters: { buyMembershipTxFailure: 'Some error message' }, play: async ({ canvasElement }) => { const screen = within(canvasElement) @@ -494,11 +622,418 @@ export const BuyMembershipTxFailure: Story = { await userEvent.click(getButtonByText(modal, 'Sign and create a member')) - expect(await screen.findByText('Failure')) + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }, +} + +const fillMembershipFormValidatorAccounts = async (modal: Container, accounts: string[]) => { + await fillMembershipForm(modal) + const validatorCheckButton = modal.getAllByText('Yes')[1] + await userEvent.click(validatorCheckButton) + expect(await modal.findByText(/^If your validator account/)) + for (const account of accounts) { + await selectFromDropdown(modal, /^If your validator account/, account) + const addButton = document.getElementsByClassName('add-button')[0] + await userEvent.click(addButton) + } +} + +export const BuyMembershipHappyBindOneValidatorHappy: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await step('Form', async () => { + const createButton = getButtonByText(modal, 'Create a Membership') + + await step('Fill', async () => { + expect(createButton).toBeDisabled() + await fillMembershipFormValidatorAccounts(modal, ['charlie']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator membership.')) + expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20') + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + }) + + await step('Add validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText(NEW_MEMBER_DATA.handle)) + expect(args.onBuyMembership).toHaveBeenCalledWith({ + rootAccount: alice.controllerAccount, + controllerAccount: bob.controllerAccount, + handle: NEW_MEMBER_DATA.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: NEW_MEMBER_DATA.metadata.name, + about: NEW_MEMBER_DATA.metadata.about, + avatarUri: NEW_MEMBER_DATA.metadata.avatar.avatarUri, + }), + invitingMemberId: undefined, + referrerId: undefined, + }) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(1) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(1) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id, charlie.controllerAccount) + + const doneButton = getButtonByText(modal, 'Done') + expect(doneButton).toBeEnabled() + userEvent.click(doneButton) + }) + }, +} + +export const BuyMembershipHappyAddTwoValidatorHappy: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await step('Form', async () => { + const createButton = getButtonByText(modal, 'Create a Membership') + + await step('Fill', async () => { + expect(createButton).toBeDisabled() + await fillMembershipFormValidatorAccounts(modal, ['charlie', 'dave']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator membership.')) + expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20') + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + }) + + await step('Add first validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Add second validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText(NEW_MEMBER_DATA.handle)) + expect(args.onBuyMembership).toHaveBeenCalledWith({ + rootAccount: alice.controllerAccount, + controllerAccount: bob.controllerAccount, + handle: NEW_MEMBER_DATA.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: NEW_MEMBER_DATA.metadata.name, + about: NEW_MEMBER_DATA.metadata.about, + avatarUri: NEW_MEMBER_DATA.metadata.avatar.avatarUri, + }), + invitingMemberId: undefined, + referrerId: undefined, + }) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id, charlie.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id, dave.controllerAccount) + + const doneButton = getButtonByText(modal, 'Done') + expect(doneButton).toBeEnabled() + userEvent.click(doneButton) + }) + }, +} + +export const InvalidValidatorAccountInput: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { totalBalance: 20 }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(getButtonByText(screen, 'Join Now')) + await fillMembershipForm(modal) + const validatorCheckButton = modal.getAllByText('Yes')[1] + await userEvent.click(validatorCheckButton) + const validatorAddressInputElement = document.getElementById('select-validatorAccount-input') + expect(validatorAddressInputElement).not.toBeNull() + await userEvent.paste( + validatorAddressInputElement as HTMLElement, + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' + ) + + expect(modal.getByText('This account is neither a validator controller account nor a validator stash account.')) + const addButton = document.getElementsByClassName('add-button')[0] + expect(addButton).toBeDisabled() + }, +} + +export const BuyMembershipWithValidatorAccountNotEnoughFunds: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { totalBalance: 20 }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await fillMembershipFormValidatorAccounts(modal, ['charlie']) + const createButton = getButtonByText(modal, 'Create a Membership') + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + + expect(modal.getByText('Insufficient funds to cover the membership creation.')) + expect(getButtonByText(modal, 'Create membership')).toBeDisabled() + }, +} + +export const BuyMembershipWithValidatorAccountFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { buyMembershipTxFailure: 'Some error message' }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await fillMembershipFormValidatorAccounts(modal, ['charlie']) + const createButton = getButtonByText(modal, 'Create a Membership') + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + + expect(await modal.findByText('Failure')) expect(await modal.findByText('Some error message')) }, } +export const BuyMembershipHappyAddOneValidatorFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { addStakingAccountTxFailure: 'Some error message' }, + + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await step('Form', async () => { + const createButton = getButtonByText(modal, 'Create a Membership') + + await step('Fill', async () => { + expect(createButton).toBeDisabled() + await fillMembershipFormValidatorAccounts(modal, ['charlie']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator membership.')) + expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20') + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + }) + + await step('Add Validator Account Tx Failure', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }) + }, +} + +export const BuyMembershipAddValidatorAccHappyConfirmTxFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { confirmStakingAccountTxFailure: 'Some error message' }, + + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await step('Form', async () => { + const createButton = getButtonByText(modal, 'Create a Membership') + + await step('Fill', async () => { + expect(createButton).toBeDisabled() + await fillMembershipFormValidatorAccounts(modal, ['charlie']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator membership.')) + expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20') + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + }) + + await step('Add validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }) + }, +} + +export const BuyMembershipAddTwoValidatorAccHappyConfirmTxFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { batchTxFailure: 'Some error message' }, + + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await step('Form', async () => { + const createButton = getButtonByText(modal, 'Create a Membership') + + await step('Fill', async () => { + expect(createButton).toBeDisabled() + await fillMembershipFormValidatorAccounts(modal, ['charlie', 'dave']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator membership.')) + expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20') + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + }) + + await step('Add first validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Add second validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }) + }, +} // ---------------------------------------------------------------------------- // Test Email Subsciption Modal // ---------------------------------------------------------------------------- diff --git a/packages/ui/src/app/Providers.tsx b/packages/ui/src/app/Providers.tsx index ded0ae48bf..5b2dcdc770 100644 --- a/packages/ui/src/app/Providers.tsx +++ b/packages/ui/src/app/Providers.tsx @@ -13,6 +13,7 @@ import { OnBoardingProvider } from '@/common/providers/onboarding/provider' import { ResponsiveProvider } from '@/common/providers/responsive/provider' import { TransactionStatusProvider } from '@/common/providers/transactionStatus/provider' import { MembershipContextProvider } from '@/memberships/providers/membership/provider' +import { ValidatorContextProvider } from '@/validators/providers/provider' import { BackendProvider } from './providers/backend/provider' import { GlobalStyle } from './providers/GlobalStyle' @@ -31,22 +32,24 @@ export const Providers = ({ children }: Props) => ( - - - - - - - - - {children} - - - - - - - + + + + + + + + + + {children} + + + + + + + + diff --git a/packages/ui/src/common/components/Modal/Modals.tsx b/packages/ui/src/common/components/Modal/Modals.tsx index bd2cc2af01..8fb3bb58c1 100644 --- a/packages/ui/src/common/components/Modal/Modals.tsx +++ b/packages/ui/src/common/components/Modal/Modals.tsx @@ -17,6 +17,16 @@ export const Row = styled.div` height: auto; ` +export const RowInline = styled.div<{ gap?: number; top?: number }>` + display: flex; + flex-direction: row; + width: 100%; + height: auto; + align-items: center; + gap: ${({ gap }) => gap ?? 16}px; + margin-top: ${({ top }) => top ?? 0}px; +` + export const AccountRow = styled.div` display: grid; grid-template-columns: 1fr 1fr; diff --git a/packages/ui/src/common/components/forms/Label.tsx b/packages/ui/src/common/components/forms/Label.tsx index 4870e99308..fb7045d834 100644 --- a/packages/ui/src/common/components/forms/Label.tsx +++ b/packages/ui/src/common/components/forms/Label.tsx @@ -4,6 +4,7 @@ import { Colors } from '../../constants' import { TooltipContainer } from '../Tooltip' interface LabelProps { + noMargin?: boolean isRequired?: boolean className?: string } @@ -13,7 +14,7 @@ export const Label = styled.label` align-items: center; align-content: center; width: fit-content; - margin-bottom: 4px; + margin-bottom: ${({ noMargin }) => (noMargin ? '0px' : '4px')}; font-size: 14px; line-height: 20px; font-weight: 700; diff --git a/packages/ui/src/common/components/icons/index.ts b/packages/ui/src/common/components/icons/index.ts index 1c0c748405..9c1d757280 100644 --- a/packages/ui/src/common/components/icons/index.ts +++ b/packages/ui/src/common/components/icons/index.ts @@ -33,3 +33,4 @@ export * from './ApplicationIcon' export * from './CouncilMemberIcon' export * from './VerifiedMemberIcon' export * from './MenuIcon' +export * from './PlusIcon' diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx new file mode 100644 index 0000000000..dcec3a0b41 --- /dev/null +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx @@ -0,0 +1,34 @@ +import { SubmittableExtrinsic } from '@polkadot/api/types' +import { ISubmittableResult } from '@polkadot/types/types' +import React from 'react' +import { ActorRef } from 'xstate' + +import { Account } from '@/accounts/types' +import { TextMedium } from '@/common/components/typography' +import { SignTransactionModal } from '@/common/modals/SignTransactionModal/SignTransactionModal' + +interface SignProps { + transaction: SubmittableExtrinsic<'rxjs', ISubmittableResult> | undefined + signer: Account + service: ActorRef +} + +export const AddStakingAccCandidateModal = ({ transaction, signer, service }: SignProps) => ( + + You are intending to bond your validator account with your membership. + +) diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx index 171938e776..8a1f7f2dd1 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -1,12 +1,14 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { BalanceOf } from '@polkadot/types/interfaces/runtime' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' +import styled from 'styled-components' import * as Yup from 'yup' -import { SelectAccount } from '@/accounts/components/SelectAccount' +import { SelectAccount, SelectedAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' +import { encodeAddress } from '@/accounts/model/encodeAddress' import { Account } from '@/accounts/types' import { TermsRoutes } from '@/app/constants/routes' import { ButtonGhost, ButtonPrimary } from '@/common/components/buttons' @@ -20,13 +22,15 @@ import { LabelLink, ToggleCheckbox, } from '@/common/components/forms' -import { Arrow } from '@/common/components/icons' +import { Arrow, CrossIcon, PlusIcon } from '@/common/components/icons' +import { AlertSymbol } from '@/common/components/icons/symbols' import { Loading } from '@/common/components/Loading' import { ModalFooter, ModalFooterGroup, ModalHeader, Row, + RowInline, ScrolledModal, ScrolledModalBody, ScrolledModalContainer, @@ -34,13 +38,14 @@ import { } from '@/common/components/Modal' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' import { TransactionInfo } from '@/common/components/TransactionInfo' -import { TextMedium } from '@/common/components/typography' +import { TextMedium, TextSmall } from '@/common/components/typography' import { definedValues } from '@/common/utils' import { useYupValidationResolver } from '@/common/utils/validation' import { AvatarInput } from '@/memberships/components/AvatarInput' import { SocialMediaSelector } from '@/memberships/components/SocialMediaSelector/SocialMediaSelector' import { useUploadAvatarAndSubmit } from '@/memberships/hooks/useUploadAvatarAndSubmit' import { useGetMembersCountQuery } from '@/memberships/queries' +import { useValidators } from '@/validators/hooks/useValidators' import { SelectMember } from '../../components/SelectMember' import { @@ -76,6 +81,7 @@ const CreateMemberSchema = Yup.object().shape({ ), hasTerms: Yup.boolean().required().oneOf([true]), isReferred: Yup.boolean(), + isValidator: Yup.boolean(), referrer: ReferrerSchema, externalResources: ExternalResourcesSchema, }) @@ -88,6 +94,9 @@ export interface MemberFormFields { about: string avatarUri: File | string | null isReferred?: boolean + isValidator?: boolean + validatorAccountCandidate?: Account + validatorAccounts?: Account[] referrer?: Member hasTerms?: boolean invitor?: Member @@ -101,6 +110,7 @@ const formDefaultValues = { about: '', avatarUri: null, isReferred: false, + isValidator: false, referrer: undefined, hasTerms: false, externalResources: {}, @@ -135,7 +145,30 @@ export const BuyMembershipForm = ({ }, }) - const [handle, isReferred, referrer, captchaToken] = form.watch(['handle', 'isReferred', 'referrer', 'captchaToken']) + const [handle, isReferred, isValidator, referrer, captchaToken, validatorAccountCandidate] = form.watch([ + 'handle', + 'isReferred', + 'isValidator', + 'referrer', + 'captchaToken', + 'validatorAccountCandidate', + ]) + + const { allValidators, allValidatorsWithCtrlAcc } = useValidators({ skip: isValidator ?? true }) + const [validatorAccounts, setValidatorAccounts] = useState([]) + const validatorAddresses = useMemo(() => { + if (!allValidatorsWithCtrlAcc || !allValidators) return + return ( + [...allValidatorsWithCtrlAcc, ...allValidators.map(({ address }) => address)].filter( + (element) => !!element + ) as string[] + ).map(encodeAddress) + }, [allValidators, allValidatorsWithCtrlAcc]) + + const isValidValidatorAccount = useMemo( + () => validatorAccountCandidate && validatorAddresses?.includes(encodeAddress(validatorAccountCandidate.address)), + [allValidators, allValidatorsWithCtrlAcc, validatorAddresses, validatorAccountCandidate] + ) useEffect(() => { if (handle) { @@ -149,10 +182,21 @@ export const BuyMembershipForm = ({ } }, [data?.membershipsConnection.totalCount]) - const isFormValid = !isUploading && form.formState.isValid + const isFormValid = !isUploading && form.formState.isValid && (!isValidator || validatorAccounts?.length) const isDisabled = type === 'onBoarding' && process.env.REACT_APP_CAPTCHA_SITE_KEY ? !captchaToken || !isFormValid : !isFormValid + const addValidatorAccount = () => { + if (validatorAccountCandidate && isValidValidatorAccount) { + setValidatorAccounts([...new Set([...validatorAccounts, validatorAccountCandidate])]) + form?.setValue('validatorAccountCandidate' as keyof MemberFormFields, undefined) + } + } + + const removeValidatorAccount = (index: number) => { + setValidatorAccounts([...validatorAccounts.slice(0, index), ...validatorAccounts.slice(index + 1)]) + } + return ( <> @@ -234,6 +278,79 @@ export const BuyMembershipForm = ({ + {type === 'general' && ( + <> + + + + + {isValidator && ( + <> + + + + + + + * + + + If your validator account is not in your signer wallet, paste the account address to the field + below: + + + + !!validatorAddresses?.includes(encodeAddress(account.address))} + /> + + + + + + {validatorAccountCandidate && !isValidValidatorAccount && ( + + + + + + + + This account is neither a validator controller account nor a validator stash account. + + + )} + + + {validatorAccounts.map((account, index) => ( + + + + { + removeValidatorAccount(index) + }} + > + + + + + ))} + + )} + + )} + {process.env.REACT_APP_CAPTCHA_SITE_KEY && type === 'onBoarding' && ( { + validatorAccounts?.map((account, index) => { + form?.register(('validatorAccounts[' + index + ']') as keyof MemberFormFields) + form?.setValue(('validatorAccounts[' + index + ']') as keyof MemberFormFields, account) + }) const values = form.getValues() uploadAvatarAndSubmit({ ...values, externalResources: { ...definedValues(values.externalResources) } }) }} @@ -308,3 +429,25 @@ export const BuyMembershipFormModal = ({ onClose, onSubmit, membershipPrice }: B ) } + +const SelectValidatorAccountWrapper = styled.div` + margin-top: -4px; + display: flex; + flex-direction: column; + gap: 8px; +` + +const InputNotificationIcon = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 12px; + height: 12px; + color: inherit; + padding-right: 2px; + + .blackPart, + .primaryPart { + fill: currentColor; + } +` diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx index c73b25f672..8ba2588ec6 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx @@ -1,5 +1,5 @@ import { useApolloClient } from '@apollo/client' -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { useApi } from '@/api/hooks/useApi' import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' @@ -7,9 +7,11 @@ import { useMachine } from '@/common/hooks/useMachine' import { useModal } from '@/common/hooks/useModal' import { toMemberTransactionParams } from '@/memberships/modals/utils' +import { AddStakingAccCandidateModal } from './AddStakingAccCandidateModal' import { BuyMembershipFormModal, MemberFormFields } from './BuyMembershipFormModal' import { BuyMembershipSignModal } from './BuyMembershipSignModal' import { BuyMembershipSuccessModal } from './BuyMembershipSuccessModal' +import { ConfirmStakingAccModal } from './ConfirmStakingAccModal' import { buyMembershipMachine } from './machine' export const BuyMembershipModal = () => { @@ -27,29 +29,71 @@ export const BuyMembershipModal = () => { apolloClient.refetchQueries({ include: 'active' }) }, [isSuccessful, apolloClient]) + const memberId = state.context.memberId?.toString() + + const buyTransaction = useMemo( + () => state.context.form && api?.tx.members.buyMembership(toMemberTransactionParams(state.context.form)), + [api?.isConnected, state.context.form] + ) + const bindTransaction = useMemo( + () => memberId && api?.tx.members.addStakingAccountCandidate(memberId), + [api?.isConnected, memberId] + ) + const conFirmTransaction = useMemo(() => { + const validatorAccounts = state.context.form?.validatorAccounts + + if (!api || !memberId || !validatorAccounts) return + + const confirmTxs = validatorAccounts.map(({ address }) => api.tx.members.confirmStakingAccount(memberId, address)) + + return confirmTxs.length > 1 ? api.tx.utility.batch(confirmTxs) : confirmTxs[0] + }, [api?.isConnected, memberId, state.context.form?.validatorAccounts]) + if (state.matches('prepare')) { const onSubmit = (params: MemberFormFields) => send({ type: 'DONE', form: params }) return } - if (state.matches('transaction') && api) { - const transaction = api.tx.members.buyMembership(toMemberTransactionParams(state.context.form)) + if (state.matches('buyMembershipTx') && buyTransaction) { const { form } = state.context - const service = state.children.transaction + const service = state.children.buyMembership return ( ) } + if (state.matches('addStakingAccCandidateTx') && bindTransaction && state.context.form.validatorAccounts) { + const service = state.children.addStakingAccCandidate + + return ( + + ) + } + + if (state.matches('confirmStakingAccTx') && conFirmTransaction && state.context.form.controllerAccount) { + const service = state.children.confirmStakingAcc + return ( + + ) + } + if (isSuccessful) { const { form, memberId } = state.context return diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx index 904d48c5b0..86577ba0ef 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx @@ -70,9 +70,28 @@ export const BuyMembershipSignModal = ({ const signDisabled = !isReady || !hasFunds || !validationInfo return ( - + - You intend to create a new membership. + + {formData.isValidator + ? 'You intend to create a validator membership.' + : 'You intend to create a new membership.'} + The creation of the new membership costs . @@ -108,7 +127,11 @@ export const BuyMembershipSignModal = ({ | undefined + signer: Account + service: ActorRef +} + +export const ConfirmStakingAccModal = ({ transaction, signer, service }: SignProps) => ( + + You are intending to confirm your validator account to be bound with your membership + +) diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts b/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts index 54c565af37..44eae36580 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts @@ -19,11 +19,14 @@ interface BuyMembershipContext { form?: MemberFormFields memberId?: BN transactionEvents?: EventRecord[] + bindingValidtorAccStep?: number } type BuyMembershipState = | { value: 'prepare'; context: EmptyObject } - | { value: 'transaction'; context: { form: MemberFormFields } } + | { value: 'buyMembershipTx'; context: { form: MemberFormFields } } + | { value: 'addStakingAccCandidateTx'; context: { form: MemberFormFields } } + | { value: 'confirmStakingAccTx'; context: { form: MemberFormFields } } | { value: 'success'; context: Required } | { value: 'canceled'; context: Required } | { value: 'error'; context: { form: MemberFormFields; transactionEvents: EventRecord[] } } @@ -35,28 +38,92 @@ export type BuyMembershipEvent = | { type: 'SUCCESS'; memberId: BN } | { type: 'ERROR' } +const isSelfTransition = (context: BuyMembershipContext) => + !!context.form?.validatorAccounts && + context.form?.validatorAccounts.length > 1 && + (!context.bindingValidtorAccStep || context.form.validatorAccounts.length - 1 > context.bindingValidtorAccStep) + export const buyMembershipMachine = createMachine({ initial: 'prepare', states: { prepare: { on: { DONE: { - target: 'transaction', + target: 'buyMembershipTx', actions: assign({ form: (_, event) => event.form }), }, }, }, - transaction: { + buyMembershipTx: { invoke: { - id: 'transaction', + id: 'buyMembership', src: transactionMachine, onDone: [ + { + target: 'addStakingAccCandidateTx', + actions: assign({ + memberId: (context, event) => getDataFromEvent(event.data.events, 'members', 'MembershipBought', 0), + }), + cond: (context, event) => isTransactionSuccess(context, event) && !!context.form?.isValidator, + }, { target: 'success', actions: assign({ memberId: (context, event) => getDataFromEvent(event.data.events, 'members', 'MembershipBought', 0), }), + cond: (context, event) => isTransactionSuccess(context, event) && !context.form?.isValidator, + }, + { + target: 'error', + cond: isTransactionError, + actions: assign({ transactionEvents: (context, event) => event.data.events }), + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + addStakingAccCandidateTx: { + invoke: { + id: 'addStakingAccCandidate', + src: transactionMachine, + onDone: [ + { + target: 'addStakingAccCandidateTx', + cond: isSelfTransition, + actions: assign({ + transactionEvents: (context, event) => event.data.events, + bindingValidtorAccStep: (context) => (context.bindingValidtorAccStep ?? 0) + 1, + }), + }, + { + target: 'confirmStakingAccTx', + cond: isTransactionSuccess, + actions: assign({ transactionEvents: (context, event) => event.data.events }), + }, + { + target: 'error', + cond: isTransactionError, + actions: assign({ transactionEvents: (context, event) => event.data.events }), + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + confirmStakingAccTx: { + invoke: { + id: 'confirmStakingAcc', + src: transactionMachine, + onDone: [ + { + target: 'success', cond: isTransactionSuccess, + actions: assign({ transactionEvents: (context, event) => event.data.events }), }, { target: 'error', diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts b/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts index de145adda7..d61ace6c53 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts @@ -1,4 +1,5 @@ import { Account } from '@/accounts/types' +import { Address } from '@/common/types' export interface UpdateMemberForm { id: string @@ -9,4 +10,7 @@ export interface UpdateMemberForm { rootAccount?: Account controllerAccount?: Account externalResources: Record + isValidator?: boolean + validatorAccountCandidate?: Account + validatorAccounts?: Address[] } diff --git a/packages/ui/src/validators/hooks/useValidatorMembers.tsx b/packages/ui/src/validators/hooks/useValidatorMembers.tsx deleted file mode 100644 index 2f6730552d..0000000000 --- a/packages/ui/src/validators/hooks/useValidatorMembers.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useMemo } from 'react' -import { map } from 'rxjs' - -import { useApi } from '@/api/hooks/useApi' -import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' -import { perbillToPercent } from '@/common/utils' -import { useGetMembersWithDetailsQuery } from '@/memberships/queries' -import { asMemberWithDetails } from '@/memberships/types' - -import { ValidatorMembership } from '../types' - -export const useValidatorMembers = () => { - const { api } = useApi() - const allValidators = useFirstObservableValue( - () => - api?.query.staking.validators.entries().pipe( - map((entries) => - entries.map((entry) => ({ - address: entry[0].args[0].toString(), - commission: perbillToPercent(entry[1].commission.toBn()), - })) - ) - ), - [api?.isConnected] - ) - - const allValidatorsWithCtrlAcc = useFirstObservableValue( - () => - allValidators && - api && - api.query.staking.bonded - .multi(allValidators.map(({ address }) => address)) - .pipe(map((entries) => entries.map((entry) => (entry.isSome ? entry.unwrap().toString() : undefined)))), - [allValidators, api?.isConnected] - ) - - const variables = { - where: { - boundAccounts_containsAny: - (allValidatorsWithCtrlAcc - ?.concat(allValidators?.map(({ address }) => address)) - .filter((element) => !element) as string[]) ?? [], - }, - } - - const { data } = useGetMembersWithDetailsQuery({ variables, skip: !!allValidatorsWithCtrlAcc }) - - const memberships = data?.memberships?.map((rawMembership) => ({ - membership: asMemberWithDetails(rawMembership), - isVerifiedValidator: rawMembership.metadata.isVerifiedValidator ?? false, - })) - - const validatorsWithMembership: ValidatorMembership[] | undefined = useMemo(() => { - return ( - allValidators && - allValidatorsWithCtrlAcc && - memberships && - allValidators.map(({ address, commission }, index) => { - const controllerAccount = allValidatorsWithCtrlAcc[index] - return { - stashAccount: address, - controllerAccount, - commission, - ...memberships.find( - ({ membership }) => - membership.boundAccounts.includes(address) || - (controllerAccount && membership.boundAccounts.includes(controllerAccount)) - ), - } - }) - ) - }, [data, allValidators, allValidatorsWithCtrlAcc]) - - return validatorsWithMembership -} diff --git a/packages/ui/src/validators/hooks/useValidators.ts b/packages/ui/src/validators/hooks/useValidators.ts new file mode 100644 index 0000000000..cbbdcdcad7 --- /dev/null +++ b/packages/ui/src/validators/hooks/useValidators.ts @@ -0,0 +1,16 @@ +import { useContext, useEffect } from 'react' + +import { ValidatorsContext } from '../providers/context' + +type Props = { skip?: boolean } + +export const useValidators = ({ skip = false }: Props = {}) => { + const { setShouldFetchValidators, allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership } = + useContext(ValidatorsContext) + + useEffect(() => { + if (!skip) setShouldFetchValidators(true) + }, []) + + return { allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership } +} diff --git a/packages/ui/src/validators/hooks/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index 06f7a3e6aa..24ffaa8598 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -12,7 +12,7 @@ import { last } from '@/common/utils' import { Verification, State, ValidatorWithDetails, ValidatorMembership } from '../types' -import { useValidatorMembers } from './useValidatorMembers' +import { useValidators } from './useValidators' export const useValidatorsList = () => { const { api } = useApi() @@ -20,7 +20,7 @@ export const useValidatorsList = () => { const [isVerified, setIsVerified] = useState(null) const [isActive, setIsActive] = useState(null) const [visibleValidators, setVisibleValidators] = useState([]) - const validators = useValidatorMembers() + const { validatorsWithMembership: validators } = useValidators() const validatorRewardPointsHistory = useFirstObservableValue( () => api?.query.staking.erasRewardPoints.entries(), diff --git a/packages/ui/src/validators/providers/context.tsx b/packages/ui/src/validators/providers/context.tsx new file mode 100644 index 0000000000..37d0aaf735 --- /dev/null +++ b/packages/ui/src/validators/providers/context.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react' + +import { UseValidators } from './provider' + +export const ValidatorsContext = createContext({ setShouldFetchValidators: () => {} }) diff --git a/packages/ui/src/validators/providers/provider.tsx b/packages/ui/src/validators/providers/provider.tsx new file mode 100644 index 0000000000..6993589c72 --- /dev/null +++ b/packages/ui/src/validators/providers/provider.tsx @@ -0,0 +1,121 @@ +import React, { ReactNode, useMemo, useState } from 'react' +import { map } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' +import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' +import { Address } from '@/common/types' +import { perbillToPercent } from '@/common/utils' +import { useGetMembersWithDetailsQuery } from '@/memberships/queries' +import { asMemberWithDetails } from '@/memberships/types' + +import { ValidatorMembership } from '../types' + +import { ValidatorsContext } from './context' + +interface Props { + children: ReactNode +} + +export interface UseValidators { + setShouldFetchValidators: (fetchValidators: boolean) => void + allValidators?: { + address: Address + commission: number + }[] + allValidatorsWithCtrlAcc?: (string | undefined)[] + validatorsWithMembership?: ValidatorMembership[] +} + +export const ValidatorContextProvider = (props: Props) => { + const { api } = useApi() + + const [shouldFetchValidators, setShouldFetchValidators] = useState(false) + + const allValidators = useFirstObservableValue(() => { + if (!shouldFetchValidators) return undefined + return api?.query.staking.validators.entries().pipe( + map((entries) => + entries.map((entry) => ({ + address: entry[0].args[0].toString(), + commission: perbillToPercent(entry[1].commission.toBn()), + })) + ) + ) + }, [api?.isConnected, shouldFetchValidators]) + + const allValidatorsWithCtrlAcc = useFirstObservableValue( + () => + allValidators && + api && + api.query.staking.bonded + .multi(allValidators.map(({ address }) => address)) + .pipe(map((entries) => entries.map((entry) => (entry.isSome ? entry.unwrap().toString() : undefined)))), + [allValidators, api?.isConnected] + ) + + const variables = { + where: { + OR: [ + { + rootAccount_in: + (allValidatorsWithCtrlAcc + ?.concat(allValidators?.map(({ address }) => address)) + .filter((element) => !!element) as string[]) ?? [], + }, + { + controllerAccount_in: + (allValidatorsWithCtrlAcc + ?.concat(allValidators?.map(({ address }) => address)) + .filter((element) => !!element) as string[]) ?? [], + }, + { + boundAccounts_containsAny: + (allValidatorsWithCtrlAcc + ?.concat(allValidators?.map(({ address }) => address)) + .filter((element) => !!element) as string[]) ?? [], + }, + ], + }, + } + + const { data } = useGetMembersWithDetailsQuery({ variables, skip: !allValidatorsWithCtrlAcc }) + + const memberships = data?.memberships?.map((rawMembership) => ({ + membership: asMemberWithDetails(rawMembership), + isVerifiedValidator: rawMembership.metadata.isVerifiedValidator ?? false, + })) + + const validatorsWithMembership: ValidatorMembership[] | undefined = useMemo(() => { + return ( + allValidators && + allValidatorsWithCtrlAcc && + memberships && + allValidators.map(({ address, commission }, index) => { + const controllerAccount = allValidatorsWithCtrlAcc[index] + return { + stashAccount: address, + controllerAccount, + commission, + ...memberships.find( + ({ membership }) => + membership.rootAccount === address || + membership.rootAccount === controllerAccount || + membership.controllerAccount === address || + membership.controllerAccount === controllerAccount || + membership.boundAccounts.includes(address) || + (controllerAccount && membership.boundAccounts.includes(controllerAccount)) + ), + } + }) + ) + }, [data, allValidators, allValidatorsWithCtrlAcc]) + + const value = { + setShouldFetchValidators, + allValidators, + allValidatorsWithCtrlAcc, + validatorsWithMembership, + } + + return {props.children} +}