From 9806b332c74ae1a93baafb75e3ab0a92d04e25b1 Mon Sep 17 00:00:00 2001 From: LewisB Date: Mon, 5 Oct 2026 18:32:04 +0700 Subject: [PATCH 1/7] update workflows and set engine to use: node 24 --- .github/workflows/deploy_vercel.yml | 16 ++++++---- .github/workflows/jest.yml | 9 ++++-- .github/workflows/lint.yml | 24 ++++---------- .github/workflows/preview_vercel.yml | 16 ++++++---- .github/workflows/tests.yaml | 48 +++++++++------------------- .nvmrc | 1 + README.md | 7 +++- package.json | 3 ++ 8 files changed, 58 insertions(+), 66 deletions(-) create mode 100644 .nvmrc 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..a65117008 100755 --- a/package.json +++ b/package.json @@ -257,6 +257,9 @@ "wagmi": "^2.14.13", "web3": "1.8.2" }, + "engines": { + "node": "24.x" + }, "packageManager": "yarn@3.6.1", "jest": { "testEnvironment": "jsdom", From 4191c80fb008ab6c28dc4ec25f024b217318a7c3 Mon Sep 17 00:00:00 2001 From: LewisB Date: Mon, 12 Oct 2026 15:47:58 +0700 Subject: [PATCH 2/7] fix: improve Reserve swap feedback and RPC failover - Integrate the shared Reserve limit modal and verified alternative-chain messaging. - Handle failed swap/approval states and remove the broken mobile modal drag behavior. - Add bounded Fuse/Celo read-RPC failover and consistent RPC exclusion filtering. - Add provider and RPC regression tests. --- package.json | 4 +- src/components/Modal/index.tsx | 25 +---- src/functions/rpcParsing.test.ts | 40 ++++++++ src/functions/rpcParsing.ts | 20 +++- src/functions/rpcProviders.test.ts | 96 ++++++++++++++++++++ src/functions/rpcProviders.ts | 28 ++++++ src/hooks/useWeb3.tsx | 32 +++---- src/pages/gd/Swap/ReserveLimitModal.tsx | 76 ++++++++++++++++ src/pages/gd/Swap/SwapConfirmModal/index.tsx | 2 +- src/pages/gd/Swap/SwapCore/mentoReserve.tsx | 59 ++++++++++-- src/pages/gd/Swap/reserveLimitError.ts | 11 +++ src/pages/gd/Swap/useReserveAlternative.ts | 47 ++++++++++ yarn.lock | 20 ++-- 13 files changed, 393 insertions(+), 67 deletions(-) create mode 100644 src/functions/rpcProviders.test.ts create mode 100644 src/functions/rpcProviders.ts create mode 100644 src/pages/gd/Swap/ReserveLimitModal.tsx create mode 100644 src/pages/gd/Swap/reserveLimitError.ts create mode 100644 src/pages/gd/Swap/useReserveAlternative.ts diff --git a/package.json b/package.json index a65117008..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", 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 From 9c2e54a94c10aeafacd378174594998c4eb58db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:16:54 +0000 Subject: [PATCH 3/7] feat: add fail-closed Reserve-limit QA simulation Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- docs/RESERVE_LIMIT_QA.md | 54 +++++++++++ src/hooks/useWeb3.tsx | 12 ++- src/pages/gd/Swap/SwapConfirmModal/index.tsx | 62 +++++++++--- src/pages/gd/Swap/SwapCore/mentoReserve.tsx | 85 +++++++++++++++-- src/pages/gd/Swap/reserveLimitError.ts | 5 + src/pages/gd/Swap/reserveLimitQa.test.ts | 99 ++++++++++++++++++++ src/pages/gd/Swap/reserveLimitQa.ts | 40 ++++++++ 7 files changed, 332 insertions(+), 25 deletions(-) create mode 100644 docs/RESERVE_LIMIT_QA.md create mode 100644 src/pages/gd/Swap/reserveLimitQa.test.ts create mode 100644 src/pages/gd/Swap/reserveLimitQa.ts diff --git a/docs/RESERVE_LIMIT_QA.md b/docs/RESERVE_LIMIT_QA.md new file mode 100644 index 000000000..6dba3d684 --- /dev/null +++ b/docs/RESERVE_LIMIT_QA.md @@ -0,0 +1,54 @@ +# Reserve-limit QA simulation + +This development-only path exercises the SDK Reserve-limit error classifier and the existing limit modal. It does not test real trading limits, approvals, mined receipts, or delegated-wallet execution. + +## Prerequisites + +- Node 24 and Yarn 3.6.1. +- A `GoodWeb3-Mono` checkout on `reserve-and-bridge-fixes` that includes the paired QA API: `Web3Provider` accepts `qa.forceTradeLimits`, and `useWeb3Context()` reports `qa.forceTradeLimits === true` only when the SDK simulation is active and blocks Reserve approval/submission before signer access. +- A connected test account, a valid Celo or XDC Reserve quote, and no token balance or allowance are required. Do not approve, sign, or send a transaction. +- The SDK and design packages must be built and installed locally with yalc; package publication is not required. + +## Build and install the paired packages + +From the `GoodWeb3-Mono` checkout, first update `reserve-and-bridge-fixes` to the commit that implements the QA API, then run: + +```sh +yarn install --immutable +cd packages/sdk-v2 +yarn build +yalc publish --push +cd ../good-design +yalc add -D @gooddollar/web3sdk-v2 +yarn build +``` + +From the `GoodProtocolUI` checkout on `reserve-and-rpc-fixes`, run: + +```sh +yarn install --immutable +yalc add @gooddollar/web3sdk-v2 @gooddollar/good-design +yarn install +``` + +If setting up the checkout for the first time, enable Corepack with `corepack enable` before using Yarn. + +## Enable, run, and disable + +In `GoodProtocolUI`, set this in the ignored `.env.local` file: + +```dotenv +REACT_APP_QA_FORCE_TRADE_LIMITS=true +``` + +Start the Vite development server with `yarn start` and open `http://localhost:3000`. The app must be running in development mode; the setting is ignored by production builds. `REACT_APP_NETWORK` remains unchanged: it selects contract deployment configuration and is not the build-mode switch. + +Connect a test account, select Celo or XDC, and enter an amount until a valid Reserve quote is shown. With the QA-capable SDK active, the app shows **QA simulation: Reserve limit** instead of the real Approve/Swap actions, even when the wallet has no balance or allowance. The confirmation is labelled **SIMULATE RESERVE LIMIT**. Confirming it exercises the SDK's injected error, which must happen before signer access. If the SDK is old or does not report the capability as active, the QA button and balance/allowance bypass remain unavailable. + +Check that the confirmation closes and the existing Reserve-limit modal identifies the selected chain. Celo provides Uniswap and Ubeswap links; XDC provides XSwap. Close the modal and repeat to check it reopens without a stale error. Links are destinations to inspect only; do not connect or trade there. + +For the separate layout check, use browser responsive mode at **375 × 812** on both chains. Confirm the text and links do not overflow, scrolling works, and the close control stays visible. This is an emulated layout check, not mobile transaction QA. + +To disable the path, set `REACT_APP_QA_FORCE_TRADE_LIMITS=false` or remove the line, then stop and restart `yarn start`. With the flag off, ordinary balance and allowance restrictions apply. + +Do not use `yarn build` or a production deployment to enable the simulation. This path verifies injected limit-error handling only; it does not verify genuine limit behavior, transaction receipts, or delegated-wallet execution. diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index 3d1541da7..a3021e185 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -19,6 +19,7 @@ import { import { createSdkReadOnlyUrls } from 'functions/rpcProviders' import { getEnv } from 'utils/env' import { isMiniPay, getMiniPayProvider } from 'utils/minipay' +import { isReserveLimitQaRequested } from 'pages/gd/Swap/reserveLimitQa' type NetworkSettings = { currentNetwork: string @@ -45,6 +46,10 @@ const RPC_CACHE_KEY = 'GD_RPC_CACHE' const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours const RPC_TEST_TIMEOUT_MS = 5000 +const QaAwareWeb3Provider = Web3Provider as React.ComponentType< + React.ComponentProps & { qa?: { forceTradeLimits?: boolean } } +> + let rpcInitializationPromise: Promise> | null = null async function testRpc(rpcUrl: string): Promise { @@ -239,6 +244,10 @@ export function Web3ContextProvider({ children }: { children: ReactNode | ReactN const network = getEnv() const contractsEnv = network const contractsEnvV2 = network === 'development' ? 'fuse' : network + const forceTradeLimitsQa = isReserveLimitQaRequested( + import.meta.env.DEV, + process.env.REACT_APP_QA_FORCE_TRADE_LIMITS + ) if (!rpcs) return <> return ( @@ -252,9 +261,10 @@ export function Web3ContextProvider({ children }: { children: ReactNode | ReactN }, }} > - Promise; state: TransactionStatus } meta: BuyInfo | undefined buying: boolean + qaSimulation?: boolean + qaCapabilityActive?: boolean } const SwapConfirmModal = memo( @@ -58,6 +61,8 @@ const SwapConfirmModal = memo( onConfirm, swap, className, + qaSimulation, + qaCapabilityActive, priceImpact, liquidityFee, minimumReceived, @@ -123,6 +128,7 @@ const SwapConfirmModal = memo( }, [swap?.state]) const handleSwap = async () => { + if (!canConfirmReserveLimitQa(Boolean(qaSimulation), Boolean(qaCapabilityActive))) return if (meta && meta.priceImpact && !confirmPriceImpactWithoutFee(meta.priceImpact as unknown as Percent)) { return } @@ -135,14 +141,16 @@ const SwapConfirmModal = memo( const outputSymbol = meta?.outputAmount.currency.symbol try { - sendData({ - event: 'swap', - action: 'swap_confirm', - amount: buying ? minimumOutputSig : inputSig, - tokens: [inputSymbol, outputSymbol], - type: buying ? 'buy' : 'sell', - network, - }) + if (!qaSimulation) { + sendData({ + event: 'swap', + action: 'swap_confirm', + amount: buying ? minimumOutputSig : inputSig, + tokens: [inputSymbol, outputSymbol], + type: buying ? 'buy' : 'sell', + network, + }) + } //mento reserve if (swap) { await swap.send() @@ -221,6 +229,17 @@ const SwapConfirmModal = memo( t`Output is estimated. You will receive at least ${metaMinimunOutput} or the transaction will revert` )} + {qaSimulation && ( +
+ {qaCapabilityActive + ? i18n._( + t`QA simulation only: the active SDK will inject a Reserve-limit error before signer access. No transaction will be sent.` + ) + : i18n._( + t`The Reserve-limit QA capability is unavailable. No transaction will be sent.` + )} +
+ )}
{metaMinimunOutput && ( @@ -270,8 +289,11 @@ const SwapConfirmModal = memo( /> )}
- - {i18n._(t`CONFIRM SWAP`)} + + {qaSimulation ? i18n._(t`SIMULATE RESERVE LIMIT`) : i18n._(t`CONFIRM SWAP`)} ) @@ -279,13 +301,25 @@ const SwapConfirmModal = memo( case 'CONFIRM': content = ( <> - {i18n._(t`Waiting for Confirmation`)} + + {qaSimulation + ? i18n._(t`Running QA simulation`) + : i18n._(t`Waiting for Confirmation`)} +
- {i18n._(t`Swapping`)} {maxPaid || meta?.inputAmount.toSignificant(6)}{' '} - {meta?.inputAmount.currency.symbol} At {price} + {qaSimulation ? ( + i18n._(t`The active SDK will classify the simulated Reserve-limit error.`) + ) : ( + <> + {i18n._(t`Swapping`)} {maxPaid || meta?.inputAmount.toSignificant(6)}{' '} + {meta?.inputAmount.currency.symbol} At {price} + + )}
- {i18n._(t`Confirm this transaction in your wallet`)} + {qaSimulation + ? i18n._(t`No wallet confirmation or transaction is involved.`) + : i18n._(t`Confirm this transaction in your wallet`)}
) diff --git a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx index 61c9f79dd..ae36b7b22 100644 --- a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx +++ b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx @@ -3,7 +3,7 @@ import * as goodDesign from '@gooddollar/good-design' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { Spinner } from 'native-base' -import { useSwapMeta, useSwap } from '@gooddollar/web3sdk-v2' +import { useSwapMeta, useSwap, useWeb3Context } from '@gooddollar/web3sdk-v2' import { SupportedChainId } from '@gooddollar/web3sdk' import { debounce } from 'lodash' import { BigNumber, ethers } from 'ethers' @@ -24,8 +24,15 @@ import { useTokenBalance } from 'state/wallet/hooks' import useG$ from 'hooks/useG$' import SwapConfirmModal from '../SwapConfirmModal' -import { isReserveLimitFailure } from '../reserveLimitError' +import { getReserveLimitFailureChainId } from '../reserveLimitError' import { ReserveAlternativeCheck, useReserveAlternative } from '../useReserveAlternative' +import { + canOpenReserveLimitQa, + canUseRealReserveActions, + getReserveActionMode, + isReserveLimitQaActive, +} from '../reserveLimitQa' +import type { ReserveLimitQaCapability } from '../reserveLimitQa' import QuestionHelper from 'components/QuestionHelper' @@ -59,6 +66,15 @@ const MentoSwap = memo(() => { const { address } = useAppKitAccount() const { chainId: rawChainId } = useAppKitNetwork() const chainId = typeof rawChainId === 'number' ? rawChainId : Number(rawChainId) || 42220 + const { qa: sdkQa } = useWeb3Context() as { qa?: ReserveLimitQaCapability } + const qaSimulationActive = isReserveLimitQaActive( + import.meta.env.DEV, + process.env.REACT_APP_QA_FORCE_TRADE_LIMITS, + sdkQa, + Boolean(ReserveLimitModal) + ) + const [qaConfirmation, setQaConfirmation] = useState(false) + const realReserveActionsAllowed = canUseRealReserveActions(qaSimulationActive || qaConfirmation) const network = SupportedChainId[chainId] const [inputAmount, setInputAmount] = useState('') const [outputAmount, setOutputAmount] = useState('') @@ -140,9 +156,10 @@ const MentoSwap = memo(() => { useEffect(() => { if (swap?.state?.status === 'Exception' || swap?.state?.status === 'Fail') { - if (isReserveLimitFailure(swap.state) && ReserveLimitModal) { + const classifiedLimitChainId = getReserveLimitFailureChainId(swap.state, chainId) + if (classifiedLimitChainId !== undefined && ReserveLimitModal) { setError(undefined) - setLimitErrorChainId(swap.state.chainId ?? chainId) + setLimitErrorChainId(classifiedLimitChainId) } else { setLimitErrorChainId(undefined) setError({ @@ -174,6 +191,7 @@ const MentoSwap = memo(() => { }, [swapMeta, lastEdited?.field, swapPair]) const handleApprove = useCallback(async () => { + if (!realReserveActionsAllowed) return if (approved) return const type = buying ? 'buy' : 'sell' try { @@ -188,7 +206,7 @@ const MentoSwap = memo(() => { } finally { setApproving(false) } - }, [approved, buying, network, approve]) + }, [realReserveActionsAllowed, approved, buying, network, approve]) useEffect(() => { approve.resetState() @@ -225,6 +243,24 @@ const MentoSwap = memo(() => { : Number(g$Balance?.toExact()) < Number(inputAmount), [inputAmount, cusdBalance?.toExact(), g$Balance?.toExact(), buying] ) + const hasValidQaQuote = Boolean( + swapMeta && + lastEdited && + inputAmountBig.gt(0) && + outputAmountBig.gt(0) && + (lastEdited.field === 'input' ? swapMeta.amountOut?.gt(0) : swapMeta.amountIn?.gt(0)) + ) + const canStartQaSimulation = canOpenReserveLimitQa({ + active: qaSimulationActive, + chainId: rawChainId == null ? undefined : chainId, + address, + hasValidQuote: hasValidQaQuote, + }) + const reserveActionMode = getReserveActionMode( + qaSimulationActive || qaConfirmation, + canStartQaSimulation, + balanceNotEnough + ) const metaSymbols = { input: buying ? CUSD.symbol : G$?.symbol, output: buying ? G$?.symbol : CUSD.symbol } const symbols: { [prop: string]: any } = metaSymbols @@ -295,7 +331,10 @@ const MentoSwap = memo(() => { Price impact is low as G$ liquidity is produced on demand depending by the reserve ratio.` ) - const onModalClosed = useCallback(() => setShowConfirm(false), [setShowConfirm]) + const onModalClosed = useCallback(() => { + setShowConfirm(false) + setQaConfirmation(false) + }, [setShowConfirm, setQaConfirmation]) const onFromChange = useCallback( (value: any) => { @@ -329,6 +368,7 @@ const MentoSwap = memo(() => { }, [setBuying]) const onSwap = useCallback(() => { + if (!realReserveActionsAllowed) return sendData({ event: 'swap', action: 'swap_start', @@ -336,7 +376,16 @@ const MentoSwap = memo(() => { network, }) setShowConfirm(true) - }, [sendData, setShowConfirm, buying, network]) + }, [realReserveActionsAllowed, sendData, setShowConfirm, buying, network]) + + const openQaSimulation = useCallback(() => { + if (!canStartQaSimulation) return + setError(undefined) + setLimitErrorChainId(undefined) + swap.resetState() + setQaConfirmation(true) + setShowConfirm(true) + }, [canStartQaSimulation, setError, setLimitErrorChainId, setQaConfirmation, setShowConfirm, swap]) const onSwapConfirmed = useCallback(async () => { setInputAmount('') @@ -414,11 +463,19 @@ const MentoSwap = memo(() => { + ) : reserveActionMode === 'simulate-limit' ? ( + + {i18n._(t`QA simulation: Reserve limit`)} + + ) : reserveActionMode === 'qa-unavailable' ? ( + + {i18n._(t`QA simulation requires a connected Celo/XDC wallet and valid quote`)} + ) : !address ? ( Connect wallet - ) : !swapMeta || (swapMeta && balanceNotEnough) ? ( + ) : !swapMeta || reserveActionMode === 'insufficient-balance' ? ( Insufficient Funds @@ -433,7 +490,13 @@ const MentoSwap = memo(() => { style={{ marginTop: 22 }} width={'auto'} onClick={handleApprove} - disabled={!swapMeta || approved || approving || balanceNotEnough} + disabled={ + !realReserveActionsAllowed || + !swapMeta || + approved || + approving || + balanceNotEnough + } > {approving ? i18n._(t`Approving`) @@ -445,7 +508,7 @@ const MentoSwap = memo(() => { className="flex-grow" style={{ marginTop: 22 }} width={'auto'} - disabled={!swapMeta || balanceNotEnough || !approved} + disabled={!realReserveActionsAllowed || !swapMeta || balanceNotEnough || !approved} onClick={onSwap} > {i18n._(t`Swap`)} @@ -509,6 +572,8 @@ const MentoSwap = memo(() => { }} buying={buying} onConfirm={onSwapConfirmed} + qaSimulation={qaConfirmation} + qaCapabilityActive={qaSimulationActive} /> ) diff --git a/src/pages/gd/Swap/reserveLimitError.ts b/src/pages/gd/Swap/reserveLimitError.ts index 068b66d45..fd31dc955 100644 --- a/src/pages/gd/Swap/reserveLimitError.ts +++ b/src/pages/gd/Swap/reserveLimitError.ts @@ -6,6 +6,11 @@ type ReserveSwapStatus = TransactionStatus & { errorType?: 'RESERVE_LIMIT' } export const isReserveLimitFailure = (state: ReserveSwapStatus): boolean => (state.status === 'Exception' || state.status === 'Fail') && state.errorType === 'RESERVE_LIMIT' +export const getReserveLimitFailureChainId = ( + state: ReserveSwapStatus, + fallbackChainId?: number +): number | undefined => (isReserveLimitFailure(state) ? state.chainId ?? fallbackChainId : undefined) + // 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/reserveLimitQa.test.ts b/src/pages/gd/Swap/reserveLimitQa.test.ts new file mode 100644 index 000000000..f768952ad --- /dev/null +++ b/src/pages/gd/Swap/reserveLimitQa.test.ts @@ -0,0 +1,99 @@ +import { + canConfirmReserveLimitQa, + canOpenReserveLimitQa, + canUseRealReserveActions, + getReserveActionMode, + isReserveLimitQaActive, + isReserveLimitQaRequested, +} from './reserveLimitQa' +import { getReserveLimitFailureChainId } from './reserveLimitError' + +describe('Reserve-limit QA mode', () => { + it('requires the exact opt-in in a development build and SDK activation', () => { + expect(isReserveLimitQaRequested(true, 'true')).toBe(true) + expect(isReserveLimitQaRequested(true, 'TRUE')).toBe(false) + expect(isReserveLimitQaRequested(false, 'true')).toBe(false) + expect(isReserveLimitQaActive(true, 'true', undefined, true)).toBe(false) + expect(isReserveLimitQaActive(true, 'true', { forceTradeLimits: false }, true)).toBe(false) + expect(isReserveLimitQaActive(true, 'true', { forceTradeLimits: true }, true)).toBe(true) + expect(isReserveLimitQaActive(true, 'true', { forceTradeLimits: true }, false)).toBe(false) + expect(isReserveLimitQaActive(false, 'true', { forceTradeLimits: true }, true)).toBe(false) + }) + + it('lets an empty wallet reach the QA confirmation only with an account, quote and supported chain', () => { + expect( + canOpenReserveLimitQa({ + active: true, + chainId: 42220, + address: '0xabc', + hasValidQuote: true, + }) + ).toBe(true) + expect( + canOpenReserveLimitQa({ + active: true, + chainId: 50, + address: '0xabc', + hasValidQuote: true, + }) + ).toBe(true) + expect( + canOpenReserveLimitQa({ + active: true, + chainId: 122, + address: '0xabc', + hasValidQuote: true, + }) + ).toBe(false) + expect( + canOpenReserveLimitQa({ + active: true, + address: '0xabc', + hasValidQuote: true, + }) + ).toBe(false) + expect( + canOpenReserveLimitQa({ + active: true, + chainId: 42220, + hasValidQuote: false, + }) + ).toBe(false) + }) + + it('prioritizes the labelled QA path over an insufficient balance but keeps normal gating when off', () => { + expect(getReserveActionMode(true, true, true)).toBe('simulate-limit') + expect(getReserveActionMode(true, false, true)).toBe('qa-unavailable') + expect(getReserveActionMode(false, false, true)).toBe('insufficient-balance') + expect(getReserveActionMode(false, false, false)).toBe('normal') + }) + + it('blocks real approval and swap actions while the SDK QA mode is active', () => { + expect(canUseRealReserveActions(true)).toBe(false) + expect(canUseRealReserveActions(false)).toBe(true) + expect(canConfirmReserveLimitQa(true, false)).toBe(false) + expect(canConfirmReserveLimitQa(true, true)).toBe(true) + expect(canConfirmReserveLimitQa(false, false)).toBe(true) + }) + + it.each([ + ['Exception', 42220, 'Celo'], + ['Fail', 42220, 'Celo'], + ['Exception', 50, 'XDC'], + ['Fail', 50, 'XDC'], + ])('routes a classified %s Reserve-limit failure to the modal for %s', (status, chainId, chainName) => { + const modalChainId = getReserveLimitFailureChainId( + { status, chainId, errorType: 'RESERVE_LIMIT' } as any, + 42220 + ) + + expect(modalChainId).toBe(chainId) + expect(modalChainId === 42220 ? 'Celo' : 'XDC').toBe(chainName) + }) + + it('does not classify unrelated failures as Reserve limits', () => { + expect( + getReserveLimitFailureChainId({ status: 'Fail', errorType: undefined } as any, 42220) + ).toBeUndefined() + }) +}) diff --git a/src/pages/gd/Swap/reserveLimitQa.ts b/src/pages/gd/Swap/reserveLimitQa.ts new file mode 100644 index 000000000..c61b68cc5 --- /dev/null +++ b/src/pages/gd/Swap/reserveLimitQa.ts @@ -0,0 +1,40 @@ +export interface ReserveLimitQaCapability { + forceTradeLimits?: boolean +} + +export const isReserveLimitQaRequested = (isDevelopmentBuild: boolean, value?: string): boolean => + isDevelopmentBuild && value === 'true' + +export const isReserveLimitQaActive = ( + isDevelopmentBuild: boolean, + value: string | undefined, + capability: ReserveLimitQaCapability | undefined, + hasLimitModal: boolean +): boolean => + isReserveLimitQaRequested(isDevelopmentBuild, value) && capability?.forceTradeLimits === true && hasLimitModal + +export const canOpenReserveLimitQa = ({ + active, + chainId, + address, + hasValidQuote, +}: { + active: boolean + chainId?: number + address?: string + hasValidQuote: boolean +}): boolean => active && Boolean(address) && hasValidQuote && (chainId === 42220 || chainId === 50) + +export const getReserveActionMode = ( + qaActive: boolean, + canStartQaSimulation: boolean, + balanceNotEnough: boolean +): 'simulate-limit' | 'qa-unavailable' | 'insufficient-balance' | 'normal' => { + if (qaActive) return canStartQaSimulation ? 'simulate-limit' : 'qa-unavailable' + return balanceNotEnough ? 'insufficient-balance' : 'normal' +} + +export const canUseRealReserveActions = (qaActive: boolean): boolean => !qaActive + +export const canConfirmReserveLimitQa = (qaSimulation: boolean, capabilityActive: boolean): boolean => + !qaSimulation || capabilityActive From eccc37b383abfed28e24d8b43375af71a9fb3cf3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:18:34 +0000 Subject: [PATCH 4/7] refactor: share Reserve QA gate helpers Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- src/{pages/gd/Swap => functions}/reserveLimitQa.ts | 0 src/hooks/useWeb3.tsx | 2 +- src/pages/gd/Swap/SwapConfirmModal/index.tsx | 2 +- src/pages/gd/Swap/SwapCore/mentoReserve.tsx | 4 ++-- src/pages/gd/Swap/reserveLimitQa.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/{pages/gd/Swap => functions}/reserveLimitQa.ts (100%) diff --git a/src/pages/gd/Swap/reserveLimitQa.ts b/src/functions/reserveLimitQa.ts similarity index 100% rename from src/pages/gd/Swap/reserveLimitQa.ts rename to src/functions/reserveLimitQa.ts diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index a3021e185..44e40e664 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -19,7 +19,7 @@ import { import { createSdkReadOnlyUrls } from 'functions/rpcProviders' import { getEnv } from 'utils/env' import { isMiniPay, getMiniPayProvider } from 'utils/minipay' -import { isReserveLimitQaRequested } from 'pages/gd/Swap/reserveLimitQa' +import { isReserveLimitQaRequested } from 'functions/reserveLimitQa' type NetworkSettings = { currentNetwork: string diff --git a/src/pages/gd/Swap/SwapConfirmModal/index.tsx b/src/pages/gd/Swap/SwapConfirmModal/index.tsx index afa2274bb..6d9188460 100644 --- a/src/pages/gd/Swap/SwapConfirmModal/index.tsx +++ b/src/pages/gd/Swap/SwapConfirmModal/index.tsx @@ -16,7 +16,7 @@ import { useLingui } from '@lingui/react' import confirmPriceImpactWithoutFee from 'components/swap/confirmPriceImpactWithoutFee' import { Percent } from '@sushiswap/sdk' import useSendAnalyticsData from 'hooks/useSendAnalyticsData' -import { canConfirmReserveLimitQa } from '../reserveLimitQa' +import { canConfirmReserveLimitQa } from 'functions/reserveLimitQa' import ShareTransaction from 'components/ShareTransaction' diff --git a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx index ae36b7b22..3bb0875ca 100644 --- a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx +++ b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx @@ -31,8 +31,8 @@ import { canUseRealReserveActions, getReserveActionMode, isReserveLimitQaActive, -} from '../reserveLimitQa' -import type { ReserveLimitQaCapability } from '../reserveLimitQa' +} from 'functions/reserveLimitQa' +import type { ReserveLimitQaCapability } from 'functions/reserveLimitQa' import QuestionHelper from 'components/QuestionHelper' diff --git a/src/pages/gd/Swap/reserveLimitQa.test.ts b/src/pages/gd/Swap/reserveLimitQa.test.ts index f768952ad..5c689fd16 100644 --- a/src/pages/gd/Swap/reserveLimitQa.test.ts +++ b/src/pages/gd/Swap/reserveLimitQa.test.ts @@ -5,7 +5,7 @@ import { getReserveActionMode, isReserveLimitQaActive, isReserveLimitQaRequested, -} from './reserveLimitQa' +} from '../../../functions/reserveLimitQa' import { getReserveLimitFailureChainId } from './reserveLimitError' describe('Reserve-limit QA mode', () => { From 6d539fe3855ea4f2e2bec51b021d0841cc63ae22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:21:43 +0000 Subject: [PATCH 5/7] fix: read Reserve QA flag from Vite env Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- src/hooks/useWeb3.tsx | 2 +- src/pages/gd/Swap/SwapCore/mentoReserve.tsx | 2 +- src/pages/gd/Swap/reserveLimitQa.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index 44e40e664..e1b79c4bc 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -246,7 +246,7 @@ export function Web3ContextProvider({ children }: { children: ReactNode | ReactN const contractsEnvV2 = network === 'development' ? 'fuse' : network const forceTradeLimitsQa = isReserveLimitQaRequested( import.meta.env.DEV, - process.env.REACT_APP_QA_FORCE_TRADE_LIMITS + import.meta.env.REACT_APP_QA_FORCE_TRADE_LIMITS ) if (!rpcs) return <> diff --git a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx index 3bb0875ca..fa7b80257 100644 --- a/src/pages/gd/Swap/SwapCore/mentoReserve.tsx +++ b/src/pages/gd/Swap/SwapCore/mentoReserve.tsx @@ -69,7 +69,7 @@ const MentoSwap = memo(() => { const { qa: sdkQa } = useWeb3Context() as { qa?: ReserveLimitQaCapability } const qaSimulationActive = isReserveLimitQaActive( import.meta.env.DEV, - process.env.REACT_APP_QA_FORCE_TRADE_LIMITS, + import.meta.env.REACT_APP_QA_FORCE_TRADE_LIMITS, sdkQa, Boolean(ReserveLimitModal) ) diff --git a/src/pages/gd/Swap/reserveLimitQa.test.ts b/src/pages/gd/Swap/reserveLimitQa.test.ts index 5c689fd16..defbfd6fd 100644 --- a/src/pages/gd/Swap/reserveLimitQa.test.ts +++ b/src/pages/gd/Swap/reserveLimitQa.test.ts @@ -11,6 +11,8 @@ import { getReserveLimitFailureChainId } from './reserveLimitError' describe('Reserve-limit QA mode', () => { it('requires the exact opt-in in a development build and SDK activation', () => { expect(isReserveLimitQaRequested(true, 'true')).toBe(true) + expect(isReserveLimitQaRequested(true, undefined)).toBe(false) + expect(isReserveLimitQaRequested(true, 'false')).toBe(false) expect(isReserveLimitQaRequested(true, 'TRUE')).toBe(false) expect(isReserveLimitQaRequested(false, 'true')).toBe(false) expect(isReserveLimitQaActive(true, 'true', undefined, true)).toBe(false) From af936624da73156d86665509d0e37a221276d27e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:41:05 +0000 Subject: [PATCH 6/7] fix: close QA-aware Web3 provider correctly Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- src/hooks/useWeb3.test.ts | 26 ++++++++++++++++++++++++++ src/hooks/useWeb3.tsx | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useWeb3.test.ts diff --git a/src/hooks/useWeb3.test.ts b/src/hooks/useWeb3.test.ts new file mode 100644 index 000000000..224575eed --- /dev/null +++ b/src/hooks/useWeb3.test.ts @@ -0,0 +1,26 @@ +import fs from 'fs' +import path from 'path' +import * as ts from 'typescript' + +describe('Web3 provider wiring', () => { + it('transpiles the provider with the QA-aware opening and closing component tags', () => { + const source = fs.readFileSync(path.join(__dirname, 'useWeb3.tsx'), 'utf8') + const result = ts.transpileModule(source, { + fileName: 'useWeb3.tsx', + reportDiagnostics: true, + compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + }, + }) + const errors = (result.diagnostics || []).filter( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error + ) + + expect(errors.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([]) + expect(source).toContain('') + expect(source).toContain('qa={{ forceTradeLimits: forceTradeLimitsQa }}') + }) +}) diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index e1b79c4bc..82b2b2eb8 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -273,7 +273,7 @@ export function Web3ContextProvider({ children }: { children: ReactNode | ReactN }} > {children} -
+ ) } From ac50050a585a12eeb90401d888735c34f2431635 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:43:54 +0000 Subject: [PATCH 7/7] plan use SDK-typed Web3Provider directly Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- src/hooks/useWeb3.test.ts | 7 ++++--- src/hooks/useWeb3.tsx | 8 ++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/hooks/useWeb3.test.ts b/src/hooks/useWeb3.test.ts index 224575eed..2434322c4 100644 --- a/src/hooks/useWeb3.test.ts +++ b/src/hooks/useWeb3.test.ts @@ -3,7 +3,7 @@ import path from 'path' import * as ts from 'typescript' describe('Web3 provider wiring', () => { - it('transpiles the provider with the QA-aware opening and closing component tags', () => { + it('transpiles the provider with matching SDK tags and QA prop wiring', () => { const source = fs.readFileSync(path.join(__dirname, 'useWeb3.tsx'), 'utf8') const result = ts.transpileModule(source, { fileName: 'useWeb3.tsx', @@ -19,8 +19,9 @@ describe('Web3 provider wiring', () => { ) expect(errors.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([]) - expect(source).toContain('') + expect(source).toContain('') + expect(source).not.toContain('QaAwareWeb3Provider') expect(source).toContain('qa={{ forceTradeLimits: forceTradeLimitsQa }}') }) }) diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index 82b2b2eb8..65a19ace4 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -46,10 +46,6 @@ const RPC_CACHE_KEY = 'GD_RPC_CACHE' const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours const RPC_TEST_TIMEOUT_MS = 5000 -const QaAwareWeb3Provider = Web3Provider as React.ComponentType< - React.ComponentProps & { qa?: { forceTradeLimits?: boolean } } -> - let rpcInitializationPromise: Promise> | null = null async function testRpc(rpcUrl: string): Promise { @@ -261,7 +257,7 @@ export function Web3ContextProvider({ children }: { children: ReactNode | ReactN }, }} > - {children} - + ) }