Compare commits

..
19 changed files with 319 additions and 380 deletions
+28
View File
@@ -0,0 +1,28 @@
---
name: Feature Epic
about: A template to capture and scope user requirements, high level process, and basic mockups for an upcoming feature as part of the initial core spec review process.
title: 'Epic: '
labels: feature-epic
---
## Core Feature
<Name>
## Tasks
- [ ] Define high level requirements
- [ ] Create basic mockups
- [ ] Update "API Requirements" in core spec
- [ ] Update "User-Interface Spec" in relevant front end repo
- [ ] Create detailed user stories using normal template
## High Level Requirements
## Basic Mockups
## Link to API Requirements in Core spec
## Link to User Interface Specs
## Linked User Stories
@@ -10,7 +10,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
Icon, Icon,
Button,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { FilterLabel } from './tx-filter-label'; import { FilterLabel } from './tx-filter-label';
@@ -100,15 +99,13 @@ export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => {
<DropdownMenu <DropdownMenu
modal={false} modal={false}
trigger={ trigger={
<DropdownMenuTrigger className="ml-0"> <DropdownMenuTrigger>
<Button size="xs" data-testid="filter-trigger"> <FilterLabel filters={filters} />
<FilterLabel filters={filters} />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
} }
> >
<DropdownMenuContent> <DropdownMenuContent>
{filters.size > 0 ? null : ( {filters.size > 1 ? null : (
<> <>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onCheckedChange={() => setFilters(new Set(AllFilterOptions))} onCheckedChange={() => setFilters(new Set(AllFilterOptions))}
@@ -44,7 +44,7 @@ export const TxsListNavigation = ({
</Button> </Button>
<Button <Button
size="xs" size="xs"
disabled={!hasMoreTxs || loading} disabled={!hasMoreTxs}
onClick={() => { onClick={() => {
nextPage(); nextPage();
}} }}
@@ -86,14 +86,18 @@ describe('Txs infinite list item', () => {
render( render(
<MockedProvider> <MockedProvider>
<MemoryRouter> <MemoryRouter>
<TxsInfiniteListItem <table>
type="testType" <tbody>
submitter="testPubKey" <TxsInfiniteListItem
hash="testTxHash" type="testType"
block="1" submitter="testPubKey"
code={0} hash="testTxHash"
command={{}} block="1"
/> code={0}
command={{}}
/>
</tbody>
</table>
</MemoryRouter> </MemoryRouter>
</MockedProvider> </MockedProvider>
); );
@@ -14,9 +14,9 @@ const DEFAULT_TRUNCATE_LENGTH = 7;
export function getIdTruncateLength(screen: Screen): number { export function getIdTruncateLength(screen: Screen): number {
if (['xxxl', 'xxl'].includes(screen)) { if (['xxxl', 'xxl'].includes(screen)) {
return 64;
} else if (['xl', 'lg', 'md'].includes(screen)) {
return 32; return 32;
} else if (['xl', 'lg', 'md'].includes(screen)) {
return 16;
} }
return DEFAULT_TRUNCATE_LENGTH; return DEFAULT_TRUNCATE_LENGTH;
} }
@@ -5,11 +5,17 @@ import type { BlockExplorerTransactionResult } from '../../routes/types/block-ex
import { Side } from '@vegaprotocol/types'; import { Side } from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
const generateHash = (): string =>
Array.from(
{ length: 64 },
() => '0123456789ABCDEF'[Math.floor(Math.random() * 16)]
).join('');
const generateTxs = (number: number): BlockExplorerTransactionResult[] => { const generateTxs = (number: number): BlockExplorerTransactionResult[] => {
return Array.from(Array(number)).map((_) => ({ return Array.from(Array(number)).map((_) => ({
block: '87901', block: '87901',
index: 2, index: 2,
hash: '0F8B98DA0923A50786B852D9CA11E051CACC4C733E1DB93D535C7D81DBD10F6F', hash: generateHash(),
submitter: submitter:
'4b782482f587d291e8614219eb9a5ee9280fa2c58982dee71d976782a9be1964', '4b782482f587d291e8614219eb9a5ee9280fa2c58982dee71d976782a9be1964',
type: 'Submit Order', type: 'Submit Order',
@@ -2,7 +2,7 @@ import { Table, TableRow } from '../table';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useFetch } from '@vegaprotocol/react-helpers'; import { useFetch } from '@vegaprotocol/react-helpers';
import type { BlockExplorerTransactions } from '../../routes/types/block-explorer-response'; import type { BlockExplorerTransactions } from '../../routes/types/block-explorer-response';
import { getTxsDataUrl } from '../../hooks/use-txs-data'; import { getTxsDataUrl } from '../../hooks/get-txs-data-url';
import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit';
import EmptyList from '../empty-list/empty-list'; import EmptyList from '../empty-list/empty-list';
import { TxsInfiniteListItem } from './txs-infinite-list-item'; import { TxsInfiniteListItem } from './txs-infinite-list-item';
@@ -14,7 +14,7 @@ interface TxsPerBlockProps {
export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => { export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => {
const filters = `filters[block.height]=${blockHeight}`; const filters = `filters[block.height]=${blockHeight}`;
const url = getTxsDataUrl({ limit: txCount.toString(), filters }); const url = getTxsDataUrl({ filters, count: txCount });
const { const {
state: { data, loading, error }, state: { data, loading, error },
} = useFetch<BlockExplorerTransactions>(url); } = useFetch<BlockExplorerTransactions>(url);
@@ -0,0 +1,59 @@
import { DATA_SOURCES } from '../config';
type IGetTxsDataFirstPage = {
baseUrl?: string;
count?: number;
party?: string;
filters?: string;
};
interface IGetTxsDataPrevious extends IGetTxsDataFirstPage {
before: string;
}
interface IGetTxsDataNext extends IGetTxsDataFirstPage {
after: string;
}
type IGetTxsDataUrl =
| IGetTxsDataPrevious
| IGetTxsDataNext
| IGetTxsDataFirstPage;
export const BE_TXS_PER_REQUEST = 25;
/**
* Properly encodes the filters and parameters for a request to the block explorer
* API for transactions. As the API uses a slightly less common format for encoding
* filters, some of it is more manual than you might expect.
*
* @param params An object containing the pagination and filters
* @returns string URL to call
*/
export const getTxsDataUrl = (params: IGetTxsDataUrl) => {
const baseUrl =
params.baseUrl || `${DATA_SOURCES.blockExplorerUrl}/transactions`;
const url = new URL(baseUrl);
const count = `${params.count || BE_TXS_PER_REQUEST}`;
if ('before' in params && params.before?.length > 0) {
url.searchParams.append('last', count);
url.searchParams.append('before', params.before);
} else if ('after' in params && params.after?.length > 0) {
url.searchParams.append('first', count);
url.searchParams.append('after', params.after);
} else {
url.searchParams.append('first', count);
}
// Hacky fix for param as array
let urlAsString = url.toString();
if (params.filters && params.filters?.length > 0) {
urlAsString += '&' + params.filters.replaceAll(' ', '%20');
}
if (params.party && params.party?.length > 0) {
urlAsString += `&filters[tx.submitter]=${params.party}`;
}
return urlAsString;
};
@@ -0,0 +1,48 @@
import { getTxsDataUrl } from './get-txs-data-url'; // import the function to be tested
describe('getTxsDataUrl', () => {
it('should return the correct URL without filters and party', () => {
const params = {
count: 10,
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?first=10';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
it('should return the correct URL with "before" in params', () => {
const params = {
count: 5,
before: '100.1',
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?last=5&before=100.1';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
it('should return the correct URL with "after" in params', () => {
const params = {
count: 5,
after: '222.1',
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?first=5&after=222.1';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
it('should return the correct URL with filters and party', () => {
const params = {
count: 10,
filters: 'filters[cmd.type]=Made Up Transaction',
party: '1234',
baseUrl: 'https://example.com/transactions',
};
const expectedUrl =
'https://example.com/transactions?first=10&filters[cmd.type]=Made%20Up%20Transaction&filters[tx.submitter]=1234';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
});
+103 -92
View File
@@ -1,130 +1,141 @@
import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom';
import type { URLSearchParamsInit } from 'react-router-dom';
import { useCallback } from 'react';
import { useFetch } from '@vegaprotocol/react-helpers'; import { useFetch } from '@vegaprotocol/react-helpers';
import type { import type {
BlockExplorerTransactionResult, BlockExplorerTransactionResult,
BlockExplorerTransactions, BlockExplorerTransactions,
} from '../routes/types/block-explorer-response'; } from '../routes/types/block-explorer-response';
import { DATA_SOURCES } from '../config';
import isNumber from 'lodash/isNumber'; import isNumber from 'lodash/isNumber';
import { AllFilterOptions } from '../components/txs/tx-filter';
import type { FilterOption } from '../components/txs/tx-filter';
import { BE_TXS_PER_REQUEST, getTxsDataUrl } from './get-txs-data-url';
export interface TxsStateProps { export function getTypeFilters(filters?: Set<FilterOption>) {
txsData: BlockExplorerTransactionResult[]; if (!filters) {
hasMoreTxs: boolean; return '';
cursor: string; } else if (filters.size > 1) {
previousCursors: string[]; return '';
hasPreviousPage: boolean; }
const forcedSingleFilter = Array.from(filters)[0];
return `filters[cmd.type]=${forcedSingleFilter}`;
} }
export interface IUseTxsData { export interface IUseTxsData {
limit: number; count?: number;
filters?: string; before?: string;
after?: string;
party?: string;
filters?: Set<FilterOption>;
} }
interface IGetTxsDataUrl { export const useTxsData = ({
limit: string; count = 25,
filters?: string; before,
} after,
filters,
party,
}: IUseTxsData) => {
const [, setSearchParams] = useSearchParams();
let hasMoreTxs = true;
let txsData: BlockExplorerTransactionResult[] = [];
export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => { const url = getTxsDataUrl({
const url = new URL(`${DATA_SOURCES.blockExplorerUrl}/transactions`); filters: getTypeFilters(filters),
count,
if (limit) { before,
url.searchParams.append('limit', limit); after,
} party,
// Hacky fix for param as array
let urlAsString = url.toString();
if (filters) {
urlAsString += '&' + filters.replace(' ', '%20');
}
return urlAsString;
};
export const useTxsData = ({ limit, filters }: IUseTxsData) => {
const [
{ txsData, hasMoreTxs, cursor, previousCursors, hasPreviousPage },
setTxsState,
] = useState<TxsStateProps>({
txsData: [],
hasMoreTxs: false,
previousCursors: [],
cursor: '',
hasPreviousPage: false,
}); });
const url = getTxsDataUrl({ limit: limit.toString(), filters });
const { const {
state: { data, error, loading }, state: { data, error, loading },
refetch, refetch,
} = useFetch<BlockExplorerTransactions>(url, {}, true); } = useFetch<BlockExplorerTransactions>(url, {}, true);
useEffect(() => { if (!loading && data && isNumber(data.transactions.length)) {
if (!loading && data && isNumber(data.transactions.length)) { hasMoreTxs = data.transactions.length >= count;
setTxsState((prev) => { txsData = data.transactions;
return { }
...prev,
txsData: data.transactions,
hasMoreTxs: data.transactions.length >= limit,
cursor: data?.transactions.at(-1)?.cursor || '',
};
});
}
}, [loading, setTxsState, data, limit]);
const nextPage = useCallback(() => { const nextPage = useCallback(() => {
const c = data?.transactions.at(0)?.cursor; const after = data?.transactions.at(-1)?.cursor || '';
const newPreviousCursors = c ? [...previousCursors, c] : previousCursors; const params: URLSearchParamsInit = { after };
if (filters) {
setTxsState((prev) => ({ params.filters = Array.from(filters).join(',');
...prev, }
hasPreviousPage: true, setSearchParams(params);
previousCursors: newPreviousCursors, }, [filters, data, setSearchParams]);
}));
return refetch({
limit,
before: cursor,
});
}, [data, previousCursors, cursor, limit, refetch]);
const previousPage = useCallback(() => { const previousPage = useCallback(() => {
const previousCursor = [...previousCursors].pop(); const before = data?.transactions[0]?.cursor || '';
const newPreviousCursors = previousCursors.slice(0, -1); const params: URLSearchParamsInit = { before };
setTxsState((prev) => ({ if (filters && filters.size > 0 && filters.size === 1) {
...prev, params.filters = Array.from(filters)[0];
hasPreviousPage: newPreviousCursors.length > 0, }
previousCursors: newPreviousCursors, setSearchParams(params);
})); }, [filters, data, setSearchParams]);
return refetch({
limit,
before: previousCursor,
});
}, [previousCursors, limit, refetch]);
const refreshTxs = useCallback(async () => { const refreshTxs = useCallback(async () => {
setTxsState(() => ({ const params: URLSearchParamsInit = {};
txsData: [], if (filters && filters.size > 0 && filters.size === 1) {
cursor: '', params.filters = Array.from(filters)[0];
previousCursors: [], }
hasMoreTxs: false, setSearchParams(params);
hasPreviousPage: false,
}));
refetch({ limit }); refetch({ count: BE_TXS_PER_REQUEST });
}, [setTxsState, limit, refetch, filters]); // eslint-disable-line react-hooks/exhaustive-deps }, [setSearchParams, refetch, filters]);
const updateFilters = useCallback(
(newFilters: Set<FilterOption>) => {
const params: URLSearchParamsInit = {};
if (newFilters && newFilters.size === 1) {
params.filters = Array.from(newFilters)[0];
}
setSearchParams(params);
},
[setSearchParams]
);
return { return {
updateFilters,
txsData, txsData,
loading, loading,
error, error,
hasMoreTxs, hasMoreTxs,
hasPreviousPage,
previousCursors,
cursor,
refreshTxs, refreshTxs,
nextPage, nextPage,
previousPage, previousPage,
}; };
}; };
/**
* Returns a Set of filters based on the URLSearchParams, or
* defaults to all.
* @param params
* @returns Set
*/
export function getInitialFilters(params: URLSearchParams): Set<FilterOption> {
const defaultFilters = new Set(AllFilterOptions);
const p = params.get('filters');
if (!p) {
return defaultFilters;
}
const filters = new Set<FilterOption>();
p.split(',').forEach((f) => {
if (AllFilterOptions.includes(f as FilterOption)) {
filters.add(f as FilterOption);
}
});
if (filters.size === 0) {
return defaultFilters;
}
return filters;
}
@@ -204,6 +204,7 @@ describe('Block', () => {
(useFetch as jest.Mock).mockReturnValue({ (useFetch as jest.Mock).mockReturnValue({
state: { data: createBlockResponse(1), loading: false, error: null }, state: { data: createBlockResponse(1), loading: false, error: null },
}); });
render(renderComponent(1)); render(renderComponent(1));
await waitFor(() => screen.getByTestId('block-header')); await waitFor(() => screen.getByTestId('block-header'));
expect(screen.getByTestId('previous-block-button')).toHaveAttribute( expect(screen.getByTestId('previous-block-button')).toHaveAttribute(
@@ -15,9 +15,13 @@ import { PartyBlockAccounts } from './components/party-block-accounts';
import { isValidPartyId } from './components/party-id-error'; import { isValidPartyId } from './components/party-id-error';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import { TxsListNavigation } from '../../../components/txs/tx-list-navigation'; import { TxsListNavigation } from '../../../components/txs/tx-list-navigation';
import type { FilterOption } from '../../../components/txs/tx-filter';
import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter';
import { useSearchParams } from 'react-router-dom';
const Party = () => { const Party = () => {
const [params] = useSearchParams();
const [filters, setFilters] = useState(new Set(AllFilterOptions)); const [filters, setFilters] = useState(new Set(AllFilterOptions));
const { party } = useParams<{ party: string }>(); const { party } = useParams<{ party: string }>();
@@ -27,24 +31,21 @@ const Party = () => {
const partyId = toNonHex(party ? party : ''); const partyId = toNonHex(party ? party : '');
const { isMobile } = useScreenDimensions(); const { isMobile } = useScreenDimensions();
const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]); const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]);
const baseFilters = `filters[tx.submitter]=${partyId}`;
const f =
filters && filters.size === 1
? `${baseFilters}&filters[cmd.type]=${Array.from(filters)[0]}`
: baseFilters;
const { const {
hasMoreTxs,
nextPage, nextPage,
refreshTxs,
previousPage, previousPage,
error, error,
refreshTxs,
loading, loading,
txsData, txsData,
hasPreviousPage, hasMoreTxs,
updateFilters,
} = useTxsData({ } = useTxsData({
limit: 25, filters: filters.size === 1 ? filters : undefined,
filters: f, before: params.get('before') || undefined,
after: !params.get('before') ? params.get('after') || undefined : undefined,
party: partyId,
}); });
const variables = useMemo(() => ({ partyId }), [partyId]); const variables = useMemo(() => ({ partyId }), [partyId]);
@@ -102,15 +103,21 @@ const Party = () => {
refreshTxs={refreshTxs} refreshTxs={refreshTxs}
nextPage={nextPage} nextPage={nextPage}
previousPage={previousPage} previousPage={previousPage}
hasPreviousPage={hasPreviousPage} hasPreviousPage={true}
loading={loading} loading={loading}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
> >
<TxsFilter filters={filters} setFilters={setFilters} /> <TxsFilter
filters={filters}
setFilters={(f) => {
setFilters(f);
updateFilters(f as Set<FilterOption>);
}}
/>
</TxsListNavigation> </TxsListNavigation>
{!error && txsData ? ( {!error && txsData ? (
<TxsInfiniteList <TxsInfiniteList
hasMoreTxs={hasMoreTxs} hasMoreTxs={true}
areTxsLoading={loading} areTxsLoading={loading}
txs={txsData} txs={txsData}
loadMoreTxs={nextPage} loadMoreTxs={nextPage}
+19 -16
View File
@@ -1,14 +1,14 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { RouteTitle } from '../../../components/route-title'; import { RouteTitle } from '../../../components/route-title';
import { TxsInfiniteList } from '../../../components/txs'; import { TxsInfiniteList } from '../../../components/txs';
import { useTxsData } from '../../../hooks/use-txs-data'; import { useTxsData, getInitialFilters } from '../../../hooks/use-txs-data';
import { useDocumentTitle } from '../../../hooks/use-document-title'; import { useDocumentTitle } from '../../../hooks/use-document-title';
import { useState } from 'react'; import { useState } from 'react';
import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; import { TxsFilter } from '../../../components/txs/tx-filter';
import type { FilterOption } from '../../../components/txs/tx-filter';
import { TxsListNavigation } from '../../../components/txs/tx-list-navigation'; import { TxsListNavigation } from '../../../components/txs/tx-list-navigation';
import { useSearchParams } from 'react-router-dom';
const BE_TXS_PER_REQUEST = 25;
export const TxsList = () => { export const TxsList = () => {
useDocumentTitle(['Transactions']); useDocumentTitle(['Transactions']);
@@ -27,25 +27,22 @@ export const TxsList = () => {
* @returns {JSX.Element} Transaction List and controls * @returns {JSX.Element} Transaction List and controls
*/ */
export const TxsListFiltered = () => { export const TxsListFiltered = () => {
const [filters, setFilters] = useState(new Set(AllFilterOptions)); const [params] = useSearchParams();
const [filters, setFilters] = useState(getInitialFilters(params));
const f =
filters && filters.size === 1
? `filters[cmd.type]=${Array.from(filters)[0]}`
: '';
const { const {
hasMoreTxs,
nextPage, nextPage,
previousPage, previousPage,
error, error,
refreshTxs, refreshTxs,
loading, loading,
txsData, txsData,
hasPreviousPage, hasMoreTxs,
updateFilters,
} = useTxsData({ } = useTxsData({
limit: BE_TXS_PER_REQUEST, filters: filters.size === 1 ? filters : undefined,
filters: f, before: params.get('before') || undefined,
after: !params.get('before') ? params.get('after') || undefined : undefined,
}); });
return ( return (
@@ -54,11 +51,17 @@ export const TxsListFiltered = () => {
refreshTxs={refreshTxs} refreshTxs={refreshTxs}
nextPage={nextPage} nextPage={nextPage}
previousPage={previousPage} previousPage={previousPage}
hasPreviousPage={hasPreviousPage} hasPreviousPage={true}
loading={loading} loading={loading}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
> >
<TxsFilter filters={filters} setFilters={setFilters} /> <TxsFilter
filters={filters}
setFilters={(f) => {
setFilters(f);
updateFilters(f as Set<FilterOption>);
}}
/>
</TxsListNavigation> </TxsListNavigation>
<TxsInfiniteList <TxsInfiniteList
hasFilters={filters.size > 0} hasFilters={filters.size > 0}
-4
View File
@@ -3,16 +3,13 @@ import {
JsonRpcConnector, JsonRpcConnector,
ViewConnector, ViewConnector,
InjectedConnector, InjectedConnector,
SnapConnector,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
export const rest = new RestConnector(); export const rest = new RestConnector();
export const jsonRpc = new JsonRpcConnector(); export const jsonRpc = new JsonRpcConnector();
export const injected = new InjectedConnector(); export const injected = new InjectedConnector();
export const snap = new SnapConnector();
let view: ViewConnector; let view: ViewConnector;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.hash.split('?')[1]); const urlParams = new URLSearchParams(window.location.hash.split('?')[1]);
view = new ViewConnector(urlParams.get('address')); view = new ViewConnector(urlParams.get('address'));
@@ -25,5 +22,4 @@ export const Connectors = {
rest, rest,
jsonRpc, jsonRpc,
view, view,
snap,
}; };
@@ -7,17 +7,11 @@ import {
VegaIcon, VegaIcon,
VegaIconNames, VegaIconNames,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import type { WalletClientError } from '@vegaprotocol/wallet-client'; import type { WalletClientError } from '@vegaprotocol/wallet-client';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import type { VegaConnector } from '../connectors'; import type { VegaConnector } from '../connectors';
import { import { InjectedConnector } from '../connectors';
InjectedConnector,
SnapConnector,
connectSnap,
defaultSnapOrigin,
getSnap,
} from '../connectors';
import { ViewConnector } from '../connectors'; import { ViewConnector } from '../connectors';
import { JsonRpcConnector, RestConnector } from '../connectors'; import { JsonRpcConnector, RestConnector } from '../connectors';
import { RestConnectorForm } from './rest-connector-form'; import { RestConnectorForm } from './rest-connector-form';
@@ -39,7 +33,7 @@ import { InjectedConnectorForm } from './injected-connector-form';
export const CLOSE_DELAY = 1700; export const CLOSE_DELAY = 1700;
type Connectors = { [key: string]: VegaConnector }; type Connectors = { [key: string]: VegaConnector };
export type WalletType = 'injected' | 'jsonRpc' | 'rest' | 'view' | 'snap'; export type WalletType = 'injected' | 'jsonRpc' | 'rest' | 'view';
export interface VegaConnectDialogProps { export interface VegaConnectDialogProps {
connectors: Connectors; connectors: Connectors;
@@ -158,10 +152,7 @@ const ConnectDialogContainer = ({
// for rest because we need to show an authentication form // for rest because we need to show an authentication form
if (connector instanceof JsonRpcConnector) { if (connector instanceof JsonRpcConnector) {
jsonRpcConnect(connector, appChainId); jsonRpcConnect(connector, appChainId);
} else if ( } else if (connector instanceof InjectedConnector) {
connector instanceof InjectedConnector ||
connector instanceof SnapConnector
) {
injectedConnect(connector, appChainId); injectedConnect(connector, appChainId);
} }
}; };
@@ -204,20 +195,6 @@ const ConnectorList = ({
setWalletUrl: (value: string) => void; setWalletUrl: (value: string) => void;
isMainnet: boolean; isMainnet: boolean;
}) => { }) => {
const [snapInstalled, setSnapInstalled] = useState(false);
useEffect(() => {
const getSnapStatus = async () => {
const result = await getSnap(defaultSnapOrigin);
if (result.enabled) {
setSnapInstalled(true);
} else {
alert('Vega snap not installed');
}
};
getSnapStatus();
}, []);
return ( return (
<> <>
<ConnectDialogTitle>{t('Connect')}</ConnectDialogTitle> <ConnectDialogTitle>{t('Connect')}</ConnectDialogTitle>
@@ -239,32 +216,6 @@ const ConnectorList = ({
/> />
</li> </li>
)} )}
<li className="mb-4 last:mb-0">
{snapInstalled ? (
<ConnectionOption
type="snap"
text={t('MetaMask Snap')}
onClick={() => onSelect('snap')}
/>
) : (
<Button
fill={true}
onClick={async () => {
try {
await connectSnap(defaultSnapOrigin);
const installedSnap = await getSnap(defaultSnapOrigin);
console.log(installedSnap);
setSnapInstalled(true);
} catch (err) {
console.error(err);
}
}}
>
{t('Install Vega MetaMask Snap')}
</Button>
)}
</li>
{!isMainnet && ( {!isMainnet && (
<li className="mb-4 last:mb-0"> <li className="mb-4 last:mb-0">
<ConnectionOption <ConnectionOption
@@ -310,10 +261,7 @@ const SelectedForm = ({
onConnect: () => void; onConnect: () => void;
riskMessage?: React.ReactNode; riskMessage?: React.ReactNode;
}) => { }) => {
if ( if (connector instanceof InjectedConnector) {
connector instanceof InjectedConnector ||
connector instanceof SnapConnector
) {
return ( return (
<InjectedConnectorForm <InjectedConnectorForm
status={injectedState.status} status={injectedState.status}
@@ -20,6 +20,7 @@ export const InjectedConnectorForm = ({
reset, reset,
error, error,
}: { }: {
// connector: JsonRpcConnector;
appChainId: string; appChainId: string;
status: Status; status: Status;
error: Error | null; error: Error | null;
-1
View File
@@ -3,4 +3,3 @@ export * from './rest-connector';
export * from './injected-connector'; export * from './injected-connector';
export * from './json-rpc-connector'; export * from './json-rpc-connector';
export * from './view-connector'; export * from './view-connector';
export * from './snap-connector';
@@ -1,152 +0,0 @@
import type { Transaction, VegaConnector } from './vega-connector';
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ethereum: any;
}
}
export const defaultSnapOrigin = 'local:http://localhost:8080';
// Requests permission for a website to communicate with the specified snaps
// and attempts to install them if they're not already installed.
// If the installation of any snap fails, returns the error that caused the failure.
// More informations here: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_requestsnaps
//
// The snapId when release will be using the following
// pattern: `npm:@vegaprotocol/snapname` (prod snap ID)
// to use the localy running extension use: `local:http://localhost:8080` (local snap ID)
// params can contain an optional version
export const connectSnap = async (
snapId: string,
params: Record<'version' | string, unknown> = {}
) => {
await window.ethereum.request({
method: 'wallet_requestSnaps',
params: {
[snapId]: params,
},
});
};
export type GetSnapsResponse = Record<string, Snap>;
export type Snap = {
permissionName: string;
id: string;
version: string;
initialPermissions: Record<string, unknown>;
};
// the returns the list of installed snaps
// for more infos: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_getsnaps
export const getSnaps = async (): Promise<GetSnapsResponse> => {
return (await window.ethereum.request({
method: 'wallet_getSnaps',
})) as unknown as GetSnapsResponse;
};
// returns the snap informations if connected with this
// webapp.
export const getSnap = async (
snapId: string,
version?: string
): Promise<Snap | undefined> => {
try {
const snaps = await getSnaps();
return Object.values(snaps).find(
(snap) => snap.id === snapId && (!version || snap.version === version)
);
} catch (e) {
return undefined;
}
};
export class SnapConnector implements VegaConnector {
description = "Connects using Vega Protocol's MetaMask snap";
snapId: string = defaultSnapOrigin;
nodeAddress: string | undefined = undefined;
/**
* @param nodeAddress url for the node (without pathname, eg /graphql)
*/
constructor(nodeAddress?: string) {
this.nodeAddress = nodeAddress;
}
async connectWallet() {
return null;
}
async connect() {
const res = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: defaultSnapOrigin,
request: { method: 'client.list_keys' },
},
});
// TODO: setConfig
console.log(res);
return res.keys;
}
async sendTx(pubKey: string, transaction: Transaction) {
if (!this.nodeAddress) throw new Error('nodeAddress not set');
const payload = JSON.parse(
JSON.stringify({
method: 'wallet_invokeSnap',
params: {
snapId: defaultSnapOrigin,
request: {
method: 'client.send_transaction',
params: {
publicKey: pubKey,
transaction,
sendingMode: 'TYPE_SYNC',
networkEndpoints: [this.nodeAddress],
},
},
},
})
);
console.log(payload);
const result = await window.ethereum.request(payload);
console.log(result);
return {
transactionHash: result.transactionHash,
receivedAt: result.receivedAt,
sentAt: result.sentAt,
signature: result.transaction.signature.value,
};
}
async getChainId(): Promise<{ chainID: string }> {
if (!this.nodeAddress) throw new Error('nodeAddress not set');
return await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: defaultSnapOrigin,
request: {
method: 'client.get_chain_id',
networkEndpoint: [this.nodeAddress],
},
},
});
}
// NO-OP
async disconnect() {
return undefined;
}
// async signTransaction(params) {
// throw new Error('signTransaction not supported');
// }
}
+8 -25
View File
@@ -1,7 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { InjectedConnector, SnapConnector } from './connectors'; import type { InjectedConnector } from './connectors';
import { useVegaWallet } from './use-vega-wallet'; import { useVegaWallet } from './use-vega-wallet';
import { useEnvironment } from '@vegaprotocol/environment';
export enum Status { export enum Status {
Idle = 'Idle', Idle = 'Idle',
@@ -13,39 +12,24 @@ export enum Status {
} }
export const useInjectedConnector = (onConnect: () => void) => { export const useInjectedConnector = (onConnect: () => void) => {
const { VEGA_URL } = useEnvironment();
const { connect, acknowledgeNeeded } = useVegaWallet(); const { connect, acknowledgeNeeded } = useVegaWallet();
const [status, setStatus] = useState(Status.Idle); const [status, setStatus] = useState(Status.Idle);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const attemptConnect = useCallback( const attemptConnect = useCallback(
async ( async (connector: InjectedConnector, appChainId: string) => {
connector: InjectedConnector | SnapConnector,
appChainId: string
) => {
try { try {
if (connector instanceof InjectedConnector && !('vega' in window)) { if (!('vega' in window)) {
throw new Error('window.vega not found'); throw new Error('window.vega not found');
} }
if (connector instanceof SnapConnector) {
if (!('ethereum' in window)) {
throw new Error('window.ethereum not found');
}
if (!VEGA_URL) {
throw new Error('no connected node');
}
connector.nodeAddress = new URL(VEGA_URL).origin;
}
setStatus(Status.GettingChainId); setStatus(Status.GettingChainId);
// const { chainID } = await connector.getChainId(); const { chainID } = await connector.getChainId();
// if (chainID !== appChainId) { if (chainID !== appChainId) {
// throw new Error('Invalid chain'); throw new Error('Invalid chain');
// } }
setStatus(Status.Connecting); setStatus(Status.Connecting);
await connector.connectWallet(); // authorize wallet await connector.connectWallet(); // authorize wallet
@@ -58,7 +42,6 @@ export const useInjectedConnector = (onConnect: () => void) => {
onConnect(); onConnect();
} }
} catch (err) { } catch (err) {
console.log(err);
if (err instanceof Error) { if (err instanceof Error) {
setError(err); setError(err);
} else { } else {
@@ -67,7 +50,7 @@ export const useInjectedConnector = (onConnect: () => void) => {
setStatus(Status.Error); setStatus(Status.Error);
} }
}, },
[acknowledgeNeeded, connect, onConnect, VEGA_URL] [acknowledgeNeeded, connect, onConnect]
); );
return { return {