diff --git a/apps/token/.env b/apps/token/.env index e1f58a3e0..052378d8f 100644 --- a/apps/token/.env +++ b/apps/token/.env @@ -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 diff --git a/apps/token/src/components/eth-wallet-container/eth-wallet-container.tsx b/apps/token/src/components/eth-wallet-container/eth-wallet-container.tsx new file mode 100644 index 000000000..03604af35 --- /dev/null +++ b/apps/token/src/components/eth-wallet-container/eth-wallet-container.tsx @@ -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 ( +
+ +
+ ); + } + return children; +}; diff --git a/apps/token/src/components/eth-wallet-container/index.tsx b/apps/token/src/components/eth-wallet-container/index.tsx new file mode 100644 index 000000000..669004a23 --- /dev/null +++ b/apps/token/src/components/eth-wallet-container/index.tsx @@ -0,0 +1 @@ +export * from './eth-wallet-container'; diff --git a/apps/token/src/i18n/translations/dev.json b/apps/token/src/i18n/translations/dev.json index 885d05016..e0afafc87 100644 --- a/apps/token/src/i18n/translations/dev.json +++ b/apps/token/src/i18n/translations/dev.json @@ -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" } diff --git a/apps/token/src/routes/governance/components/list-asset/Asset.graphql b/apps/token/src/routes/governance/components/list-asset/Asset.graphql new file mode 100644 index 000000000..f8fa0543c --- /dev/null +++ b/apps/token/src/routes/governance/components/list-asset/Asset.graphql @@ -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 + } +} diff --git a/apps/token/src/routes/governance/components/list-asset/__generated___/Asset.ts b/apps/token/src/routes/governance/components/list-asset/__generated___/Asset.ts new file mode 100644 index 000000000..ec9a63081 --- /dev/null +++ b/apps/token/src/routes/governance/components/list-asset/__generated___/Asset.ts @@ -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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ProposalAssetDocument, options); + } +export function useProposalAssetLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ProposalAssetDocument, options); + } +export type ProposalAssetQueryHookResult = ReturnType; +export type ProposalAssetLazyQueryHookResult = ReturnType; +export type ProposalAssetQueryResult = Apollo.QueryResult; +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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(AssetListBundleDocument, options); + } +export function useAssetListBundleLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(AssetListBundleDocument, options); + } +export type AssetListBundleQueryHookResult = ReturnType; +export type AssetListBundleLazyQueryHookResult = ReturnType; +export type AssetListBundleQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/token/src/routes/governance/components/list-asset/index.tsx b/apps/token/src/routes/governance/components/list-asset/index.tsx new file mode 100644 index 000000000..178a96f39 --- /dev/null +++ b/apps/token/src/routes/governance/components/list-asset/index.tsx @@ -0,0 +1 @@ +export { ListAsset } from './list-asset'; diff --git a/apps/token/src/routes/governance/components/list-asset/list-asset.spec.tsx b/apps/token/src/routes/governance/components/list-asset/list-asset.spec.tsx new file mode 100644 index 000000000..d027d59e3 --- /dev/null +++ b/apps/token/src/routes/governance/components/list-asset/list-asset.spec.tsx @@ -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; +let mockHookValue: ReturnType; + +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 = { + request: { + query: ProposalAssetDocument, + variables: { + assetId: ASSET_ID, + }, + }, + result: { + data: { + ...DEFAULT__ASSET, + }, + }, +}; + +const assetBundleMock: MockedResponse = { + 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( + + + + ); +}; + +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' + ); +}); diff --git a/apps/token/src/routes/governance/components/list-asset/list-asset.tsx b/apps/token/src/routes/governance/components/list-asset/list-asset.tsx new file mode 100644 index 000000000..77318ad19 --- /dev/null +++ b/apps/token/src/routes/governance/components/list-asset/list-asset.tsx @@ -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( + 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 ( +
+

{t('ListAsset')}

+

{t('ListAssetDescription')}

+ + + + +
+ ); +}; diff --git a/apps/token/src/routes/governance/components/proposal/proposal.spec.tsx b/apps/token/src/routes/governance/components/proposal/proposal.spec.tsx index b92174605..346ec7894 100644 --- a/apps/token/src/routes/governance/components/proposal/proposal.spec.tsx +++ b/apps/token/src/routes/governance/components/proposal/proposal.spec.tsx @@ -34,6 +34,9 @@ jest.mock('../proposal-votes-table', () => ({ jest.mock('../vote-details', () => ({ VoteDetails: () =>
, })); +jest.mock('../list-asset', () => ({ + ListAsset: () =>
, +})); 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(); + expect(screen.getByTestId('proposal-list-asset')).toBeInTheDocument(); }); diff --git a/apps/token/src/routes/governance/components/proposal/proposal.tsx b/apps/token/src/routes/governance/components/proposal/proposal.tsx index bb314470e..452d25fe3 100644 --- a/apps/token/src/routes/governance/components/proposal/proposal.tsx +++ b/apps/token/src/routes/governance/components/proposal/proposal.tsx @@ -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) => {
+ {proposal.terms.change.__typename === 'NewAsset' && + proposal.terms.change.source.__typename === 'ERC20' && + proposal.id ? ( + + ) : null}