diff --git a/.github/workflows/deploy_vercel.yml b/.github/workflows/deploy_vercel.yml index 09989b982..c8a94e500 100644 --- a/.github/workflows/deploy_vercel.yml +++ b/.github/workflows/deploy_vercel.yml @@ -24,10 +24,10 @@ jobs: if: "github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'production'" runs-on: ubuntu-latest steps: - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v6 with: - node-version: 20 - - uses: actions/checkout@v3 + node-version: 24 + - uses: actions/checkout@v6 with: persist-credentials: false fetch-depth: 0 @@ -48,11 +48,15 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: yarn + - name: Enable Yarn + run: corepack enable + - name: Verify Node.js version + run: node --version - run: yarn install --immutable - run: yarn vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} - run: yarn vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml index a3b316ef8..d2a2a6c94 100644 --- a/.github/workflows/jest.yml +++ b/.github/workflows/jest.yml @@ -12,12 +12,15 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 + + - name: Enable Yarn + run: corepack enable - name: Install dependencies run: yarn install --immutable diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f23a480f2..473ea211e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,29 +15,19 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Set up node - uses: actions/setup-node@v1 + uses: actions/setup-node@v6 with: - node-version: 12 - always-auth: true - registry-url: https://registry.npmjs.org + node-version: 24 + cache: yarn - - name: Set output of cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: Node dependency cache - uses: actions/cache@v1 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - yarn- + - name: Enable Yarn + run: corepack enable - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --immutable - name: Run linters uses: wearerequired/lint-action@77d70b9a07ecb93bc98dc46dc27d96c4f004d035 diff --git a/.github/workflows/preview_vercel.yml b/.github/workflows/preview_vercel.yml index 181c71d0f..aaddce6c1 100644 --- a/.github/workflows/preview_vercel.yml +++ b/.github/workflows/preview_vercel.yml @@ -32,10 +32,10 @@ jobs: if: "github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'qa'" runs-on: ubuntu-latest steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 20 - - uses: actions/checkout@v4 + node-version: 24 + - uses: actions/checkout@v6 with: persist-credentials: false fetch-depth: 0 @@ -57,13 +57,17 @@ jobs: name: preview url: ${{ steps.deploy.outputs.url }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: yarn + - name: Enable Yarn + run: corepack enable + - name: Verify Node.js version + run: node --version - run: yarn install --immutable - id: check_branch run: echo "::set-output name=branch::$(if [[ "${{ github.ref }}" == *"/staging" ]]; then echo 'staging'; else echo 'master'; fi)" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 338305775..ae739df38 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -10,27 +10,18 @@ on: jobs: integration-tests: name: Integration tests - runs-on: ubuntu-16.04 + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: '12' - always-auth: true - registry-url: https://registry.npmjs.org + node-version: 24 + cache: yarn - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v1 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - run: yarn install --frozen-lockfile + - name: Enable Yarn + run: corepack enable + - run: yarn install --immutable - run: yarn cypress install - run: yarn build env: @@ -43,23 +34,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: '12' - always-auth: true - registry-url: https://registry.npmjs.org + node-version: 24 + cache: yarn - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v1 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - run: yarn install --frozen-lockfile + - name: Enable Yarn + run: corepack enable + - run: yarn install --immutable - run: yarn test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 84e1424d2..6fc40f74c 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,13 @@ https://cocky-dubinsky-430a6c.netlify.app/ ### Install Dependencies +Use Node.js 24 and the repository-pinned Yarn 3.6.1. + ```bash -yarn +nvm install +nvm use +corepack enable +yarn install --immutable ``` ### Run diff --git a/package.json b/package.json index 338daf100..295e17313 100755 --- a/package.json +++ b/package.json @@ -213,10 +213,10 @@ }, "dependencies": { "@babel/runtime": "^7.18.9", - "@gooddollar/good-design": "file:temp-packages/@gooddollar-good-design-0.4.52.tgz", + "@gooddollar/good-design": "file:.yalc/@gooddollar/good-design", "@gooddollar/goodprotocol": "2.2.1", "@gooddollar/web3sdk": "0.1.59", - "@gooddollar/web3sdk-v2": "file:temp-packages/@gooddollar-web3sdk-v2-0.4.48.tgz", + "@gooddollar/web3sdk-v2": "file:.yalc/@gooddollar/web3sdk-v2", "@goodsdks/savings-widget": "^1.0.0", "@headlessui/react": "1.5.0", "@lingui/format-json": "^4.0.0", @@ -257,6 +257,9 @@ "wagmi": "^2.14.13", "web3": "1.8.2" }, + "engines": { + "node": "24.x" + }, "packageManager": "yarn@3.6.1", "jest": { "testEnvironment": "jsdom", diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 9716e94a6..6f1e904e9 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -3,8 +3,7 @@ import '@reach/dialog/styles.css' import { transparentize } from 'polished' import React from 'react' import { isMobile } from 'react-device-detect' -import { animated, useSpring, useTransition } from 'react-spring' -import { useGesture } from 'react-use-gesture' +import { animated, useTransition } from 'react-spring' import styled, { css } from 'styled-components' const AnimatedDialogOverlay = animated(DialogOverlay) @@ -115,18 +114,6 @@ export default function Modal({ leave: { opacity: 0 }, }) - const [{ y }, set] = useSpring(() => ({ y: 0, config: { mass: 1, tension: 210, friction: 20 } })) - const bind = useGesture({ - onDrag: (state) => { - set({ - y: state.down ? state.movement[1] : 0, - }) - if (state.movement[1] > 300 || (state.velocity > 3 && state.direction[1] > 0)) { - onDismiss() - } - }, - }) - return ( <> {fadeTransition.map( @@ -139,16 +126,6 @@ export default function Modal({ initialFocusRef={initialFocusRef} > `translateY(${(y as number) > 0 ? y : 0}px)` - ), - }, - } - : {})} aria-label="dialog content" minHeight={minHeight} maxHeight={maxHeight} diff --git a/src/functions/rpcParsing.test.ts b/src/functions/rpcParsing.test.ts index 192ab9138..24f44bfa7 100644 --- a/src/functions/rpcParsing.test.ts +++ b/src/functions/rpcParsing.test.ts @@ -68,4 +68,44 @@ describe('rpcParsing', () => { '50': ['https://rpc.xinfin.network'], }) }) + it('filters discovered RPCs before they can be probed', async () => { + process.env.REACT_APP_EXCLUDED_RPCS = ' RPC.FUSE.IO, 1rpc.io ' + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => CHAINLIST_RPCS_SOURCE, + } as Response) + const { fetchRpcsFromChainlist } = await import('./rpcParsing') + const urls = await fetchRpcsFromChainlist() + expect(urls['122']).toEqual(['https://fuse.drpc.org']) + expect(urls['1']).toEqual(['https://eth.llamarpc.com']) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('filters configured and default fallbacks without reintroducing excluded URLs', async () => { + process.env.REACT_APP_EXCLUDED_RPCS = 'blocked.example, rpc.fuse.io' + process.env.REACT_APP_CELO_RPC = 'https://blocked.example/celo, https://allowed.example/celo' + const { getFallbackRpcsByChain } = await import('./rpcParsing') + expect(getFallbackRpcsByChain()['122']).toEqual([]) + expect(getFallbackRpcsByChain()['42220']).toEqual(['https://allowed.example/celo']) + }) + + it('applies the same case-insensitive substring rules to cached URLs and removes duplicates', async () => { + const { filterExcludedRpcs } = await import('./rpcParsing') + expect( + filterExcludedRpcs( + [ + 'https://node.example/a', + 'https://NODE.example/b', + ' https://allowed.example/rpc ', + 'https://allowed.example/rpc', + ], + { REACT_APP_EXCLUDED_RPCS: ' NODE.EXAMPLE, , ' } + ) + ).toEqual(['https://allowed.example/rpc']) + expect( + filterExcludedRpcs(['https://node.example/a', 'https://node.example/b'], { + REACT_APP_EXCLUDED_RPCS: 'https://node.example/a', + }) + ).toEqual(['https://node.example/b']) + }) }) diff --git a/src/functions/rpcParsing.ts b/src/functions/rpcParsing.ts index 641e39f5a..de67f00fe 100644 --- a/src/functions/rpcParsing.ts +++ b/src/functions/rpcParsing.ts @@ -22,6 +22,18 @@ const ENV_RPC_KEYS_BY_CHAIN: Record = { '50': 'REACT_APP_XDC_RPC', } +// Case-insensitive URL substrings: a hostname blocks every matching path on that host. +export function filterExcludedRpcs(urls: string[], env: Env = process.env): string[] { + const excluded = (env.REACT_APP_EXCLUDED_RPCS ?? '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + + return [...new Set(urls.map((url) => url.trim()).filter(Boolean))].filter( + (url) => !excluded.some((blocked) => url.toLowerCase().includes(blocked)) + ) +} + function normalizeRpcUrls(rpcs: ChainlistRpc[] = []): string[] { return rpcs .map((rpc) => rpc.url?.trim()) @@ -44,12 +56,14 @@ export function getFallbackRpcsByChain(env: Env = process.env): Record rpc.trim()) .filter((rpc) => rpc.startsWith('http://') || rpc.startsWith('https://')) - result[chainId] = + result[chainId] = filterExcludedRpcs( enabledFallbackChains.has(chainId) && configuredRpcs.length > 0 ? configuredRpcs : enabledFallbackChains.has(chainId) ? DEFAULT_FALLBACK_RPCS_BY_CHAIN[chainId] - : [] + : [], + env + ) return result }, {} as Record) @@ -63,7 +77,7 @@ export async function fetchRpcsFromChainlist(): Promise const chainsById = new Map(chains.map((chain) => [String(chain.chainId), chain])) return SUPPORTED_CHAIN_IDS.reduce>((result, chainId) => { - result[chainId] = normalizeRpcUrls(chainsById.get(chainId)?.rpc) + result[chainId] = filterExcludedRpcs(normalizeRpcUrls(chainsById.get(chainId)?.rpc)) return result }, {}) } diff --git a/src/functions/rpcProviders.test.ts b/src/functions/rpcProviders.test.ts new file mode 100644 index 000000000..cab9b6b68 --- /dev/null +++ b/src/functions/rpcProviders.test.ts @@ -0,0 +1,96 @@ +/** @jest-environment node */ +/* eslint-env jest */ +import { providers } from 'ethers' +import { createSdkReadOnlyUrls } from './rpcProviders' +import { getUrl } from '@ethersproject/web/lib/geturl' +import { getProvidersFromConfig } from '@usedapp/core/dist/cjs/src/providers/network/readonlyNetworks/provider' + +jest.mock('@ethersproject/web/lib/geturl', () => ({ getUrl: jest.fn() })) + +const a = 'https://a.offline.invalid/rpc' +const b = 'https://b.offline.invalid/rpc' +const rpcs = { 1: 'https://mainnet.offline.invalid', 122: a, 42220: b, 50: 'https://xdc.offline.invalid' } +const candidates = { 122: [a, b, a], 42220: [b] } + +const pools: providers.FallbackProvider[] = [] +function makePool() { + const result = createSdkReadOnlyUrls(rpcs, candidates)! + for (const value of Object.values(result)) if (value instanceof providers.FallbackProvider) pools.push(value) + return result[122] as providers.FallbackProvider +} + +describe('SDK read-only RPC pools', () => { + const originalEnv = process.env + beforeEach(() => { + process.env = { ...originalEnv, REACT_APP_EXCLUDED_RPCS: '' } + jest.clearAllMocks() + }) + afterEach(() => { + process.env = originalEnv + for (const pool of pools.splice(0)) { + pool.removeAllListeners() + pool.polling = false + for (const { provider } of pool.providerConfigs) { + ;(provider as providers.BaseProvider).removeAllListeners() + ;(provider as providers.BaseProvider).polling = false + } + } + }) + + it('keeps scalar URLs intact and produces bounded providers accepted by usedapp', () => { + const result = createSdkReadOnlyUrls(rpcs, candidates)! + const pool = result[122] as providers.FallbackProvider + expect(rpcs[122]).toBe(a) + expect(result[1]).toBe(rpcs[1]) + expect(result[50]).toBe(rpcs[50]) + expect(pool.quorum).toBe(1) + expect(pool.providerConfigs).toHaveLength(2) + pool.providerConfigs.forEach((config, index) => { + expect(config).toMatchObject({ priority: index + 1, weight: 1, stallTimeout: 1000 }) + const child = config.provider as providers.StaticJsonRpcProvider + expect(child.connection).toMatchObject({ throttleLimit: 1, timeout: 5000 }) + expect(child.network.chainId).toBe(122) + }) + expect(getProvidersFromConfig({ 122: pool })[122]).toBe(pool) + expect(getProvidersFromConfig({ 122: () => pool })[122]).toBe(pool) + expect(getUrl).not.toHaveBeenCalled() + }) + + it('fails over from HTTP 429 to B with one transport attempt per endpoint', async () => { + ;(getUrl as jest.Mock).mockImplementation(async (url, options) => { + const request = JSON.parse(Buffer.from(options.body).toString()) + return url === a + ? { statusCode: 429, headers: {}, body: Buffer.from('{}') } + : { + statusCode: 200, + headers: {}, + body: Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: '0x64', + }) + ), + } + }) + await expect(makePool().getBlockNumber()).resolves.toBe(100) + expect((getUrl as jest.Mock).mock.calls.filter(([url]) => url === a)).toHaveLength(1) + expect((getUrl as jest.Mock).mock.calls.filter(([url]) => url === b)).toHaveLength(1) + }) + + it('rejects when all endpoints return 429 without an endless retry loop', async () => { + ;(getUrl as jest.Mock).mockResolvedValue({ statusCode: 429, headers: {}, body: Buffer.from('{}') }) + await expect(makePool().getBlockNumber()).rejects.toThrow('failed to meet quorum') + expect(getUrl).toHaveBeenCalledTimes(2) + }) + + it('filters selected and cached URLs, including the all-excluded case', () => { + process.env.REACT_APP_EXCLUDED_RPCS = 'A.OFFLINE.INVALID' + const pool = makePool() + expect(pool.providerConfigs).toHaveLength(1) + expect((pool.providerConfigs[0].provider as providers.StaticJsonRpcProvider).connection.url).toBe(b) + process.env.REACT_APP_EXCLUDED_RPCS = 'offline.invalid' + expect(createSdkReadOnlyUrls(rpcs, candidates)?.[122]).toBe('') + expect(getUrl).not.toHaveBeenCalled() + }) +}) diff --git a/src/functions/rpcProviders.ts b/src/functions/rpcProviders.ts new file mode 100644 index 000000000..22406d36a --- /dev/null +++ b/src/functions/rpcProviders.ts @@ -0,0 +1,28 @@ +import { providers } from 'ethers' +import { Config } from '@usedapp/core' +import { filterExcludedRpcs } from './rpcParsing' + +// Keep SDK-v1 scalar URLs separate from SDK-v2's read providers. +export function createSdkReadOnlyUrls( + rpcs: Record, + candidates: Record +): Config['readOnlyUrls'] { + const readOnlyUrls: NonNullable = { ...rpcs } + for (const chainId of [122, 42220]) { + const urls = filterExcludedRpcs([rpcs[chainId] || '', ...(candidates[chainId] || [])]) + if (!urls.length) { + readOnlyUrls[chainId] = '' + continue + } + readOnlyUrls[chainId] = new providers.FallbackProvider( + urls.map((url, index) => ({ + provider: new providers.StaticJsonRpcProvider({ url, throttleLimit: 1, timeout: 5000 }, chainId), + priority: index + 1, + weight: 1, + stallTimeout: 1000, + })), + 1 + ) + } + return readOnlyUrls +} diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index 714ece764..3d1541da7 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -10,7 +10,13 @@ import { useAppKitNetwork, useAppKitProvider } from '@reown/appkit/react' import type { Provider } from '@reown/appkit/react' import { useAccount } from 'wagmi' -import { SUPPORTED_CHAIN_IDS, fetchRpcsFromChainlist, getFallbackRpcsByChain } from 'functions/rpcParsing' +import { + SUPPORTED_CHAIN_IDS, + fetchRpcsFromChainlist, + filterExcludedRpcs, + getFallbackRpcsByChain, +} from 'functions/rpcParsing' +import { createSdkReadOnlyUrls } from 'functions/rpcProviders' import { getEnv } from 'utils/env' import { isMiniPay, getMiniPayProvider } from 'utils/minipay' @@ -154,22 +160,8 @@ export const initializeRpcs = async () => { export function useNetwork(): NetworkSettings { const [testifiedRpcs, setTestifiedRpcs] = React.useState | null>(null) - const excludedRpcs = useMemo( - () => - (process.env.REACT_APP_EXCLUDED_RPCS ?? '') - .split(',') - .map((value) => value.trim().toLowerCase()) - .filter(Boolean), - [] - ) - const filterBlockedRpcs = (rpcs: Record) => - Object.fromEntries( - Object.entries(rpcs).map(([chainId, urls]) => [ - chainId, - urls.filter((url) => !excludedRpcs.some((blocked) => url.toLowerCase().includes(blocked))), - ]) - ) + Object.fromEntries(Object.entries(rpcs).map(([chainId, urls]) => [chainId, filterExcludedRpcs(urls)])) const fallbackRpcsByChain = useMemo(() => getFallbackRpcsByChain(), []) @@ -198,7 +190,11 @@ export function useNetwork(): NetworkSettings { } export function Web3ContextProvider({ children }: { children: ReactNode | ReactNodeArray }): JSX.Element { - const { rpcs } = useNetwork() + const { rpcs, testedRpcs } = useNetwork() + const readOnlyUrls = useMemo( + () => createSdkReadOnlyUrls(rpcs, testedRpcs || getFallbackRpcsByChain()), + [rpcs, testedRpcs] + ) const { chainId } = useAppKitNetwork() const { walletProvider } = useAppKitProvider('eip155') const { connector, address } = useAccount() @@ -263,7 +259,7 @@ export function Web3ContextProvider({ children }: { children: ReactNode | ReactN pollingInterval: 15000, networks: [Mainnet, Fuse, Celo, Xdc], readOnlyChainId: undefined, - readOnlyUrls: rpcs, + readOnlyUrls, }} > {children} diff --git a/src/pages/gd/Swap/ReserveLimitModal.tsx b/src/pages/gd/Swap/ReserveLimitModal.tsx new file mode 100644 index 000000000..9826d33b8 --- /dev/null +++ b/src/pages/gd/Swap/ReserveLimitModal.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import Modal from 'components/Modal' +import Title from 'components/gd/Title' +import { ButtonAction } from 'components/gd/Button' + +interface ReserveLimitModalProps { + chainId?: number + otherChainId?: number + showCeloDexRoutes: boolean + onClose: () => void +} + +export default function ReserveLimitModal({ + chainId, + otherChainId, + showCeloDexRoutes, + onClose, +}: ReserveLimitModalProps) { + const { i18n } = useLingui() + const chain = chainId === 42220 ? 'Celo' : chainId === 50 ? 'XDC' : i18n._(t`this network`) + const otherChain = + otherChainId !== chainId && (otherChainId === 42220 ? 'Celo' : otherChainId === 50 ? 'XDC' : undefined) + + return ( + +
+ + {i18n._(t`Reserve swap limit reached`)} + +

{i18n._(t`This swap exceeds the Reserve’s current trading allowance on ${chain}.`)}

+

+ {otherChain + ? i18n._(t`You can try swapping with the Reserve on ${otherChain}.`) + : i18n._(t`You may be able to swap with the Reserve on another supported network.`)} +

+

+ {i18n._( + t`Switching networks does not move your funds. Bridging may be needed to use another network.` + )} +

+ {otherChain &&

{i18n._(t`The Reserve may use a different token on that network.`)}

} + {showCeloDexRoutes && ( + <> +

{i18n._(t`You can also check Uniswap or Ubeswap on Celo for another swap route.`)}

+ +

+ {i18n._( + t`Check liquidity, price impact and minimum received before swapping. A route may not be available, and returns can differ.` + )} +

+ + )} + {i18n._(t`Close`)} +
+
+ ) +} diff --git a/src/pages/gd/Swap/SwapConfirmModal/index.tsx b/src/pages/gd/Swap/SwapConfirmModal/index.tsx index 339e68784..cd8c0161c 100644 --- a/src/pages/gd/Swap/SwapConfirmModal/index.tsx +++ b/src/pages/gd/Swap/SwapConfirmModal/index.tsx @@ -114,7 +114,7 @@ const SwapConfirmModal = memo( if (swap?.state?.status === 'Mining') { setStatus('SENT') onSent(swap.state.transaction?.hash || '', swap.state.transaction?.from || '') - } else if (swap?.state?.status === 'Exception') { + } else if (swap?.state?.status === 'Exception' || swap?.state?.status === 'Fail') { onClose() setStatus('REJECTED') } else if (swap?.state?.status === 'Success') { diff --git a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx index e0feb1017..61c9f79dd 100644 --- a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx +++ b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx @@ -1,5 +1,5 @@ import React, { cloneElement, memo, useCallback, useEffect, useMemo, useState } from 'react' -import { ErrorModal } from '@gooddollar/good-design' +import * as goodDesign from '@gooddollar/good-design' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { Spinner } from 'native-base' @@ -24,6 +24,8 @@ import { useTokenBalance } from 'state/wallet/hooks' import useG$ from 'hooks/useG$' import SwapConfirmModal from '../SwapConfirmModal' +import { isReserveLimitFailure } from '../reserveLimitError' +import { ReserveAlternativeCheck, useReserveAlternative } from '../useReserveAlternative' import QuestionHelper from 'components/QuestionHelper' @@ -31,12 +33,25 @@ import GoodReserveLogo from 'assets/images/goodreserve-logo.png' import useSendAnalyticsData from 'hooks/useSendAnalyticsData' import { useReserveToken } from 'hooks/useReserveToken' import { decodeTransactionErrorReason } from 'utils/transactionErrors' +import { useGoodDappFeatures } from 'hooks/useFeaturesEnabled' + +const { ErrorModal } = goodDesign +// Keep the existing installed design package usable until the new component is released and upgraded. +const { ReserveLimitModal } = goodDesign as { + ReserveLimitModal?: React.ComponentType<{ + chainId?: number + otherChainId?: number + onClose: () => void + }> +} const MentoSwap = memo(() => { const CUSD = useReserveToken() const { i18n } = useLingui() const [buying, setBuying] = useState(true) const [error, setError] = useState<{ message: string; reason?: string } | undefined>(undefined) + const [limitErrorChainId, setLimitErrorChainId] = useState() + const { isFeatureActive } = useGoodDappFeatures() const [slippageTolerance, setSlippageTolerance] = useState({ custom: false, value: '0.1', @@ -104,7 +119,19 @@ const MentoSwap = memo(() => { ], [outputAmountBig.toString(), inputAmountBig.toString(), slippageTolerance.value, swapPair.input.address] ) - const { swap, approve } = useSwap(swapInput[0], swapInput[1], swapInput[2], swapInput[3]) + const reserveSwap = useSwap(swapInput[0], swapInput[1], swapInput[2], swapInput[3]) + const { swap, approve } = reserveSwap + // Optional until the SDK release is published and consumed through the normal upgrade workflow. + const { checkAlternativeReserve } = reserveSwap as typeof reserveSwap & { + checkAlternativeReserve?: ReserveAlternativeCheck + } + const otherReserveChainId = limitErrorChainId === 42220 ? 50 : 42220 + const verifiedOtherChainId = useReserveAlternative( + limitErrorChainId, + isFeatureActive('networkEnabled', otherReserveChainId) && + isFeatureActive('reserveEnabled', otherReserveChainId), + checkAlternativeReserve + ) const [approving, setApproving] = useState(false) const [showConfirm, setShowConfirm] = useState(false) @@ -112,19 +139,26 @@ const MentoSwap = memo(() => { const sendData = useSendAnalyticsData() useEffect(() => { - if (swap?.state?.status === 'Exception') { - setError({ - message: i18n._(t`Swap transaction failed, please try again.`), - reason: decodeTransactionErrorReason(swap.state.errorMessage), - }) + if (swap?.state?.status === 'Exception' || swap?.state?.status === 'Fail') { + if (isReserveLimitFailure(swap.state) && ReserveLimitModal) { + setError(undefined) + setLimitErrorChainId(swap.state.chainId ?? chainId) + } else { + setLimitErrorChainId(undefined) + setError({ + message: i18n._(t`Swap transaction failed, please try again.`), + reason: decodeTransactionErrorReason(swap.state.errorMessage), + }) + } } - if (approve?.state?.status === 'Exception') { + if (approve?.state?.status === 'Exception' || approve?.state?.status === 'Fail') { + setLimitErrorChainId(undefined) setError({ message: i18n._(t`Approve transaction failed, please try again.`), reason: decodeTransactionErrorReason(approve.state.errorMessage), }) } - }, [swap?.state?.status, approve?.state?.status]) + }, [swap.state, approve.state, chainId, i18n]) useEffect(() => { if (!(swapPair.input && swapPair.output)) { @@ -427,6 +461,13 @@ const MentoSwap = memo(() => { overlay="blur" onClose={() => setError(undefined)} /> + {ReserveLimitModal && ( + setLimitErrorChainId(undefined)} + /> + )} + (state.status === 'Exception' || state.status === 'Fail') && state.errorType === 'RESERVE_LIMIT' + +// These external interfaces support Celo mainnet. This does not assert a quote or executable route. +export const canOfferCeloDexRoutes = (production: boolean, celoNetworkEnabled: boolean): boolean => + production && celoNetworkEnabled diff --git a/src/pages/gd/Swap/useReserveAlternative.ts b/src/pages/gd/Swap/useReserveAlternative.ts new file mode 100644 index 000000000..648be1b7f --- /dev/null +++ b/src/pages/gd/Swap/useReserveAlternative.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' + +export type ReserveAlternativeCheck = () => Promise<{ chainId: number; checkedAt: number } | undefined> +const refreshInterval = 15000 + +export const useReserveAlternative = ( + sourceChainId: number | undefined, + enabled: boolean, + check: ReserveAlternativeCheck | undefined +): number | undefined => { + const [verified, setVerified] = useState<{ sourceChainId: number; chainId: number; checkedAt: number }>() + + useEffect(() => { + let cancelled = false + let timer: ReturnType | undefined + setVerified(undefined) + if (!enabled || !check || (sourceChainId !== 42220 && sourceChainId !== 50)) return + const otherChainId = sourceChainId === 42220 ? 50 : 42220 + + const refresh = async () => { + setVerified(undefined) + try { + const result = await check() + const age = result ? Date.now() - result.checkedAt : Infinity + if (!cancelled && result?.chainId === otherChainId && age >= 0 && age < refreshInterval) { + setVerified({ ...result, sourceChainId }) + } + } catch { + // An unreachable or unchecked Reserve must not show an alternative-chain suggestion. + } finally { + if (!cancelled) timer = setTimeout(() => void refresh(), refreshInterval) + } + } + void refresh() + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [sourceChainId, enabled, check]) + + return enabled && + verified && + verified.sourceChainId === sourceChainId && + Date.now() - verified.checkedAt < refreshInterval + ? verified.chainId + : undefined +} diff --git a/yarn.lock b/yarn.lock index ee70f3407..bf86e99c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5988,9 +5988,9 @@ __metadata: languageName: node linkType: hard -"@gooddollar/good-design@file:temp-packages/@gooddollar-good-design-0.4.52.tgz::locator=%40gooddollar%2Fprotocol-ui%40workspace%3A.": - version: 0.4.52 - resolution: "@gooddollar/good-design@file:temp-packages/@gooddollar-good-design-0.4.52.tgz::locator=%40gooddollar%2Fprotocol-ui%40workspace%3A." +"@gooddollar/good-design@file:.yalc/@gooddollar/good-design::locator=%40gooddollar%2Fprotocol-ui%40workspace%3A.": + version: 0.4.51 + resolution: "@gooddollar/good-design@file:.yalc/@gooddollar/good-design#.yalc/@gooddollar/good-design::hash=24ba0f&locator=%40gooddollar%2Fprotocol-ui%40workspace%3A." dependencies: "@babel/core": ^7.18.10 "@babel/runtime": ^7.18.9 @@ -6041,7 +6041,7 @@ __metadata: react: ^18.* react-native-paper: "*" uuid: "*" - checksum: e189ccf378678f8991210b2cd51e5b2f8a15d9e9277e118d74027c16180201fdf7690aa703be7e36e2f3d6c017174cae4dde1090cda358c77355a16eb825dbd6 + checksum: b34d280d5896fe05cb242dc3fb549acb790f0f61dff7ecddfb1e00dbd0286995f5a3ac221c46d575c641d70adeac525286682d570e0fcd0035468207f92ec128 languageName: node linkType: hard @@ -6099,10 +6099,10 @@ __metadata: "@ethersproject/experimental": ^5.0.1 "@fontsource/dm-sans": ^4.2.2 "@fullhuman/postcss-purgecss": ^4.0.3 - "@gooddollar/good-design": "file:temp-packages/@gooddollar-good-design-0.4.52.tgz" + "@gooddollar/good-design": "file:.yalc/@gooddollar/good-design" "@gooddollar/goodprotocol": 2.2.1 "@gooddollar/web3sdk": 0.1.59 - "@gooddollar/web3sdk-v2": "file:temp-packages/@gooddollar-web3sdk-v2-0.4.48.tgz" + "@gooddollar/web3sdk-v2": "file:.yalc/@gooddollar/web3sdk-v2" "@goodsdks/savings-widget": ^1.0.0 "@headlessui/react": 1.5.0 "@hot-loader/react-dom": ^17.0.1 @@ -6290,9 +6290,9 @@ __metadata: languageName: unknown linkType: soft -"@gooddollar/web3sdk-v2@file:temp-packages/@gooddollar-web3sdk-v2-0.4.48.tgz::locator=%40gooddollar%2Fprotocol-ui%40workspace%3A.": - version: 0.4.48 - resolution: "@gooddollar/web3sdk-v2@file:temp-packages/@gooddollar-web3sdk-v2-0.4.48.tgz::locator=%40gooddollar%2Fprotocol-ui%40workspace%3A." +"@gooddollar/web3sdk-v2@file:.yalc/@gooddollar/web3sdk-v2::locator=%40gooddollar%2Fprotocol-ui%40workspace%3A.": + version: 0.4.47+ee8ea682 + resolution: "@gooddollar/web3sdk-v2@file:.yalc/@gooddollar/web3sdk-v2#.yalc/@gooddollar/web3sdk-v2::hash=0619a5&locator=%40gooddollar%2Fprotocol-ui%40workspace%3A." dependencies: "@amplitude/analytics-browser": ^1.6.4 "@amplitude/analytics-react-native": ^0.7.0 @@ -6340,7 +6340,7 @@ __metadata: react-native-web: "*" viem: 2.* wagmi: 2.* - checksum: fdc03aedb4766e509ef38599306c4ee04bcaf3704f2377ffa51399ed95f59e5aaf88b9f4e2f4a08f7a104d146f14665e615ed06e3115d1a74c94de4dc507fca9 + checksum: c6b3fcded7bb4f7533d1691a25322383003e569c6f533161af19d30d05c90ed205331fd452b1d4d9e2644d6961da8af5bf6958276ba9994513509852a3a58ec0 languageName: node linkType: hard