feat(governance): pending balances: add event listeners (#2541)

Co-authored-by: Dexter <dexter.edwards93@gmail.com>
This commit is contained in:
Sam Keen 2023-02-01 16:02:50 +00:00 committed by GitHub
parent fb1ebb3bdf
commit d2bbd7e1fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 456 additions and 9 deletions

View File

@ -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

View File

@ -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___/**',
],
};

View File

@ -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();
}

View File

@ -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>

View File

@ -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;

View 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]);
});
});
});

View 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,
]);
};

View 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
);
};

View 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([]);
});
});

View 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: [] });
},
}));