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}