feat(governance): pending balances: add event listeners (#2541)
Co-authored-by: Dexter <dexter.edwards93@gmail.com>
This commit is contained in:
parent
fb1ebb3bdf
commit
d2bbd7e1fc
@ -1,7 +1,7 @@
|
||||
# App configuration variables
|
||||
NX_VEGA_ENV=TESTNET
|
||||
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/testnet-network.json
|
||||
NX_VEGA_URL=https://api.n10.testnet.vega.xyz/graphql
|
||||
NX_VEGA_URL=https://api.n08.testnet.vega.xyz/graphql
|
||||
NX_VEGA_NETWORKS='{"DEVNET":"https://dev.token.vega.xyz","STAGNET3":"https://stagnet3.token.vega.xyz","TESTNET":"https://token.fairground.wtf","MAINNET":"https://token.vega.xyz"}'
|
||||
NX_ETHEREUM_PROVIDER_URL=https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
|
||||
NX_ETHERSCAN_URL=https://sepolia.etherscan.io
|
||||
|
@ -12,5 +12,10 @@ export default {
|
||||
moduleNameMapper: {
|
||||
'^d3-(.*)$': `d3-$1/dist/d3-$1`,
|
||||
},
|
||||
collectCoverageFrom: ['**/*.{ts,tsx}', '!**/node_modules/**'],
|
||||
collectCoverageFrom: [
|
||||
'**/*.{ts,tsx}',
|
||||
'!**/node_modules/**',
|
||||
'!**/__generated__/**',
|
||||
'!**/__generated___/**',
|
||||
],
|
||||
};
|
||||
|
@ -2,20 +2,24 @@ import * as Sentry from '@sentry/react';
|
||||
import { toBigNum } from '@vegaprotocol/react-helpers';
|
||||
import { useEthereumConfig } from '@vegaprotocol/web3';
|
||||
import { useWeb3React } from '@web3-react/core';
|
||||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAppState } from '../../contexts/app-state/app-state-context';
|
||||
import { useContracts } from '../../contexts/contracts/contracts-context';
|
||||
import { useGetAssociationBreakdown } from '../../hooks/use-get-association-breakdown';
|
||||
import { useGetUserTrancheBalances } from '../../hooks/use-get-user-tranche-balances';
|
||||
import { useBalances } from '../../lib/balances/balances-store';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
import { useListenForStakingEvents as useListenForAssociationEvents } from '../../hooks/use-listen-for-staking-events';
|
||||
|
||||
interface BalanceManagerProps {
|
||||
children: React.ReactElement;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export const BalanceManager = ({ children }: BalanceManagerProps) => {
|
||||
const contracts = useContracts();
|
||||
const { pubKey } = useVegaWallet();
|
||||
const { account } = useWeb3React();
|
||||
const {
|
||||
appState: { decimals },
|
||||
@ -23,6 +27,20 @@ export const BalanceManager = ({ children }: BalanceManagerProps) => {
|
||||
const { updateBalances: updateStoreBalances } = useBalances();
|
||||
const { config } = useEthereumConfig();
|
||||
|
||||
const numberOfConfirmations = config?.confirmations || 0;
|
||||
|
||||
useListenForAssociationEvents(
|
||||
contracts?.staking.contract,
|
||||
pubKey,
|
||||
numberOfConfirmations
|
||||
);
|
||||
|
||||
useListenForAssociationEvents(
|
||||
contracts?.vesting.contract,
|
||||
pubKey,
|
||||
numberOfConfirmations
|
||||
);
|
||||
|
||||
const getUserTrancheBalances = useGetUserTrancheBalances(
|
||||
account || '',
|
||||
contracts?.vesting
|
||||
@ -34,7 +52,7 @@ export const BalanceManager = ({ children }: BalanceManagerProps) => {
|
||||
);
|
||||
|
||||
// update balances on connect to Ethereum
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const updateBalances = async () => {
|
||||
if (!account || !config) return;
|
||||
try {
|
||||
@ -75,13 +93,13 @@ export const BalanceManager = ({ children }: BalanceManagerProps) => {
|
||||
]);
|
||||
|
||||
// This use effect hook is very expensive and is kept separate to prevent expensive reloading of data.
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
getUserTrancheBalances();
|
||||
}
|
||||
}, [account, getUserTrancheBalances]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
getAssociationBreakdown();
|
||||
}
|
||||
|
@ -25,6 +25,8 @@ import { usePollForDelegations } from './hooks';
|
||||
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
|
||||
import { Button, ButtonLink } from '@vegaprotocol/ui-toolkit';
|
||||
import { toBigNum } from '@vegaprotocol/react-helpers';
|
||||
import { usePendingBalancesStore } from '../../hooks/use-pending-balances-manager';
|
||||
import { StakingEventType } from '../../hooks/use-get-association-breakdown';
|
||||
|
||||
export const VegaWallet = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -119,6 +121,10 @@ interface VegaWalletConnectedProps {
|
||||
}
|
||||
|
||||
const VegaWalletConnected = ({ vegaKeys }: VegaWalletConnectedProps) => {
|
||||
const pendingBalances = usePendingBalancesStore(
|
||||
(state) => state.pendingBalances
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
appDispatch,
|
||||
@ -126,6 +132,27 @@ const VegaWalletConnected = ({ vegaKeys }: VegaWalletConnectedProps) => {
|
||||
} = useAppState();
|
||||
const { delegations, currentStakeAvailable, delegatedNodes, accounts } =
|
||||
usePollForDelegations();
|
||||
const amountRemoved = BigNumber.sum.apply(null, [
|
||||
new BigNumber(0),
|
||||
...pendingBalances
|
||||
.filter(({ event }) => event === StakingEventType.Stake_Removed)
|
||||
.map(({ args }) => toBigNum(args?.[1].toString(), decimals)),
|
||||
]);
|
||||
|
||||
const amountAdded = BigNumber.sum.apply(null, [
|
||||
new BigNumber(0),
|
||||
...pendingBalances
|
||||
.filter(({ event }) => event === StakingEventType.Stake_Deposited)
|
||||
.map(({ args }) => toBigNum(args?.[1].toString(), decimals)),
|
||||
]);
|
||||
const totalPending = React.useMemo(
|
||||
() => amountRemoved.plus(amountAdded),
|
||||
[amountAdded, amountRemoved]
|
||||
);
|
||||
const pendingStakeAmount = React.useMemo(
|
||||
() => currentStakeAvailable.plus(amountAdded).minus(amountRemoved),
|
||||
[amountAdded, amountRemoved, currentStakeAvailable]
|
||||
);
|
||||
|
||||
const unstaked = React.useMemo(() => {
|
||||
const totalDelegated = delegations.reduce<BigNumber>(
|
||||
@ -161,6 +188,26 @@ const VegaWalletConnected = ({ vegaKeys }: VegaWalletConnectedProps) => {
|
||||
symbol="VEGA"
|
||||
balance={currentStakeAvailable}
|
||||
/>
|
||||
{totalPending.eq(0) ? null : (
|
||||
<>
|
||||
<WalletCardAsset
|
||||
image={vegaWhite}
|
||||
decimals={decimals}
|
||||
name="VEGA"
|
||||
subheading={t('Pending association')}
|
||||
symbol="VEGA"
|
||||
balance={totalPending}
|
||||
/>
|
||||
<WalletCardAsset
|
||||
image={vegaWhite}
|
||||
decimals={decimals}
|
||||
name="VEGA"
|
||||
subheading={t('Total associated after pending')}
|
||||
symbol="VEGA"
|
||||
balance={pendingStakeAmount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div data-testid="vega-wallet-balance-unstaked">
|
||||
<WalletCardRow label={t('unstaked')} value={unstaked} />
|
||||
</div>
|
||||
|
@ -11,6 +11,11 @@ import { useAppState } from '../contexts/app-state/app-state-context';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { useBalances } from '../lib/balances/balances-store';
|
||||
|
||||
export enum StakingEventType {
|
||||
Stake_Removed = 'Stake_Removed',
|
||||
Stake_Deposited = 'Stake_Deposited',
|
||||
}
|
||||
|
||||
export function useGetAssociationBreakdown(
|
||||
ethAddress: string,
|
||||
staking: StakingBridge,
|
||||
@ -63,8 +68,8 @@ function combineStakeEventsByVegaKey(
|
||||
const res = events.reduce((obj, e) => {
|
||||
const vegaKey = e.args?.vega_public_key;
|
||||
const amount = parseEventAmount(e, decimals);
|
||||
const isDeposit = e.event === 'Stake_Deposited';
|
||||
const isRemove = e.event === 'Stake_Removed';
|
||||
const isDeposit = e.event === StakingEventType.Stake_Deposited;
|
||||
const isRemove = e.event === StakingEventType.Stake_Removed;
|
||||
|
||||
if (!isDeposit && !isRemove) return obj;
|
||||
|
||||
|
102
apps/token/src/hooks/use-listen-for-pending-eth-events.spec.ts
Normal file
102
apps/token/src/hooks/use-listen-for-pending-eth-events.spec.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { useListenForPendingEthEvents } from './use-listen-for-pending-eth-events';
|
||||
import { renderHook, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Contract, EventFilter, Event } from 'ethers';
|
||||
|
||||
let contract: Contract;
|
||||
let filter: EventFilter;
|
||||
let addPendingTxs: (event: Event[]) => void;
|
||||
let removePendingTx: (event: Event) => void;
|
||||
let resetPendingTxs: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
contract = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
queryFilter: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as Contract;
|
||||
filter = {} as EventFilter;
|
||||
addPendingTxs = jest.fn();
|
||||
removePendingTx = jest.fn();
|
||||
resetPendingTxs = jest.fn();
|
||||
});
|
||||
|
||||
jest.mock('@web3-react/core', () => ({
|
||||
useWeb3React: () => ({
|
||||
provider: {
|
||||
getBlockNumber: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useListenForPendingEthEvents', () => {
|
||||
it('listens for events on contract', async () => {
|
||||
renderHook(() =>
|
||||
useListenForPendingEthEvents(
|
||||
1,
|
||||
contract,
|
||||
filter,
|
||||
addPendingTxs,
|
||||
removePendingTx,
|
||||
resetPendingTxs
|
||||
)
|
||||
);
|
||||
|
||||
expect(contract.on).toHaveBeenCalledWith(filter, expect.any(Function));
|
||||
cleanup();
|
||||
expect(contract.off).toHaveBeenCalledWith(filter, expect.any(Function));
|
||||
});
|
||||
|
||||
it('waits for correct number of confirmations before removing tx from pending txs', async () => {
|
||||
renderHook(() => {
|
||||
useListenForPendingEthEvents(
|
||||
2,
|
||||
contract,
|
||||
filter,
|
||||
addPendingTxs,
|
||||
removePendingTx,
|
||||
resetPendingTxs
|
||||
);
|
||||
});
|
||||
|
||||
const listener = (contract.on as jest.Mock).mock.calls[0][1];
|
||||
const event = {
|
||||
getTransaction: jest.fn().mockResolvedValue({
|
||||
wait: jest.fn().mockResolvedValue({}),
|
||||
}),
|
||||
} as unknown as Event;
|
||||
listener(null, null, null, event);
|
||||
|
||||
expect(addPendingTxs).toHaveBeenCalledWith([event]);
|
||||
expect(removePendingTx).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(removePendingTx).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
|
||||
it('gets existing transactions', async () => {
|
||||
const event = {
|
||||
getTransaction: jest.fn().mockResolvedValue({
|
||||
wait: jest.fn().mockResolvedValue(null),
|
||||
}),
|
||||
} as unknown as Event;
|
||||
|
||||
contract.queryFilter = jest.fn().mockResolvedValue([event]);
|
||||
|
||||
renderHook(() => {
|
||||
useListenForPendingEthEvents(
|
||||
2,
|
||||
contract,
|
||||
filter,
|
||||
addPendingTxs,
|
||||
removePendingTx,
|
||||
resetPendingTxs
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(contract.queryFilter).toHaveBeenCalledWith(filter, -1);
|
||||
expect(addPendingTxs).toHaveBeenCalledWith([event]);
|
||||
});
|
||||
});
|
||||
});
|
103
apps/token/src/hooks/use-listen-for-pending-eth-events.ts
Normal file
103
apps/token/src/hooks/use-listen-for-pending-eth-events.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { useWeb3React } from '@web3-react/core';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import type { Contract, Event, EventFilter } from 'ethers';
|
||||
|
||||
export const useListenForPendingEthEvents = (
|
||||
numberOfConfirmations: number,
|
||||
contract: Contract | undefined,
|
||||
filter: EventFilter | null,
|
||||
addPendingTxs: (event: Event[]) => void,
|
||||
removePendingTx: (event: Event) => void,
|
||||
resetPendingTxs: () => void
|
||||
) => {
|
||||
const { provider } = useWeb3React();
|
||||
|
||||
/**
|
||||
* Add listener for the ethereum events on the contract passed in for the filter passed in.
|
||||
* Push the event into the store and wait for the correct number of confirmations before removing it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!contract || !filter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const listener = async (...args: any[]) => {
|
||||
try {
|
||||
const event = args[3] as Event;
|
||||
addPendingTxs([event]);
|
||||
const tx = await event.getTransaction();
|
||||
await tx.wait(numberOfConfirmations);
|
||||
removePendingTx(event);
|
||||
} catch (e) {
|
||||
Sentry.captureException(`Error listening for pending eth events ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
contract.on(filter, listener);
|
||||
|
||||
return () => {
|
||||
contract.off(filter, listener);
|
||||
};
|
||||
}, [addPendingTxs, contract, filter, numberOfConfirmations, removePendingTx]);
|
||||
|
||||
/**
|
||||
* Get all transactions that exist on the blockchain but have yet to reach the number of confirmations
|
||||
*/
|
||||
const getExistingTransactions = useCallback(async () => {
|
||||
const blockNumber = (await provider?.getBlockNumber()) || 0;
|
||||
if (!filter || !contract) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await contract.queryFilter(
|
||||
filter,
|
||||
blockNumber - numberOfConfirmations
|
||||
);
|
||||
} catch (e) {
|
||||
Sentry.captureException(`Error getting existing transactions ${e}`);
|
||||
return [];
|
||||
}
|
||||
}, [contract, filter, numberOfConfirmations, provider]);
|
||||
|
||||
const waitForExistingTransactions = useCallback(
|
||||
(events: Event[], numberOfConfirmations: number) => {
|
||||
events.map(async (event) => {
|
||||
try {
|
||||
const tx = await event.getTransaction();
|
||||
|
||||
await tx.wait(Math.max(numberOfConfirmations, 0));
|
||||
|
||||
removePendingTx(event);
|
||||
} catch (e) {
|
||||
Sentry.captureException(
|
||||
`Error waiting for existing transactions ${e}`
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
[removePendingTx]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
resetPendingTxs();
|
||||
getExistingTransactions().then((events) => {
|
||||
if (!cancelled) {
|
||||
addPendingTxs([...events]);
|
||||
waitForExistingTransactions([...events], numberOfConfirmations);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
addPendingTxs,
|
||||
getExistingTransactions,
|
||||
numberOfConfirmations,
|
||||
resetPendingTxs,
|
||||
waitForExistingTransactions,
|
||||
]);
|
||||
};
|
56
apps/token/src/hooks/use-listen-for-staking-events.ts
Normal file
56
apps/token/src/hooks/use-listen-for-staking-events.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePendingBalancesStore } from './use-pending-balances-manager';
|
||||
import type { Contract } from 'ethers';
|
||||
import { useListenForPendingEthEvents } from './use-listen-for-pending-eth-events';
|
||||
import { prepend0x } from '@vegaprotocol/smart-contracts';
|
||||
|
||||
export const useListenForStakingEvents = (
|
||||
contract: Contract | undefined,
|
||||
vegaPublicKey: string | null,
|
||||
numberOfConfirmations: number
|
||||
) => {
|
||||
const { addPendingTxs, removePendingTx, resetPendingTxs } =
|
||||
usePendingBalancesStore((state) => ({
|
||||
addPendingTxs: state.addPendingTxs,
|
||||
removePendingTx: state.removePendingTx,
|
||||
resetPendingTxs: state.resetPendingTxs,
|
||||
}));
|
||||
const addFilter = useMemo(
|
||||
() =>
|
||||
vegaPublicKey && contract
|
||||
? contract.filters.Stake_Deposited(null, null, prepend0x(vegaPublicKey))
|
||||
: null,
|
||||
[contract, vegaPublicKey]
|
||||
);
|
||||
const removeFilter = useMemo(
|
||||
() =>
|
||||
vegaPublicKey && contract
|
||||
? contract.filters.Stake_Removed(null, null, prepend0x(vegaPublicKey))
|
||||
: null,
|
||||
[contract, vegaPublicKey]
|
||||
);
|
||||
|
||||
/**
|
||||
* Listen for all add stake events
|
||||
*/
|
||||
useListenForPendingEthEvents(
|
||||
numberOfConfirmations,
|
||||
contract,
|
||||
addFilter,
|
||||
addPendingTxs,
|
||||
removePendingTx,
|
||||
resetPendingTxs
|
||||
);
|
||||
|
||||
/**
|
||||
* Listen for all remove stake events
|
||||
*/
|
||||
useListenForPendingEthEvents(
|
||||
numberOfConfirmations,
|
||||
contract,
|
||||
removeFilter,
|
||||
addPendingTxs,
|
||||
removePendingTx,
|
||||
resetPendingTxs
|
||||
);
|
||||
};
|
76
apps/token/src/hooks/use-pending-balances-manager.spec.ts
Normal file
76
apps/token/src/hooks/use-pending-balances-manager.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import { usePendingBalancesStore } from './use-pending-balances-manager';
|
||||
import type { Event } from 'ethers';
|
||||
|
||||
afterEach(() => {
|
||||
usePendingBalancesStore.setState((state) => ({
|
||||
...state,
|
||||
pendingBalances: [],
|
||||
}));
|
||||
});
|
||||
|
||||
const event1 = { transactionHash: 'tx1' } as Event;
|
||||
const event2 = { transactionHash: 'tx2' } as Event;
|
||||
|
||||
describe('usePendingBalancesStore', () => {
|
||||
it('should add new events to the pendingBalances state and remove duplicates', () => {
|
||||
const { addPendingTxs } = usePendingBalancesStore.getState();
|
||||
const duplicateEvent = { transactionHash: 'tx1' } as Event;
|
||||
|
||||
act(() => {
|
||||
addPendingTxs([event1, duplicateEvent]);
|
||||
});
|
||||
|
||||
expect(usePendingBalancesStore.getState().pendingBalances).toEqual([
|
||||
event1,
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
addPendingTxs([event2]);
|
||||
});
|
||||
|
||||
expect(usePendingBalancesStore.getState().pendingBalances).toEqual([
|
||||
event1,
|
||||
event2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove a specific event from the pendingBalances state', () => {
|
||||
const { addPendingTxs, removePendingTx } =
|
||||
usePendingBalancesStore.getState();
|
||||
const eventToRemove = { transactionHash: 'tx1' } as Event;
|
||||
|
||||
act(() => {
|
||||
addPendingTxs([eventToRemove, event2]);
|
||||
});
|
||||
expect(usePendingBalancesStore.getState().pendingBalances).toEqual([
|
||||
eventToRemove,
|
||||
event2,
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
removePendingTx(eventToRemove);
|
||||
});
|
||||
expect(usePendingBalancesStore.getState().pendingBalances).toEqual([
|
||||
event2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reset the pendingBalances state', () => {
|
||||
const { addPendingTxs, resetPendingTxs } =
|
||||
usePendingBalancesStore.getState();
|
||||
|
||||
act(() => {
|
||||
addPendingTxs([event1, event2]);
|
||||
});
|
||||
expect(usePendingBalancesStore.getState().pendingBalances).toEqual([
|
||||
event1,
|
||||
event2,
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
resetPendingTxs();
|
||||
});
|
||||
expect(usePendingBalancesStore.getState().pendingBalances).toEqual([]);
|
||||
});
|
||||
});
|
35
apps/token/src/hooks/use-pending-balances-manager.ts
Normal file
35
apps/token/src/hooks/use-pending-balances-manager.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { Event } from 'ethers';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
import create from 'zustand';
|
||||
|
||||
export type PendingTxsStore = {
|
||||
pendingBalances: Event[];
|
||||
addPendingTxs: (event: Event[]) => void;
|
||||
removePendingTx: (event: Event) => void;
|
||||
resetPendingTxs: () => void;
|
||||
};
|
||||
|
||||
export const usePendingBalancesStore = create<PendingTxsStore>((set, get) => ({
|
||||
pendingBalances: [],
|
||||
addPendingTxs: (event: Event[]) => {
|
||||
set({
|
||||
pendingBalances: uniqBy(
|
||||
[...get().pendingBalances, ...event],
|
||||
'transactionHash'
|
||||
),
|
||||
});
|
||||
},
|
||||
removePendingTx: (event: Event) => {
|
||||
set({
|
||||
pendingBalances: [
|
||||
...get().pendingBalances.filter(
|
||||
({ transactionHash }) => transactionHash !== event.transactionHash
|
||||
),
|
||||
],
|
||||
});
|
||||
},
|
||||
resetPendingTxs: () => {
|
||||
set({ pendingBalances: [] });
|
||||
},
|
||||
}));
|
Loading…
Reference in New Issue
Block a user