Compare commits

..
19 changed files with 380 additions and 319 deletions
-28
View File
@@ -1,28 +0,0 @@
---
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,6 +10,7 @@ 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';
@@ -99,13 +100,15 @@ export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => {
<DropdownMenu <DropdownMenu
modal={false} modal={false}
trigger={ trigger={
<DropdownMenuTrigger> <DropdownMenuTrigger className="ml-0">
<FilterLabel filters={filters} /> <Button size="xs" data-testid="filter-trigger">
<FilterLabel filters={filters} />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
} }
> >
<DropdownMenuContent> <DropdownMenuContent>
{filters.size > 1 ? null : ( {filters.size > 0 ? 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} disabled={!hasMoreTxs || loading}
onClick={() => { onClick={() => {
nextPage(); nextPage();
}} }}
@@ -86,18 +86,14 @@ describe('Txs infinite list item', () => {
render( render(
<MockedProvider> <MockedProvider>
<MemoryRouter> <MemoryRouter>
<table> <TxsInfiniteListItem
<tbody> type="testType"
<TxsInfiniteListItem submitter="testPubKey"
type="testType" hash="testTxHash"
submitter="testPubKey" block="1"
hash="testTxHash" code={0}
block="1" command={{}}
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 32; return 64;
} else if (['xl', 'lg', 'md'].includes(screen)) { } else if (['xl', 'lg', 'md'].includes(screen)) {
return 16; return 32;
} }
return DEFAULT_TRUNCATE_LENGTH; return DEFAULT_TRUNCATE_LENGTH;
} }
@@ -5,17 +5,11 @@ 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: generateHash(), hash: '0F8B98DA0923A50786B852D9CA11E051CACC4C733E1DB93D535C7D81DBD10F6F',
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/get-txs-data-url'; import { getTxsDataUrl } from '../../hooks/use-txs-data';
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({ filters, count: txCount }); const url = getTxsDataUrl({ limit: txCount.toString(), filters });
const { const {
state: { data, loading, error }, state: { data, loading, error },
} = useFetch<BlockExplorerTransactions>(url); } = useFetch<BlockExplorerTransactions>(url);
@@ -1,59 +0,0 @@
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;
};
@@ -1,48 +0,0 @@
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);
});
});
+92 -103
View File
@@ -1,141 +1,130 @@
import { useSearchParams } from 'react-router-dom'; import { useCallback, useEffect, useState } from 'react';
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 function getTypeFilters(filters?: Set<FilterOption>) { export interface TxsStateProps {
if (!filters) { txsData: BlockExplorerTransactionResult[];
return ''; hasMoreTxs: boolean;
} else if (filters.size > 1) { cursor: string;
return ''; previousCursors: string[];
} hasPreviousPage: boolean;
const forcedSingleFilter = Array.from(filters)[0];
return `filters[cmd.type]=${forcedSingleFilter}`;
} }
export interface IUseTxsData { export interface IUseTxsData {
count?: number; limit: number;
before?: string; filters?: string;
after?: string;
party?: string;
filters?: Set<FilterOption>;
} }
export const useTxsData = ({ interface IGetTxsDataUrl {
count = 25, limit: string;
before, filters?: string;
after, }
filters,
party,
}: IUseTxsData) => {
const [, setSearchParams] = useSearchParams();
let hasMoreTxs = true;
let txsData: BlockExplorerTransactionResult[] = [];
const url = getTxsDataUrl({ export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => {
filters: getTypeFilters(filters), const url = new URL(`${DATA_SOURCES.blockExplorerUrl}/transactions`);
count,
before, if (limit) {
after, url.searchParams.append('limit', limit);
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);
if (!loading && data && isNumber(data.transactions.length)) { useEffect(() => {
hasMoreTxs = data.transactions.length >= count; if (!loading && data && isNumber(data.transactions.length)) {
txsData = data.transactions; setTxsState((prev) => {
} 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 after = data?.transactions.at(-1)?.cursor || ''; const c = data?.transactions.at(0)?.cursor;
const params: URLSearchParamsInit = { after }; const newPreviousCursors = c ? [...previousCursors, c] : previousCursors;
if (filters) {
params.filters = Array.from(filters).join(','); setTxsState((prev) => ({
} ...prev,
setSearchParams(params); hasPreviousPage: true,
}, [filters, data, setSearchParams]); previousCursors: newPreviousCursors,
}));
return refetch({
limit,
before: cursor,
});
}, [data, previousCursors, cursor, limit, refetch]);
const previousPage = useCallback(() => { const previousPage = useCallback(() => {
const before = data?.transactions[0]?.cursor || ''; const previousCursor = [...previousCursors].pop();
const params: URLSearchParamsInit = { before }; const newPreviousCursors = previousCursors.slice(0, -1);
if (filters && filters.size > 0 && filters.size === 1) { setTxsState((prev) => ({
params.filters = Array.from(filters)[0]; ...prev,
} hasPreviousPage: newPreviousCursors.length > 0,
setSearchParams(params); previousCursors: newPreviousCursors,
}, [filters, data, setSearchParams]); }));
return refetch({
limit,
before: previousCursor,
});
}, [previousCursors, limit, refetch]);
const refreshTxs = useCallback(async () => { const refreshTxs = useCallback(async () => {
const params: URLSearchParamsInit = {}; setTxsState(() => ({
if (filters && filters.size > 0 && filters.size === 1) { txsData: [],
params.filters = Array.from(filters)[0]; cursor: '',
} previousCursors: [],
setSearchParams(params); hasMoreTxs: false,
hasPreviousPage: false,
}));
refetch({ count: BE_TXS_PER_REQUEST }); refetch({ limit });
}, [setSearchParams, refetch, filters]); }, [setTxsState, limit, refetch, filters]); // eslint-disable-line react-hooks/exhaustive-deps
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,7 +204,6 @@ 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,13 +15,9 @@ 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 }>();
@@ -31,21 +27,24 @@ 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,
hasMoreTxs, hasPreviousPage,
updateFilters,
} = useTxsData({ } = useTxsData({
filters: filters.size === 1 ? filters : undefined, limit: 25,
before: params.get('before') || undefined, filters: f,
after: !params.get('before') ? params.get('after') || undefined : undefined,
party: partyId,
}); });
const variables = useMemo(() => ({ partyId }), [partyId]); const variables = useMemo(() => ({ partyId }), [partyId]);
@@ -103,21 +102,15 @@ const Party = () => {
refreshTxs={refreshTxs} refreshTxs={refreshTxs}
nextPage={nextPage} nextPage={nextPage}
previousPage={previousPage} previousPage={previousPage}
hasPreviousPage={true} hasPreviousPage={hasPreviousPage}
loading={loading} loading={loading}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
> >
<TxsFilter <TxsFilter filters={filters} setFilters={setFilters} />
filters={filters}
setFilters={(f) => {
setFilters(f);
updateFilters(f as Set<FilterOption>);
}}
/>
</TxsListNavigation> </TxsListNavigation>
{!error && txsData ? ( {!error && txsData ? (
<TxsInfiniteList <TxsInfiniteList
hasMoreTxs={true} hasMoreTxs={hasMoreTxs}
areTxsLoading={loading} areTxsLoading={loading}
txs={txsData} txs={txsData}
loadMoreTxs={nextPage} loadMoreTxs={nextPage}
+16 -19
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, getInitialFilters } from '../../../hooks/use-txs-data'; import { useTxsData } 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 { TxsFilter } from '../../../components/txs/tx-filter'; import { AllFilterOptions, 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,22 +27,25 @@ export const TxsList = () => {
* @returns {JSX.Element} Transaction List and controls * @returns {JSX.Element} Transaction List and controls
*/ */
export const TxsListFiltered = () => { export const TxsListFiltered = () => {
const [params] = useSearchParams(); const [filters, setFilters] = useState(new Set(AllFilterOptions));
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,
hasMoreTxs, hasPreviousPage,
updateFilters,
} = useTxsData({ } = useTxsData({
filters: filters.size === 1 ? filters : undefined, limit: BE_TXS_PER_REQUEST,
before: params.get('before') || undefined, filters: f,
after: !params.get('before') ? params.get('after') || undefined : undefined,
}); });
return ( return (
@@ -51,17 +54,11 @@ export const TxsListFiltered = () => {
refreshTxs={refreshTxs} refreshTxs={refreshTxs}
nextPage={nextPage} nextPage={nextPage}
previousPage={previousPage} previousPage={previousPage}
hasPreviousPage={true} hasPreviousPage={hasPreviousPage}
loading={loading} loading={loading}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
> >
<TxsFilter <TxsFilter filters={filters} setFilters={setFilters} />
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,13 +3,16 @@ 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'));
@@ -22,4 +25,5 @@ export const Connectors = {
rest, rest,
jsonRpc, jsonRpc,
view, view,
snap,
}; };
@@ -7,11 +7,17 @@ import {
VegaIcon, VegaIcon,
VegaIconNames, VegaIconNames,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, 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 { InjectedConnector } from '../connectors'; import {
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';
@@ -33,7 +39,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'; export type WalletType = 'injected' | 'jsonRpc' | 'rest' | 'view' | 'snap';
export interface VegaConnectDialogProps { export interface VegaConnectDialogProps {
connectors: Connectors; connectors: Connectors;
@@ -152,7 +158,10 @@ 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 (connector instanceof InjectedConnector) { } else if (
connector instanceof InjectedConnector ||
connector instanceof SnapConnector
) {
injectedConnect(connector, appChainId); injectedConnect(connector, appChainId);
} }
}; };
@@ -195,6 +204,20 @@ 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>
@@ -216,6 +239,32 @@ 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
@@ -261,7 +310,10 @@ const SelectedForm = ({
onConnect: () => void; onConnect: () => void;
riskMessage?: React.ReactNode; riskMessage?: React.ReactNode;
}) => { }) => {
if (connector instanceof InjectedConnector) { if (
connector instanceof InjectedConnector ||
connector instanceof SnapConnector
) {
return ( return (
<InjectedConnectorForm <InjectedConnectorForm
status={injectedState.status} status={injectedState.status}
@@ -20,7 +20,6 @@ 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,3 +3,4 @@ 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';
@@ -0,0 +1,152 @@
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');
// }
}
+25 -8
View File
@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { InjectedConnector } from './connectors'; import { InjectedConnector, SnapConnector } 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',
@@ -12,24 +13,39 @@ 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 (connector: InjectedConnector, appChainId: string) => { async (
connector: InjectedConnector | SnapConnector,
appChainId: string
) => {
try { try {
if (!('vega' in window)) { if (connector instanceof InjectedConnector && !('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
@@ -42,6 +58,7 @@ 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 {
@@ -50,7 +67,7 @@ export const useInjectedConnector = (onConnect: () => void) => {
setStatus(Status.Error); setStatus(Status.Error);
} }
}, },
[acknowledgeNeeded, connect, onConnect] [acknowledgeNeeded, connect, onConnect, VEGA_URL]
); );
return { return {