diff --git a/apps/explorer/src/app/components/nav/nav.tsx b/apps/explorer/src/app/components/nav/nav.tsx deleted file mode 100644 index 3cf5129c9..000000000 --- a/apps/explorer/src/app/components/nav/nav.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { NavLink, useLocation } from 'react-router-dom'; -import type { Navigable } from '../../routes/router-config'; -import routerConfig from '../../routes/router-config'; -import classnames from 'classnames'; -import { create } from 'zustand'; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, -} from 'react'; -import { Icon } from '@vegaprotocol/ui-toolkit'; -import first from 'lodash/first'; -import last from 'lodash/last'; -import { BREAKPOINT_MD } from '../../config/breakpoints'; - -type NavStore = { - open: boolean; - toggle: () => void; - hide: () => void; -}; - -export const useNavStore = create((set, get) => ({ - open: false, - toggle: () => set({ open: !get().open }), - hide: () => set({ open: false }), -})); - -const NavLinks = ({ links }: { links: Navigable[] }) => { - const navLinks = links.map((r) => ( -
  • - - classnames( - 'block mb-2 px-2', - 'text-lg hover:bg-vega-pink dark:hover:bg-vega-yellow hover:text-white dark:hover:text-black', - { - 'bg-vega-pink text-white dark:bg-vega-yellow dark:text-black': - isActive, - } - ) - } - > - {r.text} - -
  • - )); - - return ; -}; - -export const Nav = () => { - const [open, hide] = useNavStore((state) => [state.open, state.hide]); - const location = useLocation(); - - const navRef = useRef(null); - const btnRef = useRef(null); - - const focusable = useMemo( - () => - navRef.current - ? [ - ...(navRef.current.querySelectorAll( - 'a, button' - ) as NodeListOf), - ] - : [], - // eslint-disable-next-line react-hooks/exhaustive-deps - [navRef.current] // do not remove `navRef.current` from deps - ); - - const closeNav = useCallback(() => { - hide(); - focusable.forEach((fe) => - fe.setAttribute( - 'tabindex', - window.innerWidth > BREAKPOINT_MD ? '0' : '-1' - ) - ); - }, [focusable, hide]); - - // close navigation when location changes - useEffect(() => { - closeNav(); - }, [closeNav, location]); - - useLayoutEffect(() => { - if (open) { - focusable.forEach((fe) => fe.setAttribute('tabindex', '0')); - } - - document.body.style.overflow = open ? 'hidden' : ''; - const offset = - document.querySelector('header')?.getBoundingClientRect().top || 0; - if (navRef.current) { - navRef.current.style.height = `calc(100vh - ${offset}px)`; - } - - // focus current by default - if (navRef.current && open) { - (navRef.current.querySelector('a[aria-current]') as HTMLElement)?.focus(); - } - - const closeOnEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - closeNav(); - } - }; - - // tabbing loop - const focusLast = (e: FocusEvent) => { - e.preventDefault(); - const isNavElement = - e.relatedTarget && navRef.current?.contains(e.relatedTarget as Node); - if (!isNavElement && open) { - last(focusable)?.focus(); - } - }; - const focusFirst = (e: FocusEvent) => { - e.preventDefault(); - const isNavElement = - e.relatedTarget && navRef.current?.contains(e.relatedTarget as Node); - if (!isNavElement && open) { - first(focusable)?.focus(); - } - }; - - const resetOnDesktop = () => { - focusable.forEach((fe) => - fe.setAttribute( - 'tabindex', - window.innerWidth > BREAKPOINT_MD ? '0' : '-1' - ) - ); - }; - - window.addEventListener('resize', resetOnDesktop); - - first(focusable)?.addEventListener('focusout', focusLast); - last(focusable)?.addEventListener('focusout', focusFirst); - - document.addEventListener('keydown', closeOnEsc); - return () => { - window.removeEventListener('resize', resetOnDesktop); - document.removeEventListener('keydown', closeOnEsc); - first(focusable)?.removeEventListener('focusout', focusLast); - last(focusable)?.removeEventListener('focusout', focusFirst); - }; - }, [closeNav, focusable, open]); - - return ( - - ); -}; diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-signer.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-signer.spec.tsx index 23dca02c0..8dd1b595c 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-signer.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-signer.spec.tsx @@ -5,7 +5,6 @@ import type { components } from '../../../../../types/explorer'; import { MockedProvider } from '@apollo/client/testing'; import { MemoryRouter } from 'react-router-dom'; import { TxDetailsChainMultisigSigner } from './tx-multisig-signer'; -import { getBlockTime } from './lib/get-block-time'; type Added = components['schemas']['vegaERC20SignerAdded']; type Removed = components['schemas']['vegaERC20SignerRemoved']; @@ -61,10 +60,7 @@ describe('Chain Event: multisig signer change', () => { expect(screen.getByText(t('Add signer'))).toBeInTheDocument(); expect(screen.getByText(`${addedMock.newSigner}`)).toBeInTheDocument(); - const expectedDate = getBlockTime(mockBlockTime); - expect(screen.getByText(t('Signer change at'))).toBeInTheDocument(); - expect(screen.getByText(expectedDate)).toBeInTheDocument(); }); it('Renders TableRows if all data is provided', () => { @@ -93,9 +89,6 @@ describe('Chain Event: multisig signer change', () => { expect(screen.getByText(t('Remove signer'))).toBeInTheDocument(); expect(screen.getByText(`${removedMock.oldSigner}`)).toBeInTheDocument(); - const expectedDate = getBlockTime(mockBlockTime); - expect(screen.getByText(t('Signer change at'))).toBeInTheDocument(); - expect(screen.getByText(expectedDate)).toBeInTheDocument(); }); }); diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-threshold.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-threshold.spec.tsx index df51ffee5..de7f31e53 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-threshold.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-multisig-threshold.spec.tsx @@ -6,7 +6,6 @@ import { MockedProvider } from '@apollo/client/testing'; import { MemoryRouter } from 'react-router-dom'; import { TxDetailsChainMultisigThreshold } from './tx-multisig-threshold'; import omit from 'lodash/omit'; -import { getBlockTime } from './lib/get-block-time'; type Threshold = components['schemas']['vegaERC20MultiSigEvent']['thresholdSet']; @@ -74,9 +73,6 @@ describe('Chain Event: multisig threshold change', () => { expect(screen.getByText(t('Threshold'))).toBeInTheDocument(); expect(screen.getByText(`66.7%`)).toBeInTheDocument(); - const expectedDate = getBlockTime(mockBlockTime); - expect(screen.getByText(t('Threshold change date'))).toBeInTheDocument(); - expect(screen.getByText(expectedDate)).toBeInTheDocument(); }); }); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx index b2140c6d5..d4c40e70a 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx @@ -8,14 +8,16 @@ import { BundleError } from './bundle-error'; describe('Bundle Error', () => { const NON_ENABLED_STATUS: AssetStatus[] = [ AssetStatus.STATUS_PENDING_LISTING, + ]; + + const NOT_SHOWN_STATUS: AssetStatus[] = [ AssetStatus.STATUS_PROPOSED, AssetStatus.STATUS_REJECTED, ]; const ENABLED_STATUS: AssetStatus[] = [AssetStatus.STATUS_ENABLED]; - - it.each(NON_ENABLED_STATUS)( - 'shows the apollo error if not enabled and a message is provided', + it.each(NOT_SHOWN_STATUS)( + 'does not render for proposed or rejected bundles', (status) => { const screen = render( @@ -28,7 +30,24 @@ describe('Bundle Error', () => { ); - expect(screen.getByText('test-error-message')).toBeInTheDocument(); + expect(screen.container).toBeEmptyDOMElement(); + } + ); + it.each(NON_ENABLED_STATUS)( + 'shows the apollo error in a syntax highlighter if not enabled and a message is provided', + (status) => { + const screen = render( + + + + + + ); + + expect(screen.getByText('No signature bundle')).toBeInTheDocument(); } ); @@ -43,7 +62,7 @@ describe('Bundle Error', () => { ); - expect(screen.getByText('No bundle for proposal ID')).toBeInTheDocument(); + expect(screen.getByText('No signature bundle')).toBeInTheDocument(); } ); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx index 09fbb4fd3..c4edc894e 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx @@ -33,7 +33,7 @@ export const BundleError = ({ status, error }: BundleErrorProps) => { )}

    -

    +

    {status === 'STATUS_ENABLED' ? ( t('Asset already enabled') ) : ( @@ -43,7 +43,7 @@ export const BundleError = ({ status, error }: BundleErrorProps) => { )} -

    +
    ); }; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx index ce4c7bd99..faeed13a8 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx @@ -32,6 +32,7 @@ describe('Bundle Exists', () => { nonce={MOCK_NONCE} proposalId={MOCK_PROPOSAL_ID} signatures={MOCK_SIGNATURES} + assetAddress={'0x123413423'} status={status} /> @@ -52,6 +53,7 @@ describe('Bundle Exists', () => { nonce={MOCK_NONCE} proposalId={MOCK_PROPOSAL_ID} signatures={MOCK_SIGNATURES} + assetAddress={'0x123413423'} status={status} /> diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx index 6f2215057..01fa8a20c 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx @@ -1,6 +1,5 @@ -import { render } from '@testing-library/react'; import { AssetStatus } from '@vegaprotocol/types'; -import { IconForBundleStatus } from './bundle-icon'; +import { getIcon } from './bundle-icon'; describe('Bundle status icon', () => { const NON_ENABLED_STATUS: AssetStatus[] = [ @@ -15,30 +14,21 @@ describe('Bundle status icon', () => { it.each(NON_ENABLED_STATUS)( 'show a sparkle icon if the bundle is unused', (status) => { - const screen = render(); - const i = screen.getByRole('img'); - expect(i).toHaveAttribute('aria-label'); - expect(i.getAttribute('aria-label')).toMatch(/clean/); + expect(getIcon(status)).toEqual('clean'); } ); it.each(ERROR_STATUS)( 'show an error icon if the bundle is unavailable', (status) => { - const screen = render(); - const i = screen.getByRole('img'); - expect(i).toHaveAttribute('aria-label'); - expect(i.getAttribute('aria-label')).toMatch(/disable/); + expect(getIcon(status)).toEqual('disable'); } ); it.each(ENABLED_STATUS)( 'shows a tick if the bundle is already used', (status) => { - const screen = render(); - const i = screen.getByRole('img'); - expect(i).toHaveAttribute('aria-label'); - expect(i.getAttribute('aria-label')).toMatch(/tick-circle/); + expect(getIcon(status)).toEqual('tick-circle'); } ); }); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx index c5c435f2d..2f68dd15c 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx @@ -14,7 +14,14 @@ export interface IconForBundleStatusProps { export const IconForBundleStatus = ({ status }: IconForBundleStatusProps) => { const i = getIcon(status); - return ; + return ( + + ); }; export function getIcon(status?: AssetStatus): IconName { @@ -22,6 +29,7 @@ export function getIcon(status?: AssetStatus): IconName { case 'STATUS_ENABLED': return 'tick-circle'; case undefined: + case 'STATUS_REJECTED': return 'disable'; default: return 'clean'; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.spec.tsx new file mode 100644 index 000000000..0750bc6e6 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.spec.tsx @@ -0,0 +1,94 @@ +import type { EncodeListAssetParameters } from '../../../../../lib/encoders/abis/list-asset'; +import type { BridgeFunction } from './bundle-signers'; +import { + getBridgeAddressFromNetworkParameter, + getSigners, +} from './bundle-signers'; + +describe('Bundle Signers helpers', () => { + it('getBridgeAddressFromNetworkParameter handles invalid json', () => { + expect(getBridgeAddressFromNetworkParameter('hi')).toEqual(null); + expect(getBridgeAddressFromNetworkParameter('{hi]')).toEqual(null); + expect(getBridgeAddressFromNetworkParameter('{"hi"}')).toEqual(null); + expect( + getBridgeAddressFromNetworkParameter(false as unknown as string) + ).toEqual(null); + }); + + it('getBridgeAddressFromNetworkParameter returns null if bridge adderss is not in expected place', () => { + expect( + getBridgeAddressFromNetworkParameter(`{ + "NetworkParamter": false + }`) + ).toEqual(null); + + expect( + getBridgeAddressFromNetworkParameter(`{ + "network_id": "11155111", + "chain_id": "11155111", + "confirmations": 3, + "staking_bridge_contract": { + "address": "0xFFb0A0d4806502ceF491aF1141f66669A1Bd0D03", + "deployment_block_height": 2011705 + }, + "token_vesting_contract": { + "address": "0x680fF88252FA7071CAce7398e77872d54D781d0B", + "deployment_block_height": 2011709 + }, + "multisig_control_contract": { + "address": "0x6eBc32d66277D94DB8FF2ccF86E36f37F29a52D3", + "deployment_block_height": 2011699 + } + }`) + ).toEqual(null); + }); + + it('getBridgeAddressFromNetworkParameter returns address if the collateral_bridge_contract has an address', () => { + expect( + getBridgeAddressFromNetworkParameter(`{ + "network_id": "11155111", + "chain_id": "11155111", + "confirmations": 3, + "collateral_bridge_contract": { + "address": "0x7fe27d970bc8Afc3B11Cc8d9737bfB66B1efd799" + }, + "staking_bridge_contract": { + "address": "0xFFb0A0d4806502ceF491aF1141f66669A1Bd0D03", + "deployment_block_height": 2011705 + }, + "token_vesting_contract": { + "address": "0x680fF88252FA7071CAce7398e77872d54D781d0B", + "deployment_block_height": 2011709 + }, + "multisig_control_contract": { + "address": "0x6eBc32d66277D94DB8FF2ccF86E36f37F29a52D3", + "deployment_block_height": 2011699 + } + }`) + ).toEqual('0x7fe27d970bc8Afc3B11Cc8d9737bfB66B1efd799'); + }); + + it('getSigners to return [] in the case of bad inputs', () => { + expect( + getSigners('list_asset', '123', '', { + assetERC20: '123', + assetId: '456', + limit: 'bad', + threshold: 'data', + nonce: 'here', + }) + ).toEqual([]); + + expect( + getSigners('nothing' as unknown as BridgeFunction, '123', '', { + nonce: 'here', + } as unknown as EncodeListAssetParameters) + ).toEqual([]); + + expect( + getSigners('set_asset_limits', '0x123', '0x456', { + nonce: 'here', + } as unknown as EncodeListAssetParameters) + ).toEqual([]); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.tsx index 3936dadc7..154a717e8 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-signers.tsx @@ -6,8 +6,13 @@ import { DApp, TOKEN_VALIDATOR, useLinks } from '@vegaprotocol/environment'; import { ExternalLink, Icon } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; import { IconNames } from '@blueprintjs/icons'; -import { prepend0x } from '@vegaprotocol/smart-contracts'; import { encodeUpdateAssetBridgeTx } from '../../../../../lib/encoders/abis/update-asset'; +import { prepend0x } from '@vegaprotocol/smart-contracts'; +import type { EncodeListAssetParameters } from '../../../../../lib/encoders/abis/list-asset'; + +import omit from 'lodash/omit'; + +export type BridgeFunction = 'list_asset' | 'set_asset_limits'; export interface BundleSignersProps { signatures: string; @@ -32,17 +37,13 @@ export const BundleSigners = ({ }: BundleSignersProps) => { const tokenLink = useLinks(DApp.Token); - const bridgeFunction = + const bridgeFunction: BridgeFunction = tx?.changes?.erc20 && 'contractAddress' in tx.changes.erc20 ? 'list_asset' : 'set_asset_limits'; const { data } = useExplorerBundleSignersQuery(); - if (!id || !tx || !tx.changes?.erc20) { - return null; - } - const bridgeAddress = getBridgeAddressFromNetworkParameter( data?.networkParameter?.value ); @@ -52,40 +53,34 @@ export const BundleSigners = ({ ?.filter((n) => n?.node.status === 'NODE_STATUS_VALIDATOR') .map((s) => s?.node) || []; + if (!tx || !tx.changes?.erc20) { + return null; + } + const { lifetimeLimit, withdrawThreshold } = tx.changes.erc20; if ( + !id || allEthereumKeys.length === 0 || - bridgeAddress === null || + !bridgeAddress || !lifetimeLimit || !withdrawThreshold ) { return null; } - const digest = - bridgeFunction === 'list_asset' - ? encodeListAssetBridgeTx( - { - assetERC20: assetAddress, - assetId: prepend0x(id), - limit: lifetimeLimit, - threshold: withdrawThreshold, - nonce, - }, - bridgeAddress - ) - : encodeUpdateAssetBridgeTx( - { - assetERC20: assetAddress, - limit: lifetimeLimit, - threshold: withdrawThreshold, - nonce, - }, - bridgeAddress - ); - - const signersLowerCase = recoverAddressesFromDigest(digest, signatures); + const signersLowerCase = getSigners( + bridgeFunction, + bridgeAddress, + signatures, + { + assetERC20: assetAddress, + assetId: prepend0x(id), + limit: lifetimeLimit, + threshold: withdrawThreshold, + nonce, + } + ); return ( <> @@ -122,6 +117,42 @@ export const BundleSigners = ({ ); }; +/** + * Given all of the collated information, this function creates an equivalent unsigned bundle + * and recovers the signers from it, In the case of an error, it returns an empty array. + * + * @param bridgeFunction Decides which data goes in to the digest + * @param bridgeAddress ERC20 bridge address + * @param signatures Long string of signatures + * @param params The object containing all data that the bridge requires for New or Updating assets + * @returns String[] Empty if there was an error or no signers were recovered, otherwise lowercased ETH addresses + */ +export function getSigners( + bridgeFunction: BridgeFunction, + bridgeAddress: string, + signatures: string, + params: EncodeListAssetParameters +): string[] { + try { + if (bridgeFunction === 'list_asset') { + const digest = encodeListAssetBridgeTx(params, bridgeAddress); + + // Recover Address from digest can return null, which is handled as an empty array + return recoverAddressesFromDigest(digest, signatures) || []; + } else { + // The params bundles are so similar, rather than force the component to make two different + // styles, just delete the one different property + const p = omit(params, 'assetId'); + const digest = encodeUpdateAssetBridgeTx(p, bridgeAddress); + return recoverAddressesFromDigest(digest, signatures) || []; + } + } catch (e) { + // In the worst case, no signing addresses are recovered. This means that all nodes will + // be rendered as if they had not signed the bundle. + return []; + } +} + /** * Querying for the network parameter value gets us all of the contract details for this network * encoded as a JSON object. This function pulls out the address for the bridge, or returns null @@ -130,7 +161,7 @@ export const BundleSigners = ({ * @param networkParameterAsString the stringified JSON object * @returns null or bridge address as a string */ -function getBridgeAddressFromNetworkParameter( +export function getBridgeAddressFromNetworkParameter( networkParameterAsString: string | undefined ): string | null { if (!networkParameterAsString) { @@ -146,7 +177,10 @@ function getBridgeAddressFromNetworkParameter( } } -function recoverAddressesFromDigest(digest: string, unprefixedBundle: string) { +export function recoverAddressesFromDigest( + digest: string, + unprefixedBundle: string +) { // Remove 0x from bundle, then split it in to signatures const sigs = unprefixedBundle.substring(2).match(/.{1,130}/g); diff --git a/apps/explorer/src/app/lib/encoders/abis/bridge-command.ts b/apps/explorer/src/app/lib/encoders/abis/bridge-command.ts index ad2631e0e..8cd5dab24 100644 --- a/apps/explorer/src/app/lib/encoders/abis/bridge-command.ts +++ b/apps/explorer/src/app/lib/encoders/abis/bridge-command.ts @@ -1,4 +1,4 @@ -import { keccak256, defaultAbiCoder } from 'ethers/lib/utils'; +import { keccak256, defaultAbiCoder, isAddress } from 'ethers/lib/utils'; import type { AbiType } from './abi-types'; export const BRIDGE_COMMAND: AbiType[] = [ @@ -14,14 +14,13 @@ export const BRIDGE_COMMAND: AbiType[] = [ * @param bytes The packed bytes of the command for the bridge * @param address the Ethereum address of the ERC20 bridge * @param raw defaults to false. If set, does not keccak256 the output - * @returns */ export function encodeBridgeCommand( bytes: string, address: string, raw = false ) { - if (address.substring(0, 2) !== '0x') { + if (!isAddress(address)) { throw new Error('Bridge address must be a hex value'); } diff --git a/apps/explorer/src/app/lib/encoders/abis/bridge-commands.spec.ts b/apps/explorer/src/app/lib/encoders/abis/bridge-commands.spec.ts new file mode 100644 index 000000000..436ad8e9e --- /dev/null +++ b/apps/explorer/src/app/lib/encoders/abis/bridge-commands.spec.ts @@ -0,0 +1,31 @@ +import { encodeBridgeCommand } from './bridge-command'; + +describe('Bridge command encoder', () => { + const VALID_BYTES = + '0x000000000000000000000000b063f5504610ba4b8db230d9f884bfadc1e31da00b87ac58d4af7fc11c8b417153fcb62631cfd9643835ef28db3f5a1caef0b3790000000000000000000000000000000000000000000000487a9a30453944000000000000000000000000000000000000000000000000000000000000000000010b87ac58d4af7fc11c8b417153fcb62631cfd9643835ef28db3f5a1caef0b37900000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000a6c6973745f617373657400000000000000000000000000000000000000000000'; + const VALID_ADDRESS = '0x7fe27d970bc8Afc3B11Cc8d9737bfB66B1efd799'; + + it('rejects non valid bridge addresses', () => { + expect(() => { + encodeBridgeCommand(VALID_BYTES, '456789'); + }).toThrowError('Bridge address must be a hex value'); + }); + + it('throws if the bytes are not bytes-like', () => { + expect(() => { + encodeBridgeCommand('hello', VALID_ADDRESS); + }).toThrowError(/invalid/); + }); + + it('keccac256s the value by default', () => { + const res = encodeBridgeCommand(VALID_BYTES, VALID_ADDRESS); + // Magic number: Known output, including 0x + expect(res.length).toEqual(66); + }); + + it('Does not keccac256 the value if third param is set', () => { + const res = encodeBridgeCommand(VALID_BYTES, VALID_ADDRESS, true); + // Magic number: Known output + expect(res.length).toEqual(706); + }); +}); diff --git a/apps/explorer/src/app/lib/encoders/abis/list-asset.spec.ts b/apps/explorer/src/app/lib/encoders/abis/list-asset.spec.ts new file mode 100644 index 000000000..8b64f8009 --- /dev/null +++ b/apps/explorer/src/app/lib/encoders/abis/list-asset.spec.ts @@ -0,0 +1,76 @@ +import { encodeListAsset, encodeListAssetBridgeTx } from './list-asset'; + +describe('List Asset ABI encoder', () => { + it('throws if asset erc20 address is invalid', () => { + expect(() => { + encodeListAsset({ + assetERC20: '123', + assetId: '0x456', + limit: '1', + threshold: '1', + nonce: '1', + }); + }).toThrowError('Asset ERC20 and assetID must be hex values'); + }); + + it('throws if assetId is not hex encoded', () => { + expect(() => { + encodeListAsset({ + assetERC20: '0x123', + assetId: '456', + limit: '1', + threshold: '1', + nonce: '1', + }); + }).toThrowError('Asset ERC20 and assetID must be hex values'); + }); + + it('throws if values to not match expected format', () => { + expect(() => { + encodeListAsset({ + assetERC20: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + assetId: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + limit: 'not a valid number', + threshold: '1', + nonce: '1', + }); + }).toThrowError(/incorrect data length/); + }); + + it('returns an ABI encoded value if inputs are valid', () => { + const EXPECTED_OUTPUT = + '0x000000000000000000000000b063f5504610ba4b8db230d9f884bfadc1e31da00b87ac58d4af7fc11c8b417153fcb62631cfd9643835ef28db3f5a1caef0b37900000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000a6c6973745f617373657400000000000000000000000000000000000000000000'; + + const res = encodeListAsset({ + assetERC20: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + assetId: + '0x0b87ac58d4af7fc11c8b417153fcb62631cfd9643835ef28db3f5a1caef0b379', + limit: '1', + threshold: '1', + nonce: '1', + }); + + expect(res).toEqual(EXPECTED_OUTPUT); + }); + + it('encodeListAssetBridge returns a keccak256 hash of the bridge tx', () => { + const EXPECTED_OUTPUT = + '0xe0e62b27fe4490025d312bb2e37486f56935a3d9442dc34c2b918b2a28a386f2'; + + const res = encodeListAssetBridgeTx( + { + assetERC20: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + assetId: + '0x0b87ac58d4af7fc11c8b417153fcb62631cfd9643835ef28db3f5a1caef0b379', + limit: '1', + threshold: '1', + nonce: '1', + }, + '0xb063f5504610ba4b8db230d9f884bfadc1e31da0' + ); + + // Magic number: keccak256 hash length + '0x' + expect(res.length).toEqual(66); + expect(res).toEqual(EXPECTED_OUTPUT); + }); +}); diff --git a/apps/explorer/src/app/lib/encoders/abis/list-asset.ts b/apps/explorer/src/app/lib/encoders/abis/list-asset.ts index dc954f4b6..c40f003cb 100644 --- a/apps/explorer/src/app/lib/encoders/abis/list-asset.ts +++ b/apps/explorer/src/app/lib/encoders/abis/list-asset.ts @@ -1,4 +1,4 @@ -import { defaultAbiCoder } from 'ethers/lib/utils'; +import { defaultAbiCoder, isAddress, isHexString } from 'ethers/lib/utils'; import { encodeBridgeCommand } from './bridge-command'; import type { AbiType } from './abi-types'; @@ -33,7 +33,13 @@ export interface EncodeListAssetParameters { } /** - * Generates an ABI encoded function call to list an asset + * Generates an ABI encoded function call to list an asset. This is + * used in the Signature Bundle view on some proposals to recover + * which validators signed a multisig bundle. It does this by recovering + * the ERC20 addresses of the signers, then comparing those to the list + * of signers on the bundle. In order to do this, we recreate the signed + * data from the values we know from the transaction. That last part + * is what this function does. * * @param EncodeListAssetParameters The arguments for the ABI call * @returns string encoded message @@ -45,7 +51,7 @@ export function encodeListAsset({ threshold, nonce, }: EncodeListAssetParameters) { - if (assetERC20.substring(0, 2) !== '0x' || assetId.substring(0, 2) !== '0x') { + if (!isAddress(assetERC20) || !isHexString(assetId)) { throw new Error('Asset ERC20 and assetID must be hex values'); } diff --git a/apps/explorer/src/app/lib/encoders/abis/update-asset.spec.ts b/apps/explorer/src/app/lib/encoders/abis/update-asset.spec.ts new file mode 100644 index 000000000..5e1485104 --- /dev/null +++ b/apps/explorer/src/app/lib/encoders/abis/update-asset.spec.ts @@ -0,0 +1,58 @@ +import { encodeUpdateAsset, encodeUpdateAssetBridgeTx } from './update-asset'; + +describe('Update Asset ABI encoder', () => { + it('throws if asset erc20 address is invalid', () => { + expect(() => { + encodeUpdateAsset({ + assetERC20: '123', + limit: '1', + threshold: '1', + nonce: '1', + }); + }).toThrowError('Asset ERC20 must be a valid address'); + }); + + it('throws if an input is invalid', () => { + expect(() => { + encodeUpdateAsset({ + assetERC20: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + limit: 'hello', + threshold: '1', + nonce: '1', + }); + }).toThrowError(/invalid BigNumber/); + }); + + it('returns an ABI encoded value if inputs are valid', () => { + const EXPECTED_OUTPUT = + '0x000000000000000000000000b063f5504610ba4b8db230d9f884bfadc1e31da000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000107365745f61737365745f6c696d69747300000000000000000000000000000000'; + + const res = encodeUpdateAsset({ + assetERC20: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + limit: '1', + threshold: '1', + nonce: '1', + }); + + expect(res).toEqual(EXPECTED_OUTPUT); + }); + + it('encodeUpdateAssetBridge returns a keccak256 hash of the bridge tx', () => { + const EXPECTED_OUTPUT = + '0xeb240131c4558aebfab3da0ddbea1ac0447b9f5670899af2d78795867631d877'; + + const res = encodeUpdateAssetBridgeTx( + { + assetERC20: '0xb063f5504610ba4b8db230d9f884bfadc1e31da0', + limit: '1', + threshold: '1', + nonce: '1', + }, + '0xb063f5504610ba4b8db230d9f884bfadc1e31da0' + ); + + // Magic number: keccak256 hash length + '0x' + expect(res.length).toEqual(66); + expect(res).toEqual(EXPECTED_OUTPUT); + }); +}); diff --git a/apps/explorer/src/app/lib/encoders/abis/update-asset.ts b/apps/explorer/src/app/lib/encoders/abis/update-asset.ts index 7884c25c3..728fee6e6 100644 --- a/apps/explorer/src/app/lib/encoders/abis/update-asset.ts +++ b/apps/explorer/src/app/lib/encoders/abis/update-asset.ts @@ -1,4 +1,4 @@ -import { defaultAbiCoder } from 'ethers/lib/utils'; +import { defaultAbiCoder, isAddress } from 'ethers/lib/utils'; import { encodeBridgeCommand } from './bridge-command'; import type { AbiType } from './abi-types'; @@ -40,8 +40,8 @@ export function encodeUpdateAsset({ threshold, nonce, }: EncodeUpdateAssetParameters) { - if (assetERC20.substring(0, 2) !== '0x') { - throw new Error('Asset ERC20 must be hex values'); + if (!isAddress(assetERC20)) { + throw new Error('Asset ERC20 must be a valid address'); } const values = [assetERC20, limit, threshold, nonce, METHOD_NAME];