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}
+}