Chore/list asset (#1964)

* chore: add whitelist asset function

* feat: add whitelist button

* test: fix tests for proposal form

* test: add tests for new component

* chore: revert incorectly comitted changes

* Update apps/token/src/routes/governance/components/proposal/proposal.spec.tsx

Co-authored-by: Sam Keen <samuel@vegaprotocol.io>

Co-authored-by: Sam Keen <samuel@vegaprotocol.io>
This commit is contained in:
Dexter Edwards 2022-11-07 17:17:03 +00:00 committed by GitHub
parent e5432888aa
commit 113eb90469
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 509 additions and 3 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.n06.testnet.vega.xyz/graphql
NX_VEGA_URL=https://api.n11.testnet.vega.xyz/graphql
NX_ETHEREUM_PROVIDER_URL=https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
NX_ETHERSCAN_URL=https://sepolia.etherscan.io
NX_FAIRGROUND=false

View File

@ -0,0 +1,36 @@
import { Button } from '@vegaprotocol/ui-toolkit';
import { useWeb3React } from '@web3-react/core';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import {
AppStateActionType,
useAppState,
} from '../../contexts/app-state/app-state-context';
export const EthWalletContainer = ({
children,
}: {
children: ReactElement;
}) => {
const { account } = useWeb3React();
const { t } = useTranslation();
const { appDispatch } = useAppState();
if (!account) {
return (
<div className="w-full text-center">
<Button
onClick={() =>
appDispatch({
type: AppStateActionType.SET_ETH_WALLET_OVERLAY,
isOpen: true,
})
}
>
{t('connectEthWallet')}
</Button>
</div>
);
}
return children;
};

View File

@ -0,0 +1 @@
export * from './eth-wallet-container';

View File

@ -704,5 +704,8 @@
"votingEnded": "Voting has ended.",
"STATUS": "STATUS",
"ProposalMinimumAmounts": "Different proposal types can have different minimum token requirements. You must have the greater of the proposal minimum or spam protection minimum from the table below",
"SpamProtectionMin": "Spam protection minimum"
"SpamProtectionMin": "Spam protection minimum",
"ListAsset": "List Asset",
"ListAssetDescription": "This asset needs to be listed on the collateral bridge before it can be used.",
"ListAssetAction": "List asset"
}

View File

@ -0,0 +1,19 @@
query ProposalAsset($assetId: ID!) {
asset(id: $assetId) {
status
source {
... on ERC20 {
contractAddress
}
}
}
}
query AssetListBundle($assetId: ID!) {
erc20ListAssetBundle(assetId: $assetId) {
assetSource
vegaAssetId
nonce
signatures
}
}

View File

@ -0,0 +1,98 @@
import { Schema as Types } from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ProposalAssetQueryVariables = Types.Exact<{
assetId: Types.Scalars['ID'];
}>;
export type ProposalAssetQuery = { __typename?: 'Query', asset?: { __typename?: 'Asset', status: Types.AssetStatus, source: { __typename?: 'BuiltinAsset' } | { __typename?: 'ERC20', contractAddress: string } } | null };
export type AssetListBundleQueryVariables = Types.Exact<{
assetId: Types.Scalars['ID'];
}>;
export type AssetListBundleQuery = { __typename?: 'Query', erc20ListAssetBundle?: { __typename?: 'Erc20ListAssetBundle', assetSource: string, vegaAssetId: string, nonce: string, signatures: string } | null };
export const ProposalAssetDocument = gql`
query ProposalAsset($assetId: ID!) {
asset(id: $assetId) {
status
source {
... on ERC20 {
contractAddress
}
}
}
}
`;
/**
* __useProposalAssetQuery__
*
* To run a query within a React component, call `useProposalAssetQuery` and pass it any options that fit your needs.
* When your component renders, `useProposalAssetQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useProposalAssetQuery({
* variables: {
* assetId: // value for 'assetId'
* },
* });
*/
export function useProposalAssetQuery(baseOptions: Apollo.QueryHookOptions<ProposalAssetQuery, ProposalAssetQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ProposalAssetQuery, ProposalAssetQueryVariables>(ProposalAssetDocument, options);
}
export function useProposalAssetLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ProposalAssetQuery, ProposalAssetQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ProposalAssetQuery, ProposalAssetQueryVariables>(ProposalAssetDocument, options);
}
export type ProposalAssetQueryHookResult = ReturnType<typeof useProposalAssetQuery>;
export type ProposalAssetLazyQueryHookResult = ReturnType<typeof useProposalAssetLazyQuery>;
export type ProposalAssetQueryResult = Apollo.QueryResult<ProposalAssetQuery, ProposalAssetQueryVariables>;
export const AssetListBundleDocument = gql`
query AssetListBundle($assetId: ID!) {
erc20ListAssetBundle(assetId: $assetId) {
assetSource
vegaAssetId
nonce
signatures
}
}
`;
/**
* __useAssetListBundleQuery__
*
* To run a query within a React component, call `useAssetListBundleQuery` and pass it any options that fit your needs.
* When your component renders, `useAssetListBundleQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useAssetListBundleQuery({
* variables: {
* assetId: // value for 'assetId'
* },
* });
*/
export function useAssetListBundleQuery(baseOptions: Apollo.QueryHookOptions<AssetListBundleQuery, AssetListBundleQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<AssetListBundleQuery, AssetListBundleQueryVariables>(AssetListBundleDocument, options);
}
export function useAssetListBundleLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<AssetListBundleQuery, AssetListBundleQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<AssetListBundleQuery, AssetListBundleQueryVariables>(AssetListBundleDocument, options);
}
export type AssetListBundleQueryHookResult = ReturnType<typeof useAssetListBundleQuery>;
export type AssetListBundleLazyQueryHookResult = ReturnType<typeof useAssetListBundleLazyQuery>;
export type AssetListBundleQueryResult = Apollo.QueryResult<AssetListBundleQuery, AssetListBundleQueryVariables>;

View File

@ -0,0 +1 @@
export { ListAsset } from './list-asset';

View File

@ -0,0 +1,186 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ListAsset } from './list-asset';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type {
AssetListBundleQuery,
ProposalAssetQuery,
} from './__generated___/Asset';
import { AssetListBundleDocument } from './__generated___/Asset';
import { ProposalAssetDocument } from './__generated___/Asset';
import { AssetStatus } from '@vegaprotocol/types';
import type { useWeb3React } from '@web3-react/core';
import BigNumber from 'bignumber.js';
import type { AppState } from '../../../../contexts/app-state/app-state-context';
const mockUseEthTx = {
perform: jest.fn(),
Dialog: () => null,
};
jest.mock('@vegaprotocol/web3', () => {
const orig = jest.requireActual('@vegaprotocol/web3');
return {
...orig,
useBridgeContract: jest.fn().mockReturnValue({
list_asset: jest.fn(),
isNewContract: true,
}),
useEthereumTransaction: jest.fn(() => mockUseEthTx),
};
});
const defaultHookValue = {
isActive: false,
error: undefined,
connector: null,
chainId: 3,
account: null,
} as unknown as ReturnType<typeof useWeb3React>;
let mockHookValue: ReturnType<typeof useWeb3React>;
jest.mock('@web3-react/core', () => {
const original = jest.requireActual('@web3-react/core');
return {
...original,
useWeb3React: jest.fn(() => mockHookValue),
};
});
const mockAppState: AppState = {
totalAssociated: new BigNumber('50063005'),
decimals: 18,
totalSupply: new BigNumber(65000000),
balanceFormatted: new BigNumber(0),
walletBalance: new BigNumber(0),
lien: new BigNumber(0),
allowance: new BigNumber(0),
tranches: null,
vegaWalletOverlay: false,
vegaWalletManageOverlay: false,
ethConnectOverlay: false,
walletAssociatedBalance: null,
vestingAssociatedBalance: null,
trancheBalances: [],
totalLockedBalance: new BigNumber(0),
totalVestedBalance: new BigNumber(0),
trancheError: null,
drawerOpen: false,
associationBreakdown: {
vestingAssociations: {},
stakingAssociations: {},
},
transactionOverlay: false,
bannerMessage: '',
};
jest.mock('../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
appState: mockAppState,
}),
}));
const ASSET_ID = 'foo';
const DEFAULT__ASSET: ProposalAssetQuery = {
__typename: 'Query',
asset: {
__typename: 'Asset',
status: AssetStatus.STATUS_PENDING_LISTING,
source: {
__typename: 'ERC20',
contractAddress: '0x0',
},
},
};
const proposalAssetMock: MockedResponse<ProposalAssetQuery> = {
request: {
query: ProposalAssetDocument,
variables: {
assetId: ASSET_ID,
},
},
result: {
data: {
...DEFAULT__ASSET,
},
},
};
const assetBundleMock: MockedResponse<AssetListBundleQuery> = {
request: {
query: AssetListBundleDocument,
variables: {
assetId: ASSET_ID,
},
},
result: {
data: {
erc20ListAssetBundle: {
assetSource: '0xf00',
vegaAssetId: ASSET_ID,
nonce: '1',
signatures: '0x0',
},
},
},
};
const renderComponent = (assetId: string) => {
return render(
<MockedProvider mocks={[proposalAssetMock, assetBundleMock]}>
<ListAsset
assetId={assetId}
withdrawalThreshold={'1'}
lifetimeLimit={'100'}
/>
</MockedProvider>
);
};
it('Renders connect state if not connected', async () => {
mockHookValue = defaultHookValue;
renderComponent(ASSET_ID);
expect(
await screen.findByText('Connect Ethereum wallet')
).toBeInTheDocument();
});
it('Renders title, description and button when connected', async () => {
mockHookValue = {
...defaultHookValue,
account: 'foo',
};
renderComponent(ASSET_ID);
expect(await screen.findByText('List asset')).toBeInTheDocument();
expect(
await screen.findByText(
'This asset needs to be listed on the collateral bridge before it can be used.'
)
).toBeInTheDocument();
expect(await screen.findByTestId('list-asset')).toHaveTextContent(
'List asset'
);
});
it('Sends transaction correctly when button is pressed', async () => {
mockHookValue = {
...defaultHookValue,
account: 'foo',
};
renderComponent(ASSET_ID);
expect(await screen.findByTestId('list-asset')).toHaveTextContent(
'List asset'
);
fireEvent.click(screen.getByTestId('list-asset'));
expect(mockUseEthTx.perform).toBeCalledWith(
'0xf00',
'0xfoo',
'100',
'1',
'1',
'0x0'
);
});

View File

@ -0,0 +1,111 @@
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
import { AssetStatus } from '@vegaprotocol/types';
import { Button } from '@vegaprotocol/ui-toolkit';
import { useBridgeContract, useEthereumTransaction } from '@vegaprotocol/web3';
import { useTranslation } from 'react-i18next';
import {
useAssetListBundleQuery,
useProposalAssetQuery,
} from './__generated___/Asset';
import { EthWalletContainer } from '../../../../components/eth-wallet-container';
const useListAsset = (assetId: string) => {
const bridgeContract = useBridgeContract();
const transaction = useEthereumTransaction<CollateralBridge, 'list_asset'>(
bridgeContract,
'list_asset'
);
const {
data,
loading: loadingAsset,
error: errorAsset,
} = useProposalAssetQuery({
variables: {
assetId,
},
});
const {
data: assetData,
loading: loadingBundle,
error: errorBundle,
} = useAssetListBundleQuery({
variables: {
assetId,
},
skip: !data,
});
return {
transaction,
data,
loadingAsset,
errorAsset,
assetData,
loadingBundle,
errorBundle,
};
};
export interface ListAssetProps {
lifetimeLimit: string;
withdrawalThreshold: string;
assetId: string;
}
export const ListAsset = ({
assetId,
lifetimeLimit,
withdrawalThreshold,
}: ListAssetProps) => {
const { t } = useTranslation();
const {
transaction,
data,
loadingAsset,
errorAsset,
assetData,
loadingBundle,
errorBundle,
} = useListAsset(assetId);
const { perform, Dialog } = transaction;
if (
!assetData?.erc20ListAssetBundle ||
!assetData.erc20ListAssetBundle ||
!data?.asset ||
loadingAsset ||
loadingBundle
) {
return null;
}
if (data.asset.source.__typename !== 'ERC20') return null;
if (data.asset.status !== AssetStatus.STATUS_PENDING_LISTING) return null;
if (errorAsset || errorBundle) return null;
const { assetSource, signatures, vegaAssetId, nonce } =
assetData.erc20ListAssetBundle;
return (
<div className="mb-8">
<h3 className="text-xl mb-2">{t('ListAsset')}</h3>
<p className="pr-8">{t('ListAssetDescription')}</p>
<EthWalletContainer>
<Button
data-testid="list-asset"
onClick={() =>
perform(
assetSource,
`0x${vegaAssetId}`,
lifetimeLimit,
withdrawalThreshold,
nonce,
signatures
)
}
>
{t('ListAssetAction')}
</Button>
</EthWalletContainer>
<Dialog />
</div>
);
};

View File

@ -34,6 +34,9 @@ jest.mock('../proposal-votes-table', () => ({
jest.mock('../vote-details', () => ({
VoteDetails: () => <div data-testid="proposal-vote-details"></div>,
}));
jest.mock('../list-asset', () => ({
ListAsset: () => <div data-testid="proposal-list-asset"></div>,
}));
it('Renders with data-testid', async () => {
const proposal = generateProposal();
@ -49,4 +52,26 @@ it('renders each section', async () => {
expect(screen.getByTestId('proposal-terms-json')).toBeInTheDocument();
expect(screen.getByTestId('proposal-votes-table')).toBeInTheDocument();
expect(screen.getByTestId('proposal-vote-details')).toBeInTheDocument();
expect(screen.queryByTestId('proposal-list-asset')).not.toBeInTheDocument();
});
it('renders whitelist section if proposal is new asset and source is erc20', async () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'NewAsset',
name: 'foo',
symbol: 'FOO',
decimals: 18,
quantum: '1',
source: {
__typename: 'ERC20',
lifetimeLimit: '1',
withdrawThreshold: '100',
},
},
},
});
render(<Proposal proposal={proposal as Proposal_proposal} />);
expect(screen.getByTestId('proposal-list-asset')).toBeInTheDocument();
});

View File

@ -6,6 +6,7 @@ import { ProposalChangeTable } from '../proposal-change-table';
import { ProposalTermsJson } from '../proposal-terms-json';
import { ProposalVotesTable } from '../proposal-votes-table';
import { VoteDetails } from '../vote-details';
import { ListAsset } from '../list-asset';
export enum ProposalType {
PROPOSAL_NEW_MARKET = 'PROPOSAL_NEW_MARKET',
@ -15,7 +16,6 @@ export enum ProposalType {
PROPOSAL_NETWORK_PARAMETER = 'PROPOSAL_NETWORK_PARAMETER',
PROPOSAL_FREEFORM = 'PROPOSAL_FREEFORM',
}
interface ProposalProps {
proposal: Proposal_proposal;
}
@ -77,6 +77,15 @@ export const Proposal = ({ proposal }: ProposalProps) => {
<div className="mb-8">
<ProposalChangeTable proposal={proposal} />
</div>
{proposal.terms.change.__typename === 'NewAsset' &&
proposal.terms.change.source.__typename === 'ERC20' &&
proposal.id ? (
<ListAsset
assetId={proposal.id}
withdrawalThreshold={proposal.terms.change.source.withdrawThreshold}
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
/>
) : null}
<div className="mb-8">
<VoteDetails
proposal={proposal}

View File

@ -37,6 +37,23 @@ export class CollateralBridge {
default_withdraw_delay() {
return this.contract.default_withdraw_delay();
}
list_asset(
address: string,
vegaAssetId: string,
lifetimeLimit: string,
withdraw_threshold: string,
nonce: string,
signatures: string
) {
return this.contract.list_asset(
address,
vegaAssetId,
lifetimeLimit,
withdraw_threshold,
nonce,
signatures
);
}
withdraw_asset(
assetSource: string,
amount: string,