Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"build": "yarn workspace app build",
"start": "yarn workspace app start",
"audit:ci": "audit-ci --config ./audit-ci.jsonc",
"test:ci": "yarn workspace arb-token-bridge-ui test:ci",
"test:ci": "yarn workspace arb-token-bridge-ui test:ci && yarn workspace arb-token-bridge-ui test:integration",
"test:integration": "yarn workspace arb-token-bridge-ui test:integration",
"prettier:check": "./node_modules/.bin/prettier --check .",
"prettier:format": "./node_modules/.bin/prettier --write .",
"lint": "tsc && eslint",
Expand Down
86 changes: 86 additions & 0 deletions packages/app/src/utils/__tests__/sanitizeAndRedirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { PathnameEnum } from '@/bridge/constants';

import { initializeBridgePage } from '../bridgePageUtils';

const { redirectMock, registerLocalNetworkMock } = vi.hoisted(() => {
process.env.NEXT_PUBLIC_INFURA_KEY ||= 'test-infura-key';
process.env.NEXT_PUBLIC_FEATURE_FLAG_LIFI = 'true';

return {
redirectMock: vi.fn(),
registerLocalNetworkMock: vi.fn(async () => undefined),
};
});

vi.mock('next/navigation', () => ({
redirect: redirectMock,
}));

vi.mock('@/bridge/util/networks', async (importActual) => {
const actual = await importActual<typeof import('@/bridge/util/networks')>();

return {
...actual,
registerLocalNetwork: registerLocalNetworkMock,
};
});

const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';

function getRedirectedUrl() {
const [redirectTarget] = redirectMock.mock.calls.at(-1) ?? [];

if (!redirectTarget) {
throw new Error('Expected redirect to be called.');
}

return new URL(redirectTarget, 'https://example.com');
}

describe('initializeBridgePage sanitization', () => {
beforeEach(() => {
redirectMock.mockClear();
registerLocalNetworkMock.mockClear();
});

it('sets destinationToken to zero address for first-load apechain -> superposition', async () => {
await initializeBridgePage({
searchParams: {
sourceChain: 'apechain',
destinationChain: 'superposition',
},
redirectPath: PathnameEnum.BRIDGE,
});

expect(redirectMock).toHaveBeenCalledTimes(1);
const redirected = getRedirectedUrl();

expect(redirected.pathname).toBe(PathnameEnum.BRIDGE);
expect(redirected.searchParams.get('sourceChain')).toBe('apechain');
expect(redirected.searchParams.get('destinationChain')).toBe('superposition');
expect(redirected.searchParams.get('destinationToken')).toBe(ZERO_ADDRESS);
expect(redirected.searchParams.get('sanitized')).toBe('true');
});

it('sets token and destinationToken to zero address for first-load superposition -> apechain', async () => {
await initializeBridgePage({
searchParams: {
sourceChain: 'superposition',
destinationChain: 'apechain',
},
redirectPath: PathnameEnum.BRIDGE,
});

expect(redirectMock).toHaveBeenCalledTimes(1);
const redirected = getRedirectedUrl();

expect(redirected.pathname).toBe(PathnameEnum.BRIDGE);
expect(redirected.searchParams.get('sourceChain')).toBe('superposition');
expect(redirected.searchParams.get('destinationChain')).toBe('apechain');
expect(redirected.searchParams.get('token')).toBe(ZERO_ADDRESS);
expect(redirected.searchParams.get('destinationToken')).toBe(ZERO_ADDRESS);
expect(redirected.searchParams.get('sanitized')).toBe('true');
});
});
29 changes: 24 additions & 5 deletions packages/app/src/utils/bridgePageUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { redirect } from 'next/navigation';

import { onrampServices } from '@/bridge/components/BuyPanel/utils';
import { PathnameEnum } from '@/bridge/constants';

import { addOrbitChainsToArbitrumSDK } from '../initialization';
import { sanitizeAndRedirect } from './sanitizeAndRedirect';
import { getSanitizedRedirectPath } from './sanitizeAndRedirect';

export type Slug = (typeof onrampServices)[number]['slug'];

Expand All @@ -11,14 +13,31 @@ export interface BridgePageProps {
redirectPath: PathnameEnum | `${PathnameEnum.BUY}/${Slug}` | `${PathnameEnum.EMBED_BUY}/${Slug}`;
}

export async function initializeBridgePage({ searchParams, redirectPath }: BridgePageProps) {
export async function getBridgePageSanitizedRedirectPath({
searchParams,
redirectPath,
}: BridgePageProps) {
/**
* This code is run on every query param change,
* we don't want to sanitize every query param change.
* It should only be executed once per user per session.
*/
if (searchParams.sanitized !== 'true') {
addOrbitChainsToArbitrumSDK();
await sanitizeAndRedirect(searchParams, redirectPath);
if (searchParams.sanitized === 'true') {
return null;
}

addOrbitChainsToArbitrumSDK();

return getSanitizedRedirectPath(searchParams, redirectPath);
}

export async function initializeBridgePage({ searchParams, redirectPath }: BridgePageProps) {
const sanitizedRedirectPath = await getBridgePageSanitizedRedirectPath({
searchParams,
redirectPath,
});

if (sanitizedRedirectPath) {
redirect(sanitizedRedirectPath);
}
}
54 changes: 42 additions & 12 deletions packages/app/src/utils/sanitizeAndRedirect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { constants } from 'ethers';
import { redirect } from 'next/navigation';

import { ChainId } from '@/bridge/types/ChainId';
import { sanitizeExperimentalFeaturesQueryParam } from '@/bridge/util';
import { isE2eTestingEnvironment, isProductionEnvironment } from '@/bridge/util/CommonUtils';
import { logger } from '@/bridge/util/logger';
Expand Down Expand Up @@ -99,6 +101,19 @@ export async function sanitizeAndRedirect(
[key: string]: string | string[] | undefined;
},
baseUrl: string,
) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was called on the server, integration test is skipping it. So this util was extracted (without the redirect) to be called in integration test.

const redirectPath = await getSanitizedRedirectPath(searchParams, baseUrl);

if (redirectPath) {
redirect(redirectPath);
}
}

export async function getSanitizedRedirectPath(
searchParams: {
[key: string]: string | string[] | undefined;
},
baseUrl: string,
) {
const sourceChainId = decodeChainQueryParam(searchParams.sourceChain);
const destinationChainId = decodeChainQueryParam(searchParams.destinationChain);
Expand All @@ -115,7 +130,7 @@ export async function sanitizeAndRedirect(

// If both sourceChain and destinationChain are not present, let the client sync with Metamask
if (!sourceChainId && !destinationChainId) {
return;
return null;
}

if (!isProductionEnvironment || isE2eTestingEnvironment) {
Expand All @@ -126,19 +141,32 @@ export async function sanitizeAndRedirect(
sourceChainId,
destinationChainId,
});
const sanitizedToken = sanitizeTokenQueryParam({
token,
sourceChainId: sanitizedChainIds.sourceChainId,
destinationChainId: sanitizedChainIds.destinationChainId,
});
let sanitizedDestinationToken = sanitizeTokenQueryParam({
token: destinationToken,
sourceChainId: sanitizedChainIds.sourceChainId,
destinationChainId: sanitizedChainIds.destinationChainId,
});

// Reuse the same default selection behavior as setSelectedToken(null) for ApeChain -> Superposition.
if (
sanitizedChainIds.sourceChainId === ChainId.ApeChain &&
sanitizedChainIds.destinationChainId === ChainId.Superposition &&
typeof token === 'undefined' &&
typeof destinationToken === 'undefined'
) {
sanitizedDestinationToken = constants.AddressZero;
}

const sanitized = {
...sanitizedChainIds,
experiments: sanitizeExperimentalFeaturesQueryParam(experiments),
token: sanitizeTokenQueryParam({
token,
sourceChainId: sanitizedChainIds.sourceChainId,
destinationChainId: sanitizedChainIds.destinationChainId,
}),
destinationToken: sanitizeTokenQueryParam({
token: destinationToken,
sourceChainId: sanitizedChainIds.sourceChainId,
destinationChainId: sanitizedChainIds.destinationChainId,
}),
token: sanitizedToken,
destinationToken: sanitizedDestinationToken,
tab: sanitizeTabQueryParam(tab),
disabledFeatures: DisabledFeaturesParam.decode(disabledFeatures),
};
Expand All @@ -161,6 +189,8 @@ export async function sanitizeAndRedirect(
`[sanitizeAndRedirect] sourceChain=${sanitized.sourceChainId}&destinationChain=${sanitized.destinationChainId}&experiments=${sanitized.experiments}&token=${sanitized.token}&destinationToken=${sanitized.destinationToken}&tab=${sanitized.tab}&disabledFeatures=${sanitized.disabledFeatures}&sanitized=true (after)`,
);

redirect(getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl));
return getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl);
}

return null;
}
1 change: 1 addition & 0 deletions packages/app/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@/common': path.resolve(__dirname, '../portal/common'),
'@/bridge': path.resolve(__dirname, '../arb-token-bridge-ui/src'),
'@/app-types': path.resolve(__dirname, './src/types'),
'@/app-hooks': path.resolve(__dirname, './src/hooks'),
Expand Down
3 changes: 3 additions & 0 deletions packages/arb-token-bridge-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
"prebuild": "yarn generateDenylist",
"test": "vitest --config vitest.config.ts --watch",
"test:ci": "vitest --config vitest.config.ts --run",
"test:integration": "vitest --config vitest.integration.config.ts --run",
"test:integration:watch": "vitest --config vitest.integration.config.ts --watch",
"generateDenylist": "ts-node --project ./scripts/tsconfig.json ./scripts/generateDenylist.ts",
"generateOpenGraphImages": "ts-node --project ./scripts/tsconfig.json ./src/generateOpenGraphImages.tsx generate",
"generateOpenGraphImages:core": "yarn generateOpenGraphImages --core",
Expand Down Expand Up @@ -94,6 +96,7 @@
"cypress-terminal-report": "^7.1.0",
"env-cmd": "^10.1.0",
"happy-dom": "^20.8.9",
"jsdom-testing-mocks": "^1.16.0",
"postcss": "^8.5.3",
"satori": "^0.12.2",
"start-server-and-test": "^2.0.11",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ChevronDownIcon } from '@heroicons/react/24/outline';

import { getTokenOverride, isValidLifiTransfer } from '@/bridge/app/api/crosschain-transfers/utils';
import { useNetworksRelationship } from '@/bridge/hooks/useNetworksRelationship';
import { useSelectedToken } from '@/bridge/hooks/useSelectedToken';

import { useDestinationToken } from '../../hooks/useDestinationToken';
import { NativeCurrency, useNativeCurrency } from '../../hooks/useNativeCurrency';
import { useNetworks } from '../../hooks/useNetworks';
import { useNetworksRelationship } from '../../hooks/useNetworksRelationship';
import { sanitizeTokenSymbol } from '../../util/TokenUtils';
import { Button } from '../common/Button';
import { DialogWrapper, useDialog2 } from '../common/Dialog2';
Expand Down
Loading
Loading