diff --git a/apps/cowswap-e2e-tests/AGENTS.md b/apps/cowswap-e2e-tests/AGENTS.md index 92fffce2d02..388f8023e93 100644 --- a/apps/cowswap-e2e-tests/AGENTS.md +++ b/apps/cowswap-e2e-tests/AGENTS.md @@ -40,11 +40,24 @@ Required env vars: `INTEGRATION_TEST_PRIVATE_KEY`, `REACT_APP_NETWORK_URL_111551 - Page objects hold `Locator`s as readonly properties set in the constructor, plus action methods (`goto`, `enterSellAmount`, `clickSwap`, ...) that encapsulate waits. Add new locators/actions there, not ad hoc selectors inside a spec. +- **Prefer the `setupTestConditions` fixture** (`src/support/setupTestConditions.ts`) over manually + chaining `goto` + `enterSellAmount` + `waitForQuote` + `mocks.balances.set`/`mocks.allowances.set`. It + wires up the whole "navigate to a trade, fund/allowance the wallet, type an amount, wait for its quote" + flow in one call, takes human-readable amounts (`{ WETH: '1' }`, not raw atoms). Reach for manual page-object calls only for + what `setupTestConditions` doesn't cover, e.g. changing the amount again mid-test. - **Mock-driven scenarios that span multiple endpoints belong on the page object as a method**, not as a - free function in the spec file. Example: `SwapPage.mockSwapFulfillment(cowApi, balances, owner, chainId, - sellTokenBalanceBefore)` sets up `postOrder` + `accountOrders` + `orderStatus` + the balance debit/credit - together, because they describe one coherent thing ("the orderbook fulfilled this order") and every spec - needing that scenario should get it identically. + free function in the spec file. Example: `tradePage.mockOrderPosting(cowApi, owner)` sets up `postOrder` + + `accountOrders` together (the order shows up as `open` the moment it's posted), and returns a handle + whose `fulfill(balances, chainId, sellTokenBalanceBefore, buyTokenBalanceBefore)` you call whenever the test is ready for the + trade to settle — it's what flips `accountOrders` to `fulfilled`, debits/credits `balances`, and makes + `orderStatus` report `traded`. Posting and fulfilling are deliberately separate calls, not one bundled + step, so a spec can assert on the pending/open state before triggering settlement. +- **Prefer real CoW Protocol SDK types over hand-rolled interfaces** when shaping a mock's request/response + body. `@cowprotocol/sdk-order-book` (also re-exported wholesale by `@cowprotocol/cow-sdk`, already a + devDependency here) exports `OrderCreation` (the `postOrder` body), `Order` (an `accountOrders`/`order` + entry), `OrderStatus` (the status enum), and the rest of the real API shapes. Only + hand-roll a type for something genuinely local to this test app (`TradePage`, fixture helper options, + etc.), not for anything that crosses the wire to/from the CoW Protocol API. - Use `test.describe(...)` + `test.beforeEach(...)` for setup every test in a file needs (e.g. giving the wallet a default, sufficient token balance) instead of repeating `mocks.balances.set(...)` in every test body. Individual tests can still override on top for their specific scenario. diff --git a/apps/cowswap-e2e-tests/package.json b/apps/cowswap-e2e-tests/package.json index 356be1c9bc9..b8ea0e1caee 100644 --- a/apps/cowswap-e2e-tests/package.json +++ b/apps/cowswap-e2e-tests/package.json @@ -7,7 +7,8 @@ "license": "ISC", "dependencies": { "viem": "2.48.8", - "@cowprotocol/cow-sdk": "9.2.6" + "@cowprotocol/cow-sdk": "9.2.6", + "@cowprotocol/sdk-order-book": "4.0.2" }, "devDependencies": { "@playwright/test": "1.49.1", diff --git a/apps/cowswap-e2e-tests/scripts/run-test.sh b/apps/cowswap-e2e-tests/scripts/run-test.sh new file mode 100755 index 00000000000..ea11db94d74 --- /dev/null +++ b/apps/cowswap-e2e-tests/scripts/run-test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +set -a +source .env +set +a +npx playwright test "$@" diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index b264122d2d0..33f7635a138 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -2,18 +2,26 @@ import { createRpcProxyHandle, type RpcProxyHandle } from './rpcProxy' import { installAllowances, type AllowancesMock } from '../mocks/allowances' import { installBalances, type BalancesMock } from '../mocks/balances' -import { installBungee, type BungeeMock } from '../mocks/bungee' import { installCowProtocolApi, type CowProtocolApiMock } from '../mocks/cowProtocolApi' -import { installNearIntents, type NearIntentsMock } from '../mocks/nearIntents' +import { installEthBlockNumber } from '../mocks/ethBlockNumber' +import { installEthEstimateGas } from '../mocks/ethEstimateGas' +import { installEthGetCode, type EthGetCodeMock } from '../mocks/ethGetCode' +import { installEthGetTransactionCount } from '../mocks/ethGetTransactionCount' +import { installLaunchDarkly, type LaunchDarklyMock } from '../mocks/launchDarkly' +import { installMulticall3 } from '../mocks/multicall3' import { installSafeSdk, type SafeSdkMock } from '../mocks/safeSdk' import { installTokenLists, type TokenListsMock } from '../mocks/tokenLists' import { installUsdPrices, type UsdPricesMock } from '../mocks/usdPrices' +import { AccountModal } from '../pages/AccountModal' import { AccountPage } from '../pages/AccountPage' import { ConfirmModal } from '../pages/ConfirmModal' import { HeaderPage } from '../pages/HeaderPage' import { LimitPage } from '../pages/LimitPage' import { SwapPage } from '../pages/SwapPage' import { TwapPage } from '../pages/TwapPage' +import { logUnmockedRpcRequests } from '../support/logUnmockedRpcRequests' +import { mockApproveSimulation } from '../support/mockApproveSimulation' +import { mockOrderPosting } from '../support/mockOrderPosting' import { createSetupTestConditions, type SetupTestConditions } from '../support/setupTestConditions' import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions } from '@playwright/test' @@ -23,18 +31,21 @@ export interface SharedFixtures { limitPage: LimitPage twapPage: TwapPage accountPage: AccountPage + accountModal: AccountModal confirmModal: ConfirmModal header: HeaderPage rpcProxy: RpcProxyHandle setupTestConditions: SetupTestConditions + /** Page-agnostic order-mocking helpers shared by swap, limit and TWAP order flows. */ + tradePage: { mockOrderPosting: typeof mockOrderPosting } mocks: { allowances: AllowancesMock balances: BalancesMock cowApi: CowProtocolApiMock + ethGetCode: EthGetCodeMock tokenLists: TokenListsMock safeSdk: SafeSdkMock - bungee: BungeeMock - nearIntents: NearIntentsMock + launchDarkly: LaunchDarklyMock usdPrices: UsdPricesMock } } @@ -62,6 +73,9 @@ export const sharedFixtures: Fixtures< accountPage: async ({ page }, use) => { await use(new AccountPage(page)) }, + accountModal: async ({ page }, use) => { + await use(new AccountModal(page)) + }, confirmModal: async ({ page }, use) => { await use(new ConfirmModal(page)) }, @@ -71,6 +85,9 @@ export const sharedFixtures: Fixtures< setupTestConditions: async ({ wallet, mocks, swapPage, limitPage, twapPage }, use) => { await use(createSetupTestConditions({ wallet, mocks, swapPage, limitPage, twapPage })) }, + tradePage: async ({}, use) => { + await use({ mockOrderPosting }) + }, rpcProxy: async ({}, use, testInfo) => { const handle = createRpcProxyHandle(testInfo) await handle.reset() @@ -82,21 +99,52 @@ export const sharedFixtures: Fixtures< // teardown. A plain (non-auto) fixture is only set up when requested, so without this the // whole mock stack — including `assertNoUnmatched()` — would silently never run. mocks: [ - async ({ context }, use) => { + async ({ context }, use, testInfo) => { + // Diagnostic-only, opt-in via `LOG_UNMOCKED_RPC=1` — see `logUnmockedRpcRequests`'s own doc + // comment. Registered before every other mock below (and therefore before any manually + // installed one too, e.g. `mockSocketVerifier`, since those only get added once the test body + // starts running) so it only ever sees requests nothing else claimed. + if (process.env.LOG_UNMOCKED_RPC) { + logUnmockedRpcRequests({ context, worker: testInfo.workerIndex, test: testInfo.title }) + } + + // The order book API is mocked, so updaters can poll much faster without adding real load. + // See `getUpdaterInterval` in `libs/common-const/src/common.ts`. + await context.addInitScript(() => { + ;(window as unknown as { __COWSWAP_E2E__?: boolean }).__COWSWAP_E2E__ = true + }) + const allowances = installAllowances(context) const balances = installBalances(context) const cowApi = await installCowProtocolApi(context) + const ethGetCode = installEthGetCode(context) + installEthBlockNumber(context) + installEthEstimateGas(context) + installEthGetTransactionCount(context) + installMulticall3(context, { allowances }) + // Fires regardless of whether the UI ever shows an Approve step (confirmed by tracing real + // traffic under `LOG_UNMOCKED_RPC=1` — it hit cross-chain tests that pre-seed a sufficient + // allowance and never click Approve), so this is global rather than opt-in per test. + mockApproveSimulation(context) const tokenLists = installTokenLists(context) const safeSdk = installSafeSdk(context) - const bungee = installBungee(context) - const nearIntents = installNearIntents(context) + const launchDarkly = installLaunchDarkly(context) const usdPrices = installUsdPrices(context) - await use({ allowances, balances, cowApi, tokenLists, safeSdk, bungee, nearIntents, usdPrices }) + await use({ + allowances, + balances, + cowApi, + ethGetCode, + tokenLists, + safeSdk, + launchDarkly, + usdPrices, + }) + ethGetCode.reset() tokenLists.reset() - bungee.reset() - nearIntents.reset() + await launchDarkly.reset() usdPrices.reset() await safeSdk.disable() // Non-fatal, so it must run before the throwing assert below. diff --git a/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts b/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts index ab9862151a6..74832f83f62 100644 --- a/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts +++ b/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts @@ -1,5 +1,12 @@ import { type Hex } from 'viem' +import { + areAddressesEqual, + // eslint-disable-next-line @typescript-eslint/no-restricted-imports + COW_PROTOCOL_VAULT_RELAYER_ADDRESS, + COW_PROTOCOL_VAULT_RELAYER_ADDRESS_STAGING, +} from '@cowprotocol/cow-sdk' + import { classifyCall, encodeAllowanceResult, @@ -11,7 +18,7 @@ import { } from './codec' import { loadAllowancesFixture, parseAllowanceValue } from './fixture' import { hasAnyEntry, isOwnerConfigured, resolveAllowance } from './resolve' -import { normalizeRpcUrl, resolveRpcChainIds, unconfiguredChainIds } from './rpcUrls' +import { normalizeRpcUrl, resolveRpcChainIds } from './rpcUrls' import { allowanceKey, type AllowanceLookup, type AllowanceRead, type AllowanceValue } from './types' import type { BrowserContext, Route } from '@playwright/test' @@ -33,6 +40,15 @@ export interface AllowancesMock { /** Non-fatal warning about queried-but-unconfigured owners and decode failures. */ reportUnknownOwners(): void reset(): void + /** + * Resolve one already-decoded allowance read against the live fixture+override state, bypassing + * the URL-scoped route handler below entirely. Used by `mocks/multicall3.ts`'s host-agnostic + * `aggregate3` handler, which needs the exact same "override wins, else fixture, else 0" answer + * regardless of which real RPC host the app's independent read-only client happened to pick for a + * given batch — going through the same `resolveFor` the route handler itself uses keeps + * `reads()`/`reportUnknownOwners()` bookkeeping accurate no matter which handler answered. + */ + resolve(chainId: number, call: AllowanceCall): bigint } interface JsonRpcEntry { @@ -56,14 +72,14 @@ export function installAllowances(context: BrowserContext): AllowancesMock { '[allowances mock] No REACT_APP_NETWORK_URL_ env var is set, so no RPC traffic is intercepted ' + 'and allowances come from the real node. The suite requires REACT_APP_NETWORK_URL_11155111.', ) - } else { - const missing = unconfiguredChainIds() - if (missing.length > 0) { - console.info(`[allowances mock] not intercepting chains without an RPC override: ${missing.join(', ')}`) - } } function resolveFor(chainId: number, call: AllowanceCall): bigint { + if (!isVaultRelayerSpender(chainId, call.spender)) { + reads.push({ chainId, owner: call.owner, spender: call.spender, token: call.token, value: 0n }) + return 0n + } + const value = resolveAllowance(fixture, overrides, call.owner, chainId, call.token) reads.push({ chainId, owner: call.owner, spender: call.spender, token: call.token, value }) @@ -161,6 +177,9 @@ export function installAllowances(context: BrowserContext): AllowancesMock { unknownOwners.clear() problems.length = 0 }, + resolve(chainId, call) { + return resolveFor(chainId, call) + }, } } @@ -184,6 +203,30 @@ async function fulfillJson(route: Route, body: unknown): Promise { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) } +/** + * `set()`/the committed fixture key on `(owner, chainId, token)` alone — there's no `spender` in + * that key because every test-authored allowance here is really "let the trade proceed", i.e. an + * approval to the CoW VaultRelayer, the only spender any of this suite's trades ever check. Without + * this gate, `resolveFor` would hand that same value back for *any* spender's `allowance()` query on + * that token — including ones with nothing to do with trading. That's exactly what broke the + * cross-chain WETH tests: `useIsAnySwapAffectedUser` queries `allowance(account, ANYSWAP_V4_CONTRACT)` + * for a fixed set of tokens (WETH among them) independent of what's being traded, and a seeded WETH + * VaultRelayer allowance was leaking into that unrelated read, flipping the app into its AnySwap-hack + * warning page instead of the swap form. Gating on the real spender here — rather than widening the + * key to carry one, which would ripple into every `set()` call site, the fixture format, and their + * unit tests — keeps every existing caller's "just let the trade through" intent working while + * making every other spender read as unconfigured (0), matching what the real chain would show for + * an account this suite never actually approved anything on. + */ +function isVaultRelayerSpender(chainId: number, spender: string): boolean { + const vaultRelayer = (COW_PROTOCOL_VAULT_RELAYER_ADDRESS as Record)[chainId] + const vaultRelayerStaging = (COW_PROTOCOL_VAULT_RELAYER_ADDRESS_STAGING as Record)[chainId] + return ( + (vaultRelayer !== undefined && areAddressesEqual(vaultRelayer, spender)) || + (vaultRelayerStaging !== undefined && areAddressesEqual(vaultRelayerStaging, spender)) + ) +} + function localResult( call: ClassifiedCall, chainId: number, diff --git a/apps/cowswap-e2e-tests/src/mocks/bungee.ts b/apps/cowswap-e2e-tests/src/mocks/bungee.ts deleted file mode 100644 index c4e6d878664..00000000000 --- a/apps/cowswap-e2e-tests/src/mocks/bungee.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { BrowserContext, Route } from '@playwright/test' - -export interface BungeeMock { - stubRoute(opts: { sellAmount: string; buyAmount: string; estTimeSec: number }): void - reset(): void -} - -export function installBungee(context: BrowserContext): BungeeMock { - let next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 180 } - - void context.route(/(?:api\.bungee|api\.socket)\..*/i, async (route: Route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - result: { - routes: [{ sellAmount: next.sellAmount, buyAmount: next.buyAmount, estimatedTimeSeconds: next.estTimeSec }], - }, - }), - }) - }) - - return { - stubRoute(opts) { - next = opts - }, - reset() { - next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 180 } - }, - } -} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts b/apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts new file mode 100644 index 00000000000..a2c2f8eef9e --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts @@ -0,0 +1,72 @@ +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string +} + +// An arbitrary-but-real mainnet block number, captured once — nothing in this suite asserts on the +// actual value, so a fixed one is enough to remove the real dependency entirely. +const HARDCODED_BLOCK_NUMBER = '0x188bc6f' + +/** + * `eth_blockNumber` goes out as a single, standalone JSON-RPC call (no Multicall3 batching, same + * as `eth_getCode`) to whichever real RPC/Infura endpoint the app's own independent client picked. + * Traced with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: same class of real, rate-limited + * dependency as `eth_getCode` (`installEthGetCode`) that 429s under `pnpm e2e`'s full parallel + * load. + */ +export function installEthBlockNumber(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.some((entry) => entry?.method === 'eth_blockNumber')) return route.fallback() + + if (entries.every((entry) => entry?.method === 'eth_blockNumber')) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: HARDCODED_BLOCK_NUMBER })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries) + }) +} + +/** + * A mixed batch alongside something else this mock doesn't own — patch only the `eth_blockNumber` + * slots and merge with the real response for the rest, with the same defensive try/catch as the + * allowances/SocketVerifier mocks so a flaky real upstream can't take the whole batch down. + */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[]): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + const payload = upstreamEntries.map((entry) => { + const original = byId.get((entry as JsonRpcEntry).id) + if (!original || original.method !== 'eth_blockNumber') return entry + return { jsonrpc: '2.0', id: original.id, result: HARDCODED_BLOCK_NUMBER } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts b/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts new file mode 100644 index 00000000000..6315984b754 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts @@ -0,0 +1,47 @@ +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string +} + +/** A generous flat estimate — never actually spent, since whatever gets estimated (a `createOrder()` + * call, a `permit()`-based approval, ...) is either stubbed itself or never sent for real. */ +const FAKE_GAS_ESTIMATE = '0x7a120' as const + +/** + * Before sending almost any on-chain tx, the app estimates gas for it via its own default public + * RPC — which, traced live, is *not* `REACT_APP_NETWORK_URL_{chainId}` at all (that only backs this + * suite's own wallet-side dispatch/proxy) but whichever of the app's own hardcoded providers + * (Infura, the WalletConnect RPC relay, ...) it happens to pick, unpredictable and outside this + * test's control. Left unmocked, that's a REAL simulation against the wallet's REAL on-chain state + * (e.g. zero balance, since this is a shared test key with no real funds) and either fails outright + * or, under `pnpm e2e`'s full parallel load, 429s from the real, rate-limited host. + * + * Originally lived only inside `mockEthFlowTransaction` (for the ETH-flow `createOrder()` call + * specifically), but tracing with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1` found the exact + * same `eth_estimateGas` calls, for an EIP-2612 `permit()` approval (`0xd505accf`), in tests that + * never touch `mockEthFlowTransaction` at all — e.g. the cross-chain-to-Solana/Bitcoin tests. Since + * every gas estimate this suite ever needs is fake regardless of what it's for, this is installed + * unconditionally rather than only for ETH-flow tests. Matched host-agnostically by JSON-RPC method + * (like `mockSocketVerifier`) rather than by URL, since there's no fixed host to route on. + */ +export function installEthEstimateGas(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.length || !entries.every((entry) => entry?.method === 'eth_estimateGas')) return route.fallback() + + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: FAKE_GAS_ESTIMATE })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + }) +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts b/apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts new file mode 100644 index 00000000000..105a3fee22d --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts @@ -0,0 +1,107 @@ +import type { BrowserContext, Route } from '@playwright/test' + +export interface EthGetCodeMock { + /** Override the bytecode reported for `address` — e.g. a non-`'0x'` value to simulate a + * smart-contract wallet instead of the default plain EOA. */ + set(address: string, code: string): void + /** Drop every override, back to `'0x'` (plain EOA) for every address. */ + reset(): void +} + +interface JsonRpcEntry { + id: number | string + method: string + params?: [address?: string, ...unknown[]] +} + +/** + * `eth_getCode` (wallet-type detection, e.g. `useIsSmartContractWallet`-style checks run for the + * connected wallet on most page loads) goes out as a single, standalone JSON-RPC call to whichever + * real RPC/Infura endpoint the app's own independent client picked — not the wallet's own + * `REACT_APP_NETWORK_URL_`-overridden channel, and not batched via Multicall3 either (it's + * its own top-level RPC method, not a contract `eth_call`), so none of the other mocks ever see it. + * Traced with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: it accounted for the large majority of + * 429s from a real, rate-limited Infura key once enough parallel workers hit it at once under + * `pnpm e2e`. This suite's mock wallet is always a plain EOA, so reporting no code (`'0x'`) for + * every address by default removes that real dependency entirely. `set()` is there for a future + * test that needs to simulate a smart-contract wallet instead. + */ +export function installEthGetCode(context: BrowserContext): EthGetCodeMock { + const overrides = new Map() + + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.some((entry) => entry?.method === 'eth_getCode')) return route.fallback() + + if (entries.every((entry) => entry?.method === 'eth_getCode')) { + const payload = entries.map((entry) => ({ + jsonrpc: '2.0', + id: entry.id, + result: resolveCode(entry, overrides), + })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries, overrides) + }) + + return { + set(address, code) { + overrides.set(address.toLowerCase(), code) + }, + reset() { + overrides.clear() + }, + } +} + +/** + * A mixed batch alongside something else this mock doesn't own — patch only the `eth_getCode` + * slots and merge with the real response for the rest, with the same defensive try/catch as the + * allowances/SocketVerifier mocks so a flaky real upstream can't take the whole batch down. + */ +async function fulfillFromUpstream( + route: Route, + entries: JsonRpcEntry[], + overrides: Map, +): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + const payload = upstreamEntries.map((entry) => { + const original = byId.get((entry as JsonRpcEntry).id) + if (!original || original.method !== 'eth_getCode') return entry + return { jsonrpc: '2.0', id: original.id, result: resolveCode(original, overrides) } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} + +function resolveCode(entry: JsonRpcEntry, overrides: Map): string { + const address = entry.params?.[0] + const override = address ? overrides.get(address.toLowerCase()) : undefined + return override ?? '0x' +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts b/apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts new file mode 100644 index 00000000000..90bc88d5e2f --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts @@ -0,0 +1,73 @@ +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string +} + +// This suite's test wallets are always fresh (a nonce of 0 is genuinely accurate, not just +// convenient), so a single hardcoded value covers every address/block-tag combination. +const HARDCODED_TRANSACTION_COUNT = '0x0' + +/** + * `eth_getTransactionCount` (the wallet's own nonce) goes out as a single, standalone JSON-RPC call + * (no Multicall3 batching, same as `eth_blockNumber`/`eth_getCode`) to whichever real RPC/Infura + * endpoint the app's own independent client picked. Traced with + * `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: same class of real, rate-limited dependency as + * `eth_blockNumber` (`installEthBlockNumber`) that 429s under `pnpm e2e`'s full parallel load. + */ +export function installEthGetTransactionCount(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.some((entry) => entry?.method === 'eth_getTransactionCount')) return route.fallback() + + if (entries.every((entry) => entry?.method === 'eth_getTransactionCount')) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: HARDCODED_TRANSACTION_COUNT })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries) + }) +} + +/** + * A mixed batch alongside something else this mock doesn't own — patch only the + * `eth_getTransactionCount` slots and merge with the real response for the rest, with the same + * defensive try/catch as the allowances/SocketVerifier mocks so a flaky real upstream can't take + * the whole batch down. + */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[]): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + const payload = upstreamEntries.map((entry) => { + const original = byId.get((entry as JsonRpcEntry).id) + if (!original || original.method !== 'eth_getTransactionCount') return entry + return { jsonrpc: '2.0', id: original.id, result: HARDCODED_TRANSACTION_COUNT } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts b/apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts new file mode 100644 index 00000000000..40e7828e6da --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts @@ -0,0 +1,50 @@ +import type { BrowserContext } from '@playwright/test' + +/** + * `BridgeProvidersUpdater` keeps every bridge provider except Bungee disabled until all three + * `is*BridgeProviderEnabled` flags resolve to an actual boolean (see + * `entities/bridgeProvider/BridgeProvidersUpdater.ts`) — with no LaunchDarkly client-side ID + * configured in this suite's env, the real SDK never even attempts the flag-evaluation request + * (confirmed by tracing network traffic: only a `/sdk/goals/` call fires, never `/sdk/evalx/...`), + * so those flags never resolve and Near Intents/Across can never turn on. Rather than mock + * LaunchDarkly's network calls, `useFeatureFlags` (`libs/common-hooks/src/useFeatureFlags.ts`) + * reads `window.__COWSWAP_E2E_FEATURE_FLAGS__` directly and merges it over the (permanently + * unresolved) real flags — set here via `context.addInitScript`, so it's in place before the + * app's first render, no network round-trip or race to win. + * + * Bungee itself doesn't need any of this: it's added to the provider set synchronously at module + * load (`tradingSdk/bridgingSdk.ts`), before flags ever matter. + */ +export interface LaunchDarklyMock { + setFlag(key: string, value: boolean): Promise + reset(): Promise +} + +const DEFAULT_FLAGS: Readonly> = { + isBungeeBridgeProviderEnabled: true, + isNearIntentsBridgeProviderEnabled: true, + isAcrossBridgeProviderEnabled: false, +} + +export function installLaunchDarkly(context: BrowserContext): LaunchDarklyMock { + let flags: Record = { ...DEFAULT_FLAGS } + + async function applyInitScript(): Promise { + await context.addInitScript((flagsToApply: Record) => { + ;( + window as unknown as { __COWSWAP_E2E_FEATURE_FLAGS__?: Record } + ).__COWSWAP_E2E_FEATURE_FLAGS__ = flagsToApply + }, flags) + } + + return { + async setFlag(key, value) { + flags = { ...flags, [key]: value } + await applyInitScript() + }, + async reset() { + flags = { ...DEFAULT_FLAGS } + await applyInitScript() + }, + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/multicall3.ts b/apps/cowswap-e2e-tests/src/mocks/multicall3.ts new file mode 100644 index 00000000000..109f1243e01 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/multicall3.ts @@ -0,0 +1,290 @@ +import { decodeAbiParameters, encodeAbiParameters, type Hex } from 'viem' + +import { areAddressesEqual, getAddressKey } from '@cowprotocol/cow-sdk' + +import { AGGREGATE3_SELECTOR, ALLOWANCE_SELECTOR, encodeAllowanceResult, type AllowanceCall } from './allowances/codec' +import { normalizeRpcUrl, resolveRpcChainIds } from './allowances/rpcUrls' + +import { CHAIN_IDS } from '../support/constants' + +import type { AllowancesMock } from './allowances' +import type { BrowserContext, Route } from '@playwright/test' + +/** Canonical Multicall3 deployment address — identical on every EVM chain. */ +const MULTICALL3_ADDRESS = '0xca11bde05977b3631167028862be2a173976ca11' +/** `getEthBalance(address)` on Multicall3 itself. */ +const GET_ETH_BALANCE_SELECTOR = '0x4d2301cc' +/** ERC20 `balanceOf(address)`. */ +const BALANCE_OF_SELECTOR = '0x70a08231' + +const CALL3_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, +] as const + +const RESULT_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, +] as const + +const ADDRESS_PAIR = [{ type: 'address' }, { type: 'address' }] as const +const UINT256 = [{ type: 'uint256' }] as const +const ZERO_UINT256 = encodeAbiParameters(UINT256, [0n]) + +interface BatchCall { + kind: 'batch' + calls: ClassifiedCall[] +} +type ClassifiedCall = AllowanceCall | BatchCall | OpaqueCall | UnknownCall | ZeroCall +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] +} +interface OpaqueCall { + kind: 'opaque' +} +interface ResultSlot { + success: boolean + returnData: Hex +} +interface UnknownCall { + kind: 'unknown' +} + +interface ZeroCall { + kind: 'zero' +} + +const OPAQUE: OpaqueCall = { kind: 'opaque' } +const UNKNOWN: UnknownCall = { kind: 'unknown' } +const ZERO: ZeroCall = { kind: 'zero' } + +/** + * Host-agnostic fallback for Multicall3's `aggregate3` — the single biggest source of real, + * rate-limited RPC traffic seen in `logUnmockedRpcRequests`' output (`LOG_UNMOCKED_RPC=1`): 87 of + * ~143 unmocked lines in one traced run, 22 of them real `429`s. The app's independent read-only + * RPC client (see `mockSocketVerifier.ts`'s doc comment, and the cross-chain-swaps `AGENTS.md` + * note on it) doesn't reliably use the wallet's own `REACT_APP_NETWORK_URL_` endpoint, so + * `mocks/allowances`'s URL-scoped handler misses any batch that lands on a different real host + * (Infura, the WalletConnect RPC relay, publicnode, ...). `mockSocketVerifier` is host-agnostic but + * only installed for Bungee-provider cross-chain tests, and only resolves its own SocketVerifier + * selectors — everything else inside the batch still falls through to a real (if now safely + * try/caught) `route.fetch()`. + * + * This mock closes that gap generally: it engages for *any* `eth_call` whose decoded body is (or + * contains, once batches are unwrapped) an `aggregate3` call to the canonical Multicall3 address, + * *except* on a host `mocks/allowances` already owns (any `REACT_APP_NETWORK_URL_` + * override) — those defer immediately via `route.fallback()`, since `mocks/allowances`'s + * URL-scoped handler already knows the exact chain id for that host and resolves allowances + * correctly; this mock's own `chainIdFromUrl` is a heuristic (see its doc comment) that guesses + * mainnet absent better information, and guessing wrong for a *configured* host — e.g. Sepolia's + * `ethereum-sepolia-rpc.publicnode.com` — silently resolved a seeded allowance against the wrong + * chain key and made it read back as unconfigured (0), breaking `[LO-01]` and any other + * Sepolia-based test relying on `mocks.allowances.set(...)`. So this mock only ever engages for + * hosts *not* in that map — the genuinely unpredictable ones (Infura, the WalletConnect RPC relay, + * publicnode-for-a-different-chain, ...) `mocks/allowances` was never scoped to reach — and fully + * resolves those locally. Anything it doesn't own inside the batch (including SocketVerifier's own + * selectors, when `mockSocketVerifier` isn't active) gets a safe empty success slot instead of a + * real network round-trip. + */ +export function installMulticall3(context: BrowserContext, deps: { allowances: AllowancesMock }): void { + const configuredChainIdByUrl = resolveRpcChainIds() + + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + if (isConfiguredHost(request.url(), configuredChainIdByUrl)) return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = JSON.parse(request.postData() ?? '') as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + const classified = entries.map((entry) => { + if (entry.method !== 'eth_call') return OPAQUE + const call = entry.params?.[0] + if (!call?.to || !call?.data) return OPAQUE + return classifyTopLevel(call.to, call.data) + }) + + if (classified.every((call) => call.kind === 'opaque')) return route.fallback() + + const chainId = chainIdFromUrl(request.url()) + + if (classified.every((call) => call.kind === 'batch')) { + const payload = entries.map((entry, index) => ({ + jsonrpc: '2.0', + id: entry.id, + result: encodeBatchResult(classified[index] as BatchCall, chainId, deps.allowances), + })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries, classified, chainId, deps.allowances) + }) +} + +/** + * Best-effort chain id for a host-agnostic request that's *not* on a configured host (those defer + * entirely, see `isConfiguredHost`) — there's no `REACT_APP_NETWORK_URL_` -> chain id + * mapping for an unpredictable host by definition, so this looks for the `chainId=eip155:` + * query param WalletConnect's RPC relay puts on its URLs (e.g. + * `rpc.walletconnect.org/v1/?chainId=eip155%3A1&...`), then falls back to mainnet. This is a + * heuristic based on what `logUnmockedRpcRequests` has actually observed (every logged + * `aggregate3` occurrence on an unconfigured host so far has been mainnet), not a general solution + * — a non-mainnet occurrence would resolve allowances against the wrong chain and needs a real fix + * (threading the chain id through some other signal) rather than another special case here. + */ +function chainIdFromUrl(rawUrl: string): number { + try { + const raw = new URL(rawUrl).searchParams.get('chainId') + const match = raw ? /^eip155:(\d+)$/.exec(raw) : null + if (match) return Number(match[1]) + } catch { + // Malformed URL — fall through to the mainnet default below. + } + return CHAIN_IDS.MAINNET +} + +/** + * Classifies one inner call by selector alone (not `to`) — same rationale as + * `allowances/codec.ts`'s `classifyCall`: calldata that decodes as `aggregate3` is a nested batch + * whatever it's addressed to, and unrecognized selectors default to `unknown` rather than + * `opaque`, since (unlike the top-level entry) this is always resolved locally once the outer + * `aggregate3` shape has been recognized. + */ +function classifyInner(to: string, data: string): ClassifiedCall { + const selector = data.slice(0, 10).toLowerCase() + + if (selector === ALLOWANCE_SELECTOR) return decodeAllowance(to, data) + if (selector === GET_ETH_BALANCE_SELECTOR || selector === BALANCE_OF_SELECTOR) return ZERO + if (selector === AGGREGATE3_SELECTOR) return decodeBatch(data, classifyInner) + return UNKNOWN +} + +/** Classifies the top-level `eth_call` — only an `aggregate3` call to Multicall3 itself engages this mock. */ +function classifyTopLevel(to: string, data: string): ClassifiedCall { + const selector = data.slice(0, 10).toLowerCase() + if (!areAddressesEqual(to, MULTICALL3_ADDRESS) || selector !== AGGREGATE3_SELECTOR) return OPAQUE + return decodeBatch(data, classifyInner) +} + +/** Mirrors `allowances/codec.ts`'s `classifyAllowance` decode step, normalizing via the same `getAddressKey`. */ +function decodeAllowance(to: string, data: string): ClassifiedCall { + try { + const [owner, spender] = decodeAbiParameters(ADDRESS_PAIR, `0x${data.slice(10)}` as Hex) + return { kind: 'allowance', token: getAddressKey(to), owner: getAddressKey(owner), spender: getAddressKey(spender) } + } catch { + return UNKNOWN + } +} + +/** Decodes an `aggregate3` payload into its inner calls, recursing for nested batches. */ +function decodeBatch(data: string, classify: (to: string, data: string) => ClassifiedCall): ClassifiedCall { + try { + const [calls] = decodeAbiParameters(CALL3_TUPLE, `0x${data.slice(10)}` as Hex) + return { + kind: 'batch', + calls: (calls as ReadonlyArray<{ target: string; callData: Hex }>).map((c) => classify(c.target, c.callData)), + } + } catch { + return OPAQUE + } +} + +function encodeBatchResult(call: BatchCall, chainId: number, allowances: AllowancesMock): Hex { + const slots: ResultSlot[] = call.calls.map((inner) => resolveSlot(inner, chainId, allowances)) + return encodeAbiParameters(RESULT_TUPLE, [slots]) +} + +/** + * A mixed batch alongside something this mock doesn't recognize as `aggregate3`-to-Multicall3 (rare + * — the log evidence shows this almost always arrives as a single `eth_call`) — same defensive + * try/catch as every other host-agnostic mock in this suite (`mockSocketVerifier`, + * `installEthBlockNumber`, `installEthGetCode`), patching only the recognized slots and forwarding + * the rest of the real response untouched. + */ +async function fulfillFromUpstream( + route: Route, + entries: JsonRpcEntry[], + classified: ClassifiedCall[], + chainId: number, + allowances: AllowancesMock, +): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + + const classifiedById = new Map() + entries.forEach((entry, index) => classifiedById.set(entry.id, classified[index])) + + const payload = upstreamEntries.map((entry) => { + const call = classifiedById.get(entry.id) + if (!call || call.kind !== 'batch') return entry + return { jsonrpc: '2.0', id: entry.id, result: encodeBatchResult(call, chainId, allowances) } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} + +/** Whether `rawUrl` is one of `mocks/allowances`'s own `REACT_APP_NETWORK_URL_`-configured + * hosts — if so, this mock must not answer at all; see `installMulticall3`'s doc comment. */ +function isConfiguredHost(rawUrl: string, configuredChainIdByUrl: Map): boolean { + try { + return configuredChainIdByUrl.has(normalizeRpcUrl(rawUrl)) + } catch { + return false + } +} + +/** + * Resolves one decoded inner call to its Multicall3 result slot. + * + * - `allowance` reads through `deps.allowances`'s live fixture/override state, so + * `mocks.allowances.set(...)` is honored no matter which real host answered the batch. + * - `zero` (Multicall3's own `getEthBalance` and ERC20 `balanceOf`) always returns `0`: balances in + * this suite are tracked via the balances-watcher SSE mock (`mocks/balances`), not via RPC reads, + * so there's no existing mocked state to reuse here, and these Multicall3 reads are typically for + * auxiliary/throwaway addresses (e.g. a bridging deposit address), not the tracked test wallet. A + * `set()`-style override could be added later if a specific test needs a non-zero value. + * - `batch` recurses; `unknown`/`opaque` get a safe empty success slot rather than ever triggering a + * real `route.fetch()` — the core fix this mock exists for. + */ +function resolveSlot(call: ClassifiedCall, chainId: number, allowances: AllowancesMock): ResultSlot { + if (call.kind === 'allowance') { + return { success: true, returnData: encodeAllowanceResult(allowances.resolve(chainId, call)) } + } + if (call.kind === 'zero') { + return { success: true, returnData: ZERO_UINT256 } + } + if (call.kind === 'batch') { + return { success: true, returnData: encodeBatchResult(call, chainId, allowances) } + } + return { success: true, returnData: '0x' } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts deleted file mode 100644 index 7778c7ecaa0..00000000000 --- a/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { BrowserContext, Route } from '@playwright/test' - -export interface NearIntentsMock { - stubRoute(opts: { sellAmount: string; buyAmount: string; estTimeSec: number }): void - reset(): void -} - -export function installNearIntents(context: BrowserContext): NearIntentsMock { - let next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 240 } - - void context.route(/(?:api\.near-intents|near-intents\.org)/i, async (route: Route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - intent: { - sellAmount: next.sellAmount, - buyAmount: next.buyAmount, - estimatedTimeSeconds: next.estTimeSec, - provider: 'near', - }, - }), - }) - }) - - return { - stubRoute(opts) { - next = opts - }, - reset() { - next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 240 } - }, - } -} diff --git a/apps/cowswap-e2e-tests/src/pages/AccountModal.ts b/apps/cowswap-e2e-tests/src/pages/AccountModal.ts new file mode 100644 index 00000000000..97735ad4f81 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/pages/AccountModal.ts @@ -0,0 +1,32 @@ +import type { Page, Locator } from '@playwright/test' + +/** + * The wallet-details side panel opened from the header's connected-wallet button. It has no + * `role="dialog"` and no dedicated close button locator (the panel's own `X` has no accessible + * name) — `#web3-status-connected` is a toggle, so clicking it again is the panel's close action + * too. + */ +export class AccountModal { + readonly page: Page + readonly toggleButton: Locator + readonly activitiesList: Locator + /** Confirms cancellation in `RequestCancellationModal`, opened via an activity row's "Cancel order" link. */ + readonly requestCancellationButton: Locator + + constructor(page: Page) { + this.page = page + this.toggleButton = page.locator('#web3-status-connected') + this.activitiesList = page.locator('#account-activities-list') + this.requestCancellationButton = page.getByRole('button', { name: 'Request cancellation' }) + } + + async open(): Promise { + await this.toggleButton.click() + await this.activitiesList.waitFor({ state: 'visible' }) + } + + async close(): Promise { + await this.toggleButton.click() + await this.activitiesList.waitFor({ state: 'hidden' }) + } +} diff --git a/apps/cowswap-e2e-tests/src/pages/ConfirmModal.ts b/apps/cowswap-e2e-tests/src/pages/ConfirmModal.ts index 973479e334f..47893843c52 100644 --- a/apps/cowswap-e2e-tests/src/pages/ConfirmModal.ts +++ b/apps/cowswap-e2e-tests/src/pages/ConfirmModal.ts @@ -1,17 +1,25 @@ -import type { Page, Locator } from '@playwright/test' +import { Page, Locator, expect } from '@playwright/test' export class ConfirmModal { + readonly page: Page readonly confirmButton: Locator readonly priceUpdatedBanner: Locator readonly minimumReceive: Locator constructor(page: Page) { + this.page = page this.confirmButton = page.locator('#trade-confirmation > button') this.priceUpdatedBanner = page.getByText(/price updated/i) this.minimumReceive = page.getByText(/minimum receive/i) } async confirm(): Promise { + await expect(this.confirmButton).toBeEnabled() await this.confirmButton.click() } + + /** One of the modal's labeled `.confirm-order-amount` rows (e.g. "Maximum sent", "Expected to receive", "Minimum receive"). */ + amountRow(label: string): Locator { + return this.page.locator('.confirm-order-amount', { hasText: label }) + } } diff --git a/apps/cowswap-e2e-tests/src/pages/HeaderPage.ts b/apps/cowswap-e2e-tests/src/pages/HeaderPage.ts index 16d399141c0..d34dadef2a8 100644 --- a/apps/cowswap-e2e-tests/src/pages/HeaderPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/HeaderPage.ts @@ -5,11 +5,13 @@ export class HeaderPage { readonly page: Page readonly header: Locator readonly networkDialog: Locator + readonly snackbarPopup: Locator constructor(page: Page) { this.page = page this.header = page.locator('#cowswap-app-header') this.networkDialog = page.getByRole('dialog') + this.snackbarPopup = page.locator('.snackbar-popup').first() } /** diff --git a/apps/cowswap-e2e-tests/src/pages/LimitPage.ts b/apps/cowswap-e2e-tests/src/pages/LimitPage.ts index e9e5ec2a364..a00b5fda71e 100644 --- a/apps/cowswap-e2e-tests/src/pages/LimitPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/LimitPage.ts @@ -1,3 +1,5 @@ +import { expect } from '@playwright/test' + import type { TradePage } from './TradePage' import type { Page, Locator } from '@playwright/test' @@ -8,6 +10,10 @@ export class LimitPage implements TradePage { readonly placeOrderButton: Locator readonly unlockButton: Locator readonly arrowSeparator: Locator + readonly orderSubmittedHeading: Locator + readonly continueButton: Locator + readonly openOrdersTab: Locator + readonly ordersTable: Locator constructor(page: Page) { this.page = page @@ -16,6 +22,10 @@ export class LimitPage implements TradePage { this.placeOrderButton = page.locator('#do-trade-button') this.unlockButton = page.locator('#unlock-limit-orders-btn') this.arrowSeparator = page.locator('#currency-arrow-separator') + this.orderSubmittedHeading = page.getByRole('heading', { name: 'Order Submitted' }) + this.continueButton = page.getByRole('button', { name: /continue/i }) + this.openOrdersTab = page.locator('.orders-table_tab', { hasText: 'Open' }) + this.ordersTable = page.locator('#orders-table') } async goto(opts: { chainId: number; sell?: string; buy?: string }): Promise { @@ -26,11 +36,21 @@ export class LimitPage implements TradePage { } // The first visit shows an "unlock" intro screen instead of the order form — dismiss it. + // The click can be swallowed by the trade widget's own state-reconciliation effects (chain/ + // provider sync still settling right after navigation, especially under CI load) — retry the + // click until the form actually shows up instead of firing it once and hoping it stuck. private async unlockIfNeeded(): Promise { await this.unlockButton.or(this.inputAmount).first().waitFor({ state: 'visible' }) - if (await this.unlockButton.isVisible()) { - await this.unlockButton.click() - } + if (!(await this.unlockButton.isVisible())) return + + await expect + .poll(async () => { + if (await this.unlockButton.isVisible()) { + await this.unlockButton.click() + } + return this.inputAmount.isVisible() + }) + .toBe(true) } async setLimitPrice(value: string): Promise { @@ -38,6 +58,7 @@ export class LimitPage implements TradePage { } async placeOrder(): Promise { + await expect(this.placeOrderButton).toBeEnabled() await this.placeOrderButton.click() } diff --git a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts index 02b58d8ba1b..fe761ed0db9 100644 --- a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts @@ -1,28 +1,10 @@ +import { expect } from '@playwright/test' + import { TokenSelector } from './TokenSelector' import type { TradePage } from './TradePage' -import type { BalancesMock } from '../mocks/balances' -import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' import type { Page, Locator } from '@playwright/test' -interface PostOrderBody { - sellToken: string - buyToken: string - sellAmount: string - buyAmount: string - receiver: string - validTo: number - appData: string - appDataHash: string - feeAmount: string - kind: string - partiallyFillable: boolean - sellTokenBalance: string - buyTokenBalance: string - signingScheme: string - signature: string -} - export class SwapPage implements TradePage { readonly page: Page readonly inputAmount: Locator @@ -31,6 +13,16 @@ export class SwapPage implements TradePage { readonly buyBalance: Locator readonly swapButton: Locator readonly approveButton: Locator + /** + * The actual primary CTA once form validation passes: `#do-trade-button` for a plain swap, but + * `TradeApproveButton`'s `#approve-trade-button` instead whenever an ERC-20 allowance decision + * applies (e.g. every cross-chain swap here) — regardless of whether its own label says + * "Approve..." or, once the mocked allowance already covers the trade, "Swap and Bridge". + * `swapButton`/`#do-trade-button` alone still covers every *disabled*, validation-blocking state + * (`ButtonError` also renders under that same id), so this is only for the final ready-to-submit + * click and its enabled/text assertions. + */ + readonly primaryActionButton: Locator readonly arrowSeparator: Locator readonly maxButton: Locator readonly openOrders: Locator @@ -39,7 +31,35 @@ export class SwapPage implements TradePage { readonly orderProgressBarModal: Locator readonly sellTokenSelect: Locator readonly buyTokenSelect: Locator - readonly priceImpactWarning: Locator + readonly sellFiatAmount: Locator + readonly buyFiatAmount: Locator + readonly priceImpact: Locator + readonly priceImpactTooltipTrigger: Locator + readonly receiveAmountLabel: Locator + readonly receiveAmountTooltipTrigger: Locator + readonly receiveAmountValue: Locator + /** `AddressInputPanel`'s wrapping `ReceiverPanel` — `id="recipient"` set by `SetRecipient`. */ + readonly recipientPanel: Locator + /** `AddressInputPanel.tsx`'s default className on the `` itself. */ + readonly recipientInput: Locator + readonly recipientPasteButton: Locator + /** Hardcoded id on `ReceiverConfirmationRow.pure.tsx`'s "confirm this is the right chain" checkbox. */ + readonly recipientConfirmationCheckbox: Locator + /** `TradeApproveButton`'s toggle between "Partial approval" (a finite, trade-tied amount) and infinite (MaxUint256). */ + readonly approveModeSelector: Locator + readonly settingsDialogButton: Locator + readonly slippageInput: Locator + /** + * This validation state's button doesn't carry the `#do-trade-button` id the ordinary + * swap/approve states do (`validateTradeForm.ts`'s `WrapUnwrapFlow`), so it's matched by text. + */ + readonly wrapButton: Locator + readonly unwrapButton: Locator + /** + * `TradeFormBlankButton`'s "Connect Wallet" — doesn't carry `#do-trade-button` either, and the + * header has its own, differently-cased "Connect wallet" button, so this is matched `exact`. + */ + readonly connectWalletButton: Locator constructor(page: Page) { this.page = page @@ -54,15 +74,42 @@ export class SwapPage implements TradePage { // which is the only titled element in either panel outside USD-values mode. this.sellBalance = page.locator('#input-currency-input .currency-balance-text > span') this.buyBalance = page.locator('#output-currency-input .currency-balance-text > span') + this.sellFiatAmount = page.locator('#input-currency-input [data-testid="fiat-amount"]') + this.buyFiatAmount = page.locator('#output-currency-input [data-testid="fiat-amount"]') + // Only the output panel receives `priceImpactParams` (`TradeWidgetForm`), so price impact + // only ever renders next to the buy-side USD estimation. + this.priceImpact = page.locator('#output-currency-input [data-testid="price-impact"]') + // `HoverTooltip`'s mouseenter/mouseleave handlers sit on the innermost wrapper div around the + // "(X%)" text, not on the outer `[data-testid]` span — hovering the outer span can land the + // pointer outside that inner div's box and never open the tooltip. + this.priceImpactTooltipTrigger = this.priceImpact.locator('div div') + // `ReceiveAmount` renders as a sibling of `#output-currency-input`, not inside it — its + // "Receive (incl. fees)" label and the `HelpTooltip` icon next to it (the real hover hitbox, + // same `HoverTooltip` quirk as `priceImpactTooltipTrigger` above) are the label's next sibling. + this.receiveAmountLabel = page.getByText('Receive (incl. fees)', { exact: true }) + this.receiveAmountTooltipTrigger = this.receiveAmountLabel.locator('xpath=following-sibling::*[1]') + // The exact " " value lives in `ReceiveAmountValue`'s own `title`, one level + // above `TokenAmount`'s inner titled span — same convention as `sellBalance`/`buyBalance`. + this.receiveAmountValue = this.receiveAmountLabel.locator('xpath=../..').locator('[title]').first() this.swapButton = page.locator('#do-trade-button') this.approveButton = page.locator('#approve-trade-button') + this.primaryActionButton = page.locator('#do-trade-button, #approve-trade-button') this.arrowSeparator = page.locator('#currency-arrow-separator') this.maxButton = page.getByRole('button', { name: /^max$/i }) this.openOrders = page.locator('[data-testid="open-orders-list"]') this.unlockButton = page.locator('#unlock-cross-chain-swap-btn') this.orderProgressBarModal = page.locator('#order-progress-bar-modal') - this.priceImpactWarning = page.getByText('Price impact unknown - trade carefully') this.tokens = new TokenSelector(page) + this.recipientPanel = page.locator('#recipient') + this.recipientInput = page.locator('input.recipient-address-input') + this.recipientPasteButton = this.recipientPanel.getByText('Paste', { exact: true }) + this.recipientConfirmationCheckbox = page.locator('#receiver-confirmation') + this.approveModeSelector = page.locator('.approve-mode-selector') + this.settingsDialogButton = page.locator('#open-settings-dialog-button') + this.slippageInput = page.locator('#slippage-input') + this.wrapButton = page.getByRole('button', { name: 'Wrap', exact: true }) + this.unwrapButton = page.getByRole('button', { name: 'Unwrap', exact: true }) + this.connectWalletButton = page.getByRole('button', { name: 'Connect Wallet', exact: true }) } async goto(opts: { chainId: number; sell?: string; buy?: string }): Promise { @@ -73,11 +120,23 @@ export class SwapPage implements TradePage { } // The first visit shows an "unlock" intro screen instead of the order form — dismiss it. - private async unlockIfNeeded(): Promise { + // Public: `MockWalletApi.openApp()` navigates directly (bypassing `goto()`), so callers using + // it need to dismiss this screen themselves the same way. + // The click can be swallowed by the trade widget's own state-reconciliation effects (chain/ + // provider sync still settling right after navigation, especially under CI load) — retry the + // click until the form actually shows up instead of firing it once and hoping it stuck. + async unlockIfNeeded(): Promise { await this.unlockButton.or(this.inputAmount).first().waitFor({ state: 'visible' }) - if (await this.unlockButton.isVisible()) { - await this.unlockButton.click() - } + if (!(await this.unlockButton.isVisible())) return + + await expect + .poll(async () => { + if (await this.unlockButton.isVisible()) { + await this.unlockButton.click() + } + return this.inputAmount.isVisible() + }) + .toBe(true) } async waitForQuote(): Promise { @@ -93,102 +152,31 @@ export class SwapPage implements TradePage { await this.inputAmount.fill(amount) } + async enterBuyAmount(amount: string): Promise { + await this.outputAmount.fill(amount) + } + async clickMax(): Promise { await this.maxButton.click() } async clickSwap(): Promise { + // The button briefly disables itself while price impact is still being computed ("Price + // impact unknown") right after a fresh quote lands — clicking during that window is a no-op. + await expect(this.swapButton).toBeEnabled() await this.swapButton.click() } - /** - * Emulates the orderbook fulfilling whatever order gets posted next: keeps the balances mock - * in sync with the trade (debits the sell token, credits the buy token), makes `accountOrders` - * include it as `fulfilled`, and makes `orderStatus` report it as `traded` — the three things - * the real backend would eventually reflect once the trade settles on-chain. - * - * Returns a handle to read the posted buyAmount back once the order goes through, since the - * app applies its own slippage on top of the quote — a caller asserting on the resulting - * balance needs the amount that was actually posted, not the pre-slippage quote. - */ - mockSwapFulfillment( - cowApi: CowProtocolApiMock, - balances: BalancesMock, - owner: string, - chainId: number, - sellTokenBalanceBefore: bigint, - buyTokenBalanceBefore: bigint, - ): { getPostedBuyAmount(): string } { - let postedOrder: Record | null = null - let postedBuyAmount = '' - - // Starts out as the plain fixture list; once the order below is posted, this starts - // prepending it — fulfilled — so "My orders" reflects the trade emulated as settled in the - // orderbook, without the app ever seeing a real fill on-chain. - cowApi.set('accountOrders', (req) => { - const defaults = req.defaults as unknown[] - return postedOrder ? [postedOrder, ...defaults] : defaults - }) - - cowApi.set('postOrder', (req) => { - const body = req.body as PostOrderBody - const uid = req.defaults as string - postedBuyAmount = body.buyAmount - - balances.set(owner, chainId, { - [body.sellToken]: (sellTokenBalanceBefore - BigInt(body.sellAmount)).toString(), - [body.buyToken]: (buyTokenBalanceBefore + BigInt(body.buyAmount)).toString(), - }) - - postedOrder = { - creationDate: new Date().toISOString(), - owner, - uid, - availableBalance: null, - executedBuyAmount: body.buyAmount, - executedSellAmount: body.sellAmount, - executedSellAmountBeforeFees: body.sellAmount, - executedFeeAmount: '0', - executedFee: '123000000000', - executedFeeToken: body.sellToken, - invalidated: false, - status: 'fulfilled', - class: 'market', - settlementContract: '0xf553d092b50bdcbdded1a99af2ca29fbe5e2cb13', - isLiquidityOrder: false, - fullAppData: body.appData, - sellToken: body.sellToken, - buyToken: body.buyToken, - receiver: body.receiver, - sellAmount: body.sellAmount, - buyAmount: body.buyAmount, - validTo: body.validTo, - appData: body.appDataHash, - feeAmount: body.feeAmount, - kind: body.kind, - partiallyFillable: body.partiallyFillable, - sellTokenBalance: body.sellTokenBalance, - buyTokenBalance: body.buyTokenBalance, - signingScheme: body.signingScheme, - signature: body.signature, - interactions: { pre: [], post: [] }, - } - - // Order-progress polls this once the order exists — "traded" is what moves it past - // "still searching" to a fulfilled state, mirroring the same fill emulated above. - cowApi.set('orderStatus', () => ({ - type: 'traded', - value: [ - { - solver: '0x99b4136666ca1d13020830350ca8d01a0e5e466b', - executedAmounts: { sell: body.sellAmount, buy: body.buyAmount }, - }, - ], - })) - - return req.defaults - }) + async clickPrimaryAction(): Promise { + await expect(this.primaryActionButton).toBeEnabled() + await this.primaryActionButton.click() + } - return { getPostedBuyAmount: () => postedBuyAmount } + /** Opens the settings dropdown, sets a custom slippage percentage, and closes it again. */ + async setSlippage(percent: string): Promise { + await this.settingsDialogButton.click() + await this.slippageInput.fill(percent) + await this.slippageInput.blur() + await this.page.keyboard.press('Escape') } } diff --git a/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts b/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts index 23da36ab69c..de2397e7766 100644 --- a/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts +++ b/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts @@ -1,3 +1,5 @@ +import { getAddressKey } from '@cowprotocol/cow-sdk' + import type { Page, Locator } from '@playwright/test' export class TokenSelector { @@ -23,8 +25,37 @@ export class TokenSelector { await this.outputSelectButton.click() } + /** + * Picks a destination network in the token picker's chain panel (only rendered when the field + * being picked for is bridging-eligible — see `useChainPanelState`). Chain rows have no + * `data-testid`; `ChainButton` renders only the chain's `label` text (e.g. "Arbitrum", "Base", + * "BNB", "Solana", "Bitcoin" — see `@cowprotocol/sdk-config`'s chain definitions). + */ + async selectChain(chainLabel: string): Promise { + await this.page.getByText(chainLabel, { exact: true }).click() + } + async searchAndPick(symbolOrAddress: string): Promise { - await this.searchInput.fill(symbolOrAddress) - await this.currencyList.getByText(symbolOrAddress, { exact: false }).first().click() + const input = this.page.locator('#token-search-input') + await input.fill(symbolOrAddress) + // `TokenListItem` sets `data-token-symbol`/`data-address` on the row itself — targeting those + // directly (rather than the rendered text) avoids picking an unrelated element that merely + // contains the search string, e.g. a tooltip icon next to the token's shortened address. + const row = symbolOrAddress.startsWith('0x') + ? this.page.locator(`[data-address="${getAddressKey(symbolOrAddress)}"]`) + : this.page.locator(`[data-token-symbol="${symbolOrAddress}"]`) + const firstRow = row.first() + // `TokenListItem`'s click handler no-ops on the already-selected token (it's a picker, not a + // toggle) — this happens whenever the requested token is already active, e.g. the app's own + // duplicate-currency guard already swapped it into place while picking the other side. Dismiss + // the same way a user finding nothing to click would, via the header's `BackButton`, which + // installs its own Escape handler. + const alreadySelected = await firstRow.evaluate((el) => el.classList.contains('token-item-selected')) + if (alreadySelected) { + await this.page.keyboard.press('Escape') + } else { + await firstRow.click() + } + await this.page.locator('#currency-list').waitFor({ state: 'hidden' }) } } diff --git a/apps/cowswap-e2e-tests/src/pages/TwapPage.ts b/apps/cowswap-e2e-tests/src/pages/TwapPage.ts index 260d7814f81..761682bde48 100644 --- a/apps/cowswap-e2e-tests/src/pages/TwapPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/TwapPage.ts @@ -1,3 +1,5 @@ +import { expect } from '@playwright/test' + import type { TradePage } from './TradePage' import type { Page, Locator } from '@playwright/test' @@ -28,11 +30,21 @@ export class TwapPage implements TradePage { } // The first visit shows an "unlock" intro screen instead of the order form — dismiss it. + // The click can be swallowed by the trade widget's own state-reconciliation effects (chain/ + // provider sync still settling right after navigation, especially under CI load) — retry the + // click until the form actually shows up instead of firing it once and hoping it stuck. private async unlockIfNeeded(): Promise { await this.unlockButton.or(this.inputAmount).first().waitFor({ state: 'visible' }) - if (await this.unlockButton.isVisible()) { - await this.unlockButton.click() - } + if (!(await this.unlockButton.isVisible())) return + + await expect + .poll(async () => { + if (await this.unlockButton.isVisible()) { + await this.unlockButton.click() + } + return this.inputAmount.isVisible() + }) + .toBe(true) } async enterSellAmount(amount: string): Promise { diff --git a/apps/cowswap-e2e-tests/src/support/expectActivityStatus.ts b/apps/cowswap-e2e-tests/src/support/expectActivityStatus.ts new file mode 100644 index 00000000000..b5e0ba8f192 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/expectActivityStatus.ts @@ -0,0 +1,15 @@ +import { expect } from '../fixtures' + +import type { AccountModal } from '../pages/AccountModal' + +/** Opens the account drawer, asserts the activities list shows `status`, then closes it again. */ +export async function expectActivityStatus( + accountModal: AccountModal, + status: string, + opts?: { timeout?: number }, +): Promise { + await accountModal.open() + await accountModal.activitiesList.scrollIntoViewIfNeeded() + await expect(accountModal.activitiesList).toContainText(status, opts) + await accountModal.close() +} diff --git a/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts b/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts new file mode 100644 index 00000000000..e0580f2454d --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts @@ -0,0 +1,115 @@ +import { appendFile, mkdir } from 'node:fs/promises' +import path from 'node:path' + +import type { APIResponse, BrowserContext, Route } from '@playwright/test' + +export interface UnmockedRpcLoggerOpts { + context: BrowserContext + worker: number + test: string + logPath?: string +} + +export interface UnmockedRpcRequestLogEntry { + timestamp: string + worker: number + test: string + url: string + request: unknown + status: number + response: unknown + durationMs: number + error?: string +} + +const DEFAULT_LOG_PATH = path.join('test-results', 'unmocked-rpc-requests.log') + +/** + * Diagnostic tool for CC-03/CC-26/CC-27-style flakiness ("Error loading price" under `pnpm e2e`'s + * full parallel load, not reproducible running one test at a time): several mocks + * (`mockSocketVerifier`, `mocks.allowances`, ...) fall back to a real `route.fetch()` against + * whatever real RPC the app picked (e.g. `ethereum-rpc.publicnode.com`) whenever a batch isn't + * *fully* recognized — reliable for one test, but exactly the kind of real, rate-limited + * dependency that starts 429-ing once dozens of parallel workers hit it at once. + * + * Enable with `LOG_UNMOCKED_RPC=1`. Registers a catch-all route with the lowest possible priority + * — call this before installing any other mock (first thing in the `mocks` fixture) so every + * other, more specific handler gets first refusal via `route.fallback()`. Whatever reaches this + * one is, by construction, not mocked by anything else. For JSON-RPC-shaped bodies (the shape + * every blockchain RPC call in this suite uses — CoW API/Bungee/etc. traffic has different shapes + * and is already excluded), it performs the real request itself, logs the request and the real + * response (status, body — including a real 429) as one JSON line to `logPath`, then fulfills with + * that same real response so test behavior is completely unchanged; this is observation-only. + */ +export function logUnmockedRpcRequests(opts: UnmockedRpcLoggerOpts): void { + const { context, worker, test, logPath = DEFAULT_LOG_PATH } = opts + + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: unknown + try { + body = request.postDataJSON() + } catch { + return route.fallback() + } + if (!looksLikeJsonRpc(body)) return route.fallback() + + const startedAt = Date.now() + try { + const response = await route.fetch() + const responseBody = await readBody(response) + void appendEntry(logPath, { + timestamp: new Date(startedAt).toISOString(), + worker, + test, + url: request.url(), + request: body, + status: response.status(), + response: responseBody, + durationMs: Date.now() - startedAt, + }) + await route.fulfill({ response }) + } catch (error) { + void appendEntry(logPath, { + timestamp: new Date(startedAt).toISOString(), + worker, + test, + url: request.url(), + request: body, + status: 0, + response: null, + durationMs: Date.now() - startedAt, + error: String(error), + }) + await route.fallback() + } + }) +} + +/** Best-effort: a logging failure must never break the real request it's observing. */ +async function appendEntry(logPath: string, entry: UnmockedRpcRequestLogEntry): Promise { + try { + await mkdir(path.dirname(logPath), { recursive: true }) + await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8') + } catch { + // Diagnostic logging is best-effort only. + } +} + +function looksLikeJsonRpc(body: unknown): boolean { + const isEntry = (entry: unknown): boolean => + typeof entry === 'object' && entry !== null && typeof (entry as { method?: unknown }).method === 'string' + + return Array.isArray(body) ? body.length > 0 && body.every(isEntry) : isEntry(body) +} + +async function readBody(response: APIResponse): Promise { + const text = await response.text() + try { + return JSON.parse(text) + } catch { + return text + } +} diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts new file mode 100644 index 00000000000..1f93d65e8d1 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts @@ -0,0 +1,74 @@ +import { APPROVE_CALL_SUCCESS_RESULT, APPROVE_SELECTOR } from './mockApproveTransaction' + +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] +} + +/** + * Answers the preflight `approve(address,uint256)` simulation `eth_call` (see + * `mockApproveTransaction.ts`'s doc comment) for every trade that pre-seeds a sufficient + * allowance via `seedTrader`/`mocks.allowances.set` and therefore never calls + * `mockApproveTransaction` at all — the wallet-connector layer still fires this simulate-before- + * sign check regardless of whether the UI ever shows an Approve step, and confirmed by tracing + * real traffic (`LOG_UNMOCKED_RPC=1`), it goes to the app's own hardcoded provider rather than any + * URL this suite controls. Unlike `mockApproveTransaction`'s own per-token simulation stub, this one matches on the + * selector alone — an ERC20 `approve()` call succeeding is safe to assume unconditionally + * regardless of which token/spender it targets, and no test in this suite depends on one + * reverting. + */ +export function mockApproveSimulation(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] | null + try { + // Same rationale as `mockApproveTransaction.ts`'s own preflight handler: this route sees + // every request in the page, so a POST with no/non-JSON body (e.g. an analytics beacon) + // must be checked explicitly rather than relying on a try/catch alone. + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] | null + } catch { + return route.fallback() + } + if (!body) return route.fallback() + + const entries = Array.isArray(body) ? body : [body] + const matches = entries.map(isApproveSimulationCall) + if (!matches.some(Boolean)) return route.fallback() + + if (matches.every(Boolean)) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + return fulfillFromUpstream(route, entries, matches) + }) +} + +/** Same merge-with-upstream technique as `mockApproveTransaction.ts`'s own preflight handler. */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[], matches: boolean[]): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const matchedIds = new Set(entries.filter((_, i) => matches[i]).map((entry) => entry.id)) + const payload = upstreamEntries.map((entry) => + matchedIds.has(entry.id) ? { jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT } : entry, + ) + await route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + } catch { + await route.fallback() + } +} + +/** Matches any `eth_call` whose calldata is an `approve(address,uint256)` invocation, regardless of `to`. */ +function isApproveSimulationCall(entry: JsonRpcEntry | null | undefined): boolean { + if (entry?.method !== 'eth_call') return false + const call = entry.params?.[0] + if (!call?.to || !call?.data) return false + return call.data.toLowerCase().startsWith(APPROVE_SELECTOR) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts new file mode 100644 index 00000000000..6284ad073d4 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts @@ -0,0 +1,228 @@ +import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, erc20Abi, type Hex } from 'viem' + +import { areAddressesEqual } from '@cowprotocol/cow-sdk' + +import { RpcStub } from '../mockWallet/walletEngine' + +import type { MockWalletApi } from '../fixtures/mockWallet' +import type { AllowancesMock } from '../mocks/allowances' +import type { BrowserContext, Route } from '@playwright/test' + +const FAKE_APPROVE_TX_HASH = `0x${'ab'.repeat(32)}` as const + +/** `approve(address,uint256)` selector — what the preflight `eth_call` this mock also stubs is checking won't revert. */ +export const APPROVE_SELECTOR = '0x095ea7b3' +/** ABI-encoded `true` — the only thing a `bool`-returning `eth_call` needs to report success. */ +export const APPROVE_CALL_SUCCESS_RESULT = encodeAbiParameters([{ type: 'bool' }], [true]) + +export interface MockApproveTransactionHandle { + /** The raw amount decoded from the actual approve(spender, amount) calldata, once sent. */ + getApprovedAmount(): bigint | undefined +} + +export interface MockApproveTransactionOpts { + context: BrowserContext + wallet: Pick + allowances: AllowancesMock + chainId: number + token: string +} + +interface JsonRpcEntry { + id: number | string + method: string + params: unknown[] +} + +interface ReceiptContext { + owner: string + token: string + spender: Hex | undefined + amount: bigint | undefined +} + +/** + * Fakes an ERC20 `approve()` end-to-end instead of letting it broadcast for real: the + * `eth_sendTransaction` itself goes through the connected wallet (stubbed here), but the + * confirmation poll that follows it (`eth_getTransactionReceipt`) goes through the app's own + * direct RPC client straight to `REACT_APP_NETWORK_URL_` — the same wire + * `mocks.balances`/`mocks.allowances` intercept — bypassing the wallet entirely, so it needs its + * own route stub. The allowance mock is also kept in sync, since faking the send doesn't change + * anything the real allowance-read mock would otherwise report. + * + * Before ever reaching that stubbed `eth_sendTransaction`, the wallet-connector layer also fires a + * preflight, non-batched `eth_call` for the same `approve(address,uint256)` calldata — a + * simulate-before-sign check that the call won't revert. Tracing real RPC traffic + * (`LOG_UNMOCKED_RPC=1`) showed this going straight to a real, hardcoded provider (Infura) rather + * than any URL this suite controls, and getting rate-limited (HTTP 429) under `pnpm e2e`'s full + * parallel load — so it's matched host-agnostically by `to`/`data` (like `mockSocketVerifier.ts`) + * and answered with a successful ABI-encoded `true`, same as the real call would return. + */ +export async function mockApproveTransaction(opts: MockApproveTransactionOpts): Promise { + const { context, wallet, allowances, chainId, token } = opts + const rpcUrl = process.env[`REACT_APP_NETWORK_URL_${chainId}`] + if (!rpcUrl) throw new Error(`REACT_APP_NETWORK_URL_${chainId} not set`) + + let approvedAmount: bigint | undefined + let spender: Hex | undefined + + const stub: RpcStub = ({ params }) => { + const tx = params[0] as { data?: Hex } + // Ground truth: decode the actual approve(spender, amount) calldata rather than trusting the + // UI's rendered figure. + const { args } = decodeFunctionData({ abi: erc20Abi, data: tx.data as Hex }) + spender = args[0] as Hex + approvedAmount = args[1] as bigint + allowances.set(wallet.address, chainId, { [token]: approvedAmount }) + return FAKE_APPROVE_TX_HASH + } + wallet.stubRpc('eth_sendTransaction', stub) + + await context.route(rpcUrl, async (route) => { + const body = route.request().postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + const entries = Array.isArray(body) ? body : [body] + const isReceiptEntry = entries.map((entry) => entry.method === 'eth_getTransactionReceipt') + if (!isReceiptEntry.some(Boolean)) return route.fallback() + + const ctx: ReceiptContext = { owner: wallet.address, token, spender, amount: approvedAmount } + + if (isReceiptEntry.every(Boolean)) { + const payload = entries.map((entry) => buildReceiptRpcResponse(entry, ctx)) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + // A mixed batch — only the receipt entries are ours to answer; fetch upstream and patch just + // those in, so a non-receipt read bundled alongside our poll doesn't get nulled out. + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const receiptIds = new Set(entries.filter((_, i) => isReceiptEntry[i]).map((entry) => entry.id)) + const payload = upstreamEntries.map((entry) => + receiptIds.has(entry.id) ? buildReceiptRpcResponse(entry, ctx) : entry, + ) + return route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + }) + + await context.route('**/*', (route) => handleApproveSimulationCall(route, token)) + + return { + getApprovedAmount: () => approvedAmount, + } +} + +/** + * `useApproveAndSwap` confirms the approved amount by parsing the receipt's `Approval` log (not + * by re-reading `allowance()`) — an empty `logs: []` reads as "approval failed". + */ +function buildApproveReceipt(ctx: ReceiptContext): unknown { + const approvalLog = { + address: ctx.token, + topics: encodeEventTopics({ + abi: erc20Abi, + eventName: 'Approval', + args: { owner: ctx.owner as Hex, spender: ctx.spender as Hex }, + }), + data: encodeAbiParameters([{ type: 'uint256' }], [ctx.amount as bigint]), + blockNumber: '0x2783872', + transactionHash: FAKE_APPROVE_TX_HASH, + transactionIndex: '0x0', + blockHash: `0x${'cd'.repeat(32)}`, + logIndex: '0x0', + removed: false, + } + + return { + transactionHash: FAKE_APPROVE_TX_HASH, + status: '0x1', + blockNumber: '0x1', + blockHash: `0x${'cd'.repeat(32)}`, + contractAddress: null, + cumulativeGasUsed: '0x5208', + gasUsed: '0x5208', + effectiveGasPrice: '0x3b9aca00', + logs: [approvalLog], + logsBloom: `0x${'0'.repeat(512)}`, + transactionIndex: '0x0', + from: ctx.owner, + to: ctx.token, + type: '0x0', + } +} + +/** A JSON-RPC response for one batched request — only `eth_getTransactionReceipt` for our own fake hash gets a real result, everything else (including a stale poll for a since-superseded hash) reads as not-yet-mined. */ +function buildReceiptRpcResponse( + entry: JsonRpcEntry, + ctx: ReceiptContext, +): { jsonrpc: '2.0'; id: number | string; result: unknown } { + const isOurReceipt = entry.method === 'eth_getTransactionReceipt' && entry.params[0] === FAKE_APPROVE_TX_HASH + return { jsonrpc: '2.0', id: entry.id, result: isOurReceipt ? buildApproveReceipt(ctx) : null } +} + +/** + * Not observed in practice (this preflight is always a standalone, non-batched `eth_call`) — but if + * it ever turns up mixed with other, unrecognized calls, fetch the real upstream and patch in only + * the entries this mock actually understands, rather than fabricate data for the rest. Same + * try/catch → `route.fallback()` guard as `mockSocketVerifier.ts`'s `fulfillFromUpstream`, so a + * transient real-RPC hiccup here can't abort the whole request. + */ +async function fulfillApproveSimulationFromUpstream( + route: Route, + entries: JsonRpcEntry[], + matches: boolean[], +): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const matchedIds = new Set(entries.filter((_, i) => matches[i]).map((entry) => entry.id)) + const payload = upstreamEntries.map((entry) => + matchedIds.has(entry.id) ? { jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT } : entry, + ) + await route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + } catch { + await route.fallback() + } +} + +/** + * Answers the preflight `approve(address,uint256)` simulation `eth_call` (see the doc comment on + * `mockApproveTransaction`) with a successful `true`, host-agnostically. Unlike `mockSocketVerifier.ts`, + * this call is never wrapped in a Multicall3 batch in practice (confirmed by tracing real RPC + * traffic), so no batch-decoding is needed — just the single/array JSON-RPC envelope every route in + * this suite already has to handle. + */ +async function handleApproveSimulationCall(route: Route, token: string): Promise { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] | null + try { + // Unlike `route.request().postDataJSON()` elsewhere in this file (only ever called against a + // known JSON-RPC endpoint), this route sees every request in the page — `postDataJSON()` + // returns `null` rather than throwing for a POST with no/non-JSON body (e.g. an analytics + // beacon), so that has to be checked explicitly, not just guarded by try/catch. + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] | null + } catch { + return route.fallback() + } + if (!body) return route.fallback() + + const entries = Array.isArray(body) ? body : [body] + const matches = entries.map((entry) => isApproveSimulationCall(entry, token)) + if (!matches.some(Boolean)) return route.fallback() + + if (matches.every(Boolean)) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + return fulfillApproveSimulationFromUpstream(route, entries, matches) +} + +/** Matches the preflight `eth_call` simulating `approve(address,uint256)` against the same token this mock was set up for, before the real `eth_sendTransaction` is ever asked for. */ +function isApproveSimulationCall(entry: JsonRpcEntry | null | undefined, token: string): boolean { + if (entry?.method !== 'eth_call') return false + const call = entry.params?.[0] as { to?: string; data?: string } | undefined + if (!call?.to || !call?.data) return false + return areAddressesEqual(call.to, token) && call.data.toLowerCase().startsWith(APPROVE_SELECTOR) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts b/apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts new file mode 100644 index 00000000000..965450f44bf --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts @@ -0,0 +1,101 @@ +import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' + +const FAKE_ORDER_UID = `0x${'ab'.repeat(56)}` + +export interface MockCancellableOrderHandle { + uid: string + /** True once `DELETE /api/v1/orders` (`cancelOrders`) has been called for this order. */ + wasCancelRequested(): boolean + /** Marks the order invalidated on the backend — starts the "Cancelling..." → "Cancelled" transition. */ + markCancelled(): void +} + +export interface MockCancellableOrderOpts { + cowApi: CowProtocolApiMock + owner: string + sellToken: string + buyToken: string + sellAmount: bigint + buyAmount: bigint + /** + * Seconds to backdate the order's `creationDate` by. `isOrderCancelled` only reports true once + * `invalidated` has been true for over `PENDING_ORDERS_BUFFER` (60s) since `creationDate` — the + * default (30s) keeps the transient "Cancelling..." state observable for a while after + * `markCancelled()` before the order settles into "Cancelled", rather than jumping straight to + * one or the other. + */ + createdSecondsAgo?: number +} + +/** + * Seeds a fake "open" order directly through the CoW API mocks, without ever creating one through + * the swap UI. `#account-activities-list` isn't driven by a live UI action at all: + * `OrdersFromApiUpdater` polls `GET /api/v1/account/{address}/orders` on its own + * (`ORDER_BOOK_API_UPDATE_INTERVAL`, 30s) and transforms whatever it returns into local order + * state — mocking `accountOrders` (and `order`, for the same updater's per-uid reads) is the + * actual, correct lever, not something that needs reverse-engineering from localStorage. + * + * Returning only this fake order from `accountOrders` (not `[fakeOrder, ...req.defaults]`) avoids + * the default fixture's own orders also being cancellable and ambiguous to locate on the page. + * + * Note: `OrdersFromApiUpdater` also needs to resolve `sellToken`/`buyToken` via + * `useAllActiveTokens()` before it'll turn the fetched order into local state — selecting them via + * the real dropdown UI (`swapPage.tokens.searchAndPick(...)`, same as most swap tests) is what + * gets them into that set; this helper only seeds the order data itself. + */ +export function mockCancellableOrder(opts: MockCancellableOrderOpts): MockCancellableOrderHandle { + const { cowApi, owner, sellToken, buyToken, sellAmount, buyAmount, createdSecondsAgo = 30 } = opts + const creationDate = new Date(Date.now() - createdSecondsAgo * 1000).toISOString() + + let invalidated = false + let cancelRequested = false + + const buildOrder = (): unknown => ({ + creationDate, + owner, + uid: FAKE_ORDER_UID, + availableBalance: null, + executedBuyAmount: '0', + executedSellAmount: '0', + executedSellAmountBeforeFees: '0', + executedFeeAmount: '0', + executedFee: '0', + executedFeeToken: sellToken, + invalidated, + status: 'open', + class: 'market', + settlementContract: '0xf553d092b50bdcbdded1a99af2ca29fbe5e2cb13', + isLiquidityOrder: false, + fullAppData: '{}', + sellToken, + buyToken, + receiver: owner, + sellAmount: sellAmount.toString(), + buyAmount: buyAmount.toString(), + validTo: Math.floor(Date.now() / 1000) + 3600, + appData: `0x${'cd'.repeat(32)}`, + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip712', + signature: `0x${'11'.repeat(65)}`, + interactions: { pre: [], post: [] }, + }) + + cowApi.set('accountOrders', () => [buildOrder()]) + cowApi.set('order', () => buildOrder()) + cowApi.set('cancelOrders', (req) => { + cancelRequested = true + return req.defaults + }) + + return { + uid: FAKE_ORDER_UID, + wasCancelRequested: () => cancelRequested, + markCancelled: () => { + invalidated = true + }, + } +} diff --git a/apps/cowswap-e2e-tests/src/support/mockEthFlowOrderIndexing.ts b/apps/cowswap-e2e-tests/src/support/mockEthFlowOrderIndexing.ts new file mode 100644 index 00000000000..069427117a9 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockEthFlowOrderIndexing.ts @@ -0,0 +1,53 @@ +import { reply } from '../mocks/cowProtocolApi' + +import type { MockEthFlowTransactionHandle } from './mockEthFlowTransaction' +import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' + +export interface MockEthFlowOrderIndexingHandle { + /** Lets the `order`-by-uid poll start succeeding — what flips the order from `creating` to `pending`/`open`. */ + markIndexed(): void +} + +/** + * Wires the `order` endpoint for an ETH-flow trade. There's no `postOrder` call to hook for this + * flow (its uid is computed client-side before anything is sent on-chain, see + * `mockEthFlowTransaction`), so `mockOrderPosting` can't be reused — this is its ETH-flow + * equivalent. Reports 404 (still `creating`) until `markIndexed()` is called, mirroring + * `GET /api/v1/orders/{uid}`'s default fixture answering any uid with a valid order. Every + * amount/status field is read straight off `ethFlow`'s decoded `createOrder()` calldata (and its + * own `confirmFilled()` flag) rather than trusted from the UI — `classifyOrder`'s + * `isOrderFulfilled` compares this response's own `sellAmount` against + * `executedSellAmountBeforeFees`, and an unrelated fixture default would never match. + */ +export function mockEthFlowOrderIndexing( + cowApi: CowProtocolApiMock, + ethFlow: MockEthFlowTransactionHandle, +): MockEthFlowOrderIndexingHandle { + let indexed = false + + cowApi.set('order', (req) => { + if (!indexed) return reply(404, { errorType: 'NotFound' }) + + const orderParams = ethFlow.getOrderParams() + const defaults = req.defaults as Record + const filled = ethFlow.isFilled() + const executedSellAmount = filled ? orderParams?.sellAmount.toString() : '0' + return { + ...defaults, + kind: 'sell', + buyToken: orderParams?.buyToken, + sellAmount: orderParams?.sellAmount.toString(), + buyAmount: orderParams?.buyAmount.toString(), + status: filled ? 'fulfilled' : 'open', + executedBuyAmount: filled ? orderParams?.buyAmount.toString() : '0', + executedSellAmount, + executedSellAmountBeforeFees: executedSellAmount, + } + }) + + return { + markIndexed: () => { + indexed = true + }, + } +} diff --git a/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts new file mode 100644 index 00000000000..65c8b1f96f3 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts @@ -0,0 +1,479 @@ +import { decodeAbiParameters, encodeAbiParameters, type Hex } from 'viem' + +import { areAddressesEqual } from '@cowprotocol/cow-sdk' + +import type { MockWalletApi } from '../fixtures/mockWallet' +import type { RpcStub } from '../mockWallet/walletEngine' +import type { BrowserContext, Route } from '@playwright/test' + +const FAKE_ETH_FLOW_TX_HASH = `0x${'ef'.repeat(32)}` as const + +/** `getEthBalance(address)` on Multicall3 — how this app actually reads native ETH balance (confirmed by tracing real RPC traffic; it is never a bare `eth_getBalance`). */ +const GET_ETH_BALANCE_SELECTOR = '0x4d2301cc' +/** `aggregate3((address,bool,bytes)[])` on Multicall3 — same batching every other read on this RPC channel goes through, see `mocks/allowances/codec.ts`. */ +const AGGREGATE3_SELECTOR = '0x82ad56cb' + +const CALL3_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, +] as const + +const RESULT_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, +] as const + +const UINT256 = [{ type: 'uint256' }] as const + +export interface BatchCall { + kind: 'batch' + calls: ClassifiedEthCall[] +} + +export type ClassifiedEthCall = OwnBalanceCall | BatchCall | OpaqueCall + +export interface OpaqueCall { + kind: 'opaque' +} + +export interface OwnBalanceCall { + kind: 'ownBalance' +} + +interface BatchResultSlot { + success: boolean + returnData: Hex +} + +const OPAQUE: OpaqueCall = { kind: 'opaque' } + +export interface NativeBalanceRouteOpts { + context: BrowserContext + rpcUrl: string + /** Owner whose `getEthBalance(owner)` reads (however deep inside a Multicall3 batch) get patched. */ + owner: string + /** The fake hash `eth_getTransactionReceipt` polls for. */ + txHash: Hex + /** Read fresh each time the route fires, so it reflects whatever the caller's own `eth_sendTransaction` stub last recorded. */ + getBalance: () => bigint + /** Whether the receipt should report success yet — the caller owns this flag so it can also + * drive its own `confirmMined()`/other routes (e.g. `mockEthFlowTxLookupFallback`) in step. */ + isMined: () => boolean +} + +type NativeBalanceEntry = { kind: 'receipt' } | { kind: 'call'; call: ClassifiedEthCall } | { kind: 'opaque' } + +/** + * Classifies one `eth_call` payload for `owner`'s own ETH balance, recursively — mirrors + * `mocks/allowances/codec.ts`'s `classifyCall`, since Multicall3 batches nest the same way + * regardless of what's inside them. Recognizing `getEthBalance(owner)` wherever it appears inside + * a batch (rather than requiring the *whole* batch to be nothing but that) is what keeps this from + * ever needing to forward the owner's real balance to the real RPC just because some other, + * unrelated read got bundled into the same Multicall3 call. + */ +export function classifyEthCall(data: Hex, owner: string): ClassifiedEthCall { + const selector = data.slice(0, 10).toLowerCase() + + if (selector === GET_ETH_BALANCE_SELECTOR) { + try { + const [address] = decodeAbiParameters([{ type: 'address' }], `0x${data.slice(10)}` as Hex) + return areAddressesEqual(address as string, owner) ? { kind: 'ownBalance' } : OPAQUE + } catch { + return OPAQUE + } + } + + if (selector === AGGREGATE3_SELECTOR) { + try { + const [calls] = decodeAbiParameters(CALL3_TUPLE, `0x${data.slice(10)}` as Hex) + return { + kind: 'batch', + calls: (calls as ReadonlyArray<{ callData: Hex }>).map((c) => classifyEthCall(c.callData, owner)), + } + } catch { + return OPAQUE + } + } + + return OPAQUE +} + +/** + * Shared by every mock that fakes a plain `eth_sendTransaction` and needs to patch the two + * direct-RPC reads the app polls afterwards: the tx's own `eth_getTransactionReceipt`, and the + * wallet's native ETH balance (read via Multicall3's `getEthBalance`, batched through `aggregate3` + * — see `classifyEthCall`). Used by `mockEthFlowTransaction`, `mockWrapTransaction`, and + * `mockUnwrapTransaction` — the only things that differ between them are the fake tx hash and the + * direction/amount `getBalance()` computes. The receipt reports success only once `isMined()` says + * so, so a test can assert the transient "pending" state before letting it proceed. For entries + * this route doesn't recognize (fully opaque, or a batch only partially recognized), the real + * upstream is fetched and only the recognized slots are patched in, so unrelated batched reads + * still get real data instead of being silently nulled out. + */ +export async function installNativeBalanceRoute(opts: NativeBalanceRouteOpts): Promise { + const { context, rpcUrl, owner, txHash, getBalance, isMined } = opts + + const classify = (entry: JsonRpcEntry): NativeBalanceEntry => { + if (entry.method === 'eth_getTransactionReceipt' && entry.params[0] === txHash) { + return { kind: 'receipt' } + } + if (entry.method === 'eth_call') { + const call = entry.params[0] as { data?: Hex } + if (call.data) { + const classifiedCall = classifyEthCall(call.data, owner) + if (classifiedCall.kind !== 'opaque') return { kind: 'call', call: classifiedCall } + } + } + return { kind: 'opaque' } + } + + const isEntryFullyMocked = (entry: NativeBalanceEntry): boolean => + entry.kind === 'receipt' || (entry.kind === 'call' && isFullyMocked(entry.call)) + + const buildResult = (classified: NativeBalanceEntry, balance: bigint, upstream?: Hex): unknown => { + if (classified.kind === 'receipt') return isMined() ? buildReceipt(txHash) : null + if (classified.kind === 'call') { + if (classified.call.kind === 'ownBalance') return encodeAbiParameters(UINT256, [balance]) + if (classified.call.kind === 'opaque') return undefined + return resolveEthBalanceBatch(classified.call, balance, upstream) + } + return undefined + } + + await context.route(rpcUrl, async (route) => { + const body = route.request().postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + const entries = Array.isArray(body) ? body : [body] + const classified = entries.map(classify) + + if (classified.every((c) => c.kind === 'opaque')) return route.fallback() + + const balance = getBalance() + + if (classified.every(isEntryFullyMocked)) { + const payload = entries.map((entry, i) => ({ + jsonrpc: '2.0', + id: entry.id, + result: buildResult(classified[i], balance), + })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + // Some entries need real data (fully opaque, or a batch only partially recognized) — fetch + // upstream and patch in only what's actually mocked, same merge technique as the allowances mock. + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + + const classifiedById = new Map() + entries.forEach((entry, i) => classifiedById.set(entry.id, classified[i])) + + const payload = upstreamEntries.map((entry) => { + const classifiedEntry = classifiedById.get(entry.id) + if (!classifiedEntry || classifiedEntry.kind === 'opaque') return entry + const upstreamResult = typeof entry.result === 'string' ? (entry.result as Hex) : undefined + return { jsonrpc: '2.0', id: entry.id, result: buildResult(classifiedEntry, balance, upstreamResult) } + }) + return route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + }) +} + +export function isFullyMocked(call: ClassifiedEthCall): boolean { + if (call.kind === 'ownBalance') return true + if (call.kind === 'opaque') return false + return call.calls.every(isFullyMocked) +} + +/** + * Builds the `Result[]` blob for a batch, patching only the `ownBalance` slots and leaving every + * other slot as whatever the real upstream response had for it (or a failure slot if there's no + * upstream at all, i.e. the batch turned out to be nothing but `ownBalance` calls). Same + * upstream-as-base technique as `codec.ts`'s `resolveBatchResult`. + */ +export function resolveEthBalanceBatch(call: BatchCall, balance: bigint, upstream?: Hex): Hex { + const base = upstream ? decodeResultSlots(upstream) : [] + + const slots = call.calls.map((inner, index) => { + const fallback = base[index] ?? { success: false, returnData: '0x' as Hex } + + if (inner.kind === 'ownBalance') { + return { success: true, returnData: encodeAbiParameters(UINT256, [balance]) } + } + if (inner.kind === 'batch') { + const nestedUpstream = fallback.success ? fallback.returnData : undefined + return { success: true, returnData: resolveEthBalanceBatch(inner, balance, nestedUpstream) } + } + return fallback + }) + + return encodeAbiParameters(RESULT_TUPLE, [slots]) +} + +function decodeResultSlots(blob: Hex): BatchResultSlot[] { + try { + return [...(decodeAbiParameters(RESULT_TUPLE, blob)[0] as ReadonlyArray)] + } catch { + // An upstream error body or a truncated blob must not lose the mocked slots. + return [] + } +} + +/** `EthFlowOrder.Data` — the struct `createOrder()` takes, per `libs/abis/src/abis/CoWSwapEthFlow.ts`. */ +const ETH_FLOW_ORDER_TUPLE = [ + { + type: 'tuple', + components: [ + { name: 'buyToken', type: 'address' }, + { name: 'receiver', type: 'address' }, + { name: 'sellAmount', type: 'uint256' }, + { name: 'buyAmount', type: 'uint256' }, + { name: 'appData', type: 'bytes32' }, + { name: 'feeAmount', type: 'uint256' }, + { name: 'validTo', type: 'uint32' }, + { name: 'partiallyFillable', type: 'bool' }, + { name: 'quoteId', type: 'int64' }, + ], + }, +] as const + +export interface EthFlowOrderParams { + buyToken: string + receiver: string + sellAmount: bigint + buyAmount: bigint + appData: string + feeAmount: bigint + validTo: number + partiallyFillable: boolean + quoteId: bigint +} + +export interface MockEthFlowTransactionHandle { + /** The native ETH amount (wei) actually sent in the fake `createOrder()` transaction, once sent. */ + getSentValue(): bigint | undefined + getTxHash(): string + /** Marks the creation tx as mined, so `eth_getTransactionReceipt` starts reporting success. */ + confirmMined(): void + isMined(): boolean + /** The order struct decoded from the actual `createOrder()` calldata, once the tx is sent — ground + * truth for building a fulfilled-order response, rather than trusting the UI's rendered figures. */ + getOrderParams(): EthFlowOrderParams | undefined + /** Marks the order as filled — a caller's own `order`/`orderStatus` overrides read this to decide + * when to start reporting the trade as settled. */ + confirmFilled(): void + isFilled(): boolean +} + +export interface MockEthFlowTransactionOpts { + context: BrowserContext + wallet: Pick + chainId: number + initialEthBalance: bigint +} + +/** A generous flat estimate for the `createOrder()` call — never actually spent, since the send itself is stubbed. */ +const FAKE_GAS_ESTIMATE = '0x7a120' as const + +interface JsonRpcEntry { + id: number | string + method: string + params: unknown[] + result?: unknown +} + +type TxLookupEntry = { kind: 'receipt' } | { kind: 'transaction' } + +/** + * Fakes the ETH-flow order-creation transaction end-to-end. Selling native ETH doesn't post an + * off-chain EIP-712-signed order like every other trade in this suite — it sends an on-chain + * `createOrder()` tx (with the sell amount as `tx.value`) to a dedicated EthFlow contract instead. + * `eth_sendTransaction` goes through the connected wallet (stubbed here), same channel + * `mockApproveTransaction` uses for `approve()`. Unlike that helper, there's no allowance to keep + * in sync — instead this fakes the two direct-RPC reads the app polls afterwards: + * `eth_getTransactionReceipt` for the creation tx, and the wallet's native ETH balance. The latter + * never flows through the balances-watcher SSE stream `mocks.balances` intercepts, and — despite + * `NativeTokenBalanceUpdater`'s own comment claiming a bare `eth_getBalance` — tracing actual RPC + * traffic shows it's read via Multicall3's `getEthBalance(address)`, batched through `aggregate3` + * the same way every other read on this RPC channel is (see `mocks/allowances/codec.ts`), so it + * has to be decoded/patched at that level rather than intercepted as a plain `eth_getBalance`. + * `classifyEthCall` recognizes the owner's own `getEthBalance` wherever it appears inside a + * Multicall3 batch — not just when the whole batch is nothing else — so this never has to forward + * the owner's *real* balance to the real RPC just because some unrelated read got bundled + * alongside it (which is exactly what let a real Sepolia balance leak into an assertion here once + * a second ETH-flow test started running against the same wallet address). + * + * The receipt (and therefore the order leaving `CREATING`, since `GET /api/v1/orders/{uid}`'s + * default fixture already answers any uid with a valid open order) is withheld until + * `confirmMined()` is called, so a test can assert the transient "creating" state before letting + * it proceed — otherwise both mocks would resolve on the very first poll and race right past it. + */ +export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): Promise { + const { context, wallet, chainId, initialEthBalance } = opts + const rpcUrl = process.env[`REACT_APP_NETWORK_URL_${chainId}`] + if (!rpcUrl) throw new Error(`REACT_APP_NETWORK_URL_${chainId} not set`) + + let sentValue: bigint | undefined + let orderParams: EthFlowOrderParams | undefined + let mined = false + let filled = false + + stubEthFlowSend(wallet, (value, order) => { + sentValue = value + orderParams = order + }) + + await mockEthFlowTxLookupFallback(context, wallet.address, () => mined) + + await installNativeBalanceRoute({ + context, + rpcUrl, + owner: wallet.address, + txHash: FAKE_ETH_FLOW_TX_HASH, + getBalance: () => initialEthBalance - (sentValue ?? 0n), + isMined: () => mined, + }) + + return { + getSentValue: () => sentValue, + getTxHash: () => FAKE_ETH_FLOW_TX_HASH, + confirmMined: () => { + mined = true + }, + isMined: () => mined, + getOrderParams: () => orderParams, + confirmFilled: () => { + filled = true + }, + isFilled: () => filled, + } +} + +function buildReceipt(txHash: string): unknown { + return { + transactionHash: txHash, + status: '0x1', + blockNumber: '0x1', + blockHash: `0x${'cd'.repeat(32)}`, + contractAddress: null, + cumulativeGasUsed: '0x5208', + gasUsed: '0x5208', + effectiveGasPrice: '0x3b9aca00', + logs: [], + logsBloom: `0x${'0'.repeat(512)}`, + transactionIndex: '0x0', + type: '0x0', + } +} + +/** A plausible-looking, mined `eth_getTransactionByHash` result — mirrors `buildReceipt`'s made-up + * but shape-correct fields (same fake block, same flat gas figures), plus the sender/value/nonce + * fields a receipt doesn't carry but a full transaction object does. */ +function buildTransaction(txHash: string, from: string): unknown { + return { + hash: txHash, + blockNumber: '0x1', + blockHash: `0x${'cd'.repeat(32)}`, + transactionIndex: '0x0', + from, + to: null, + value: '0x0', + nonce: '0x0', + gas: FAKE_GAS_ESTIMATE, + gasPrice: '0x3b9aca00', + input: '0x', + type: '0x0', + v: '0x1', + r: `0x${'11'.repeat(32)}`, + s: `0x${'22'.repeat(32)}`, + } +} + +function buildTxLookupResult(entry: TxLookupEntry, mined: boolean, from: string): unknown { + if (!mined) return null + return entry.kind === 'receipt' ? buildReceipt(FAKE_ETH_FLOW_TX_HASH) : buildTransaction(FAKE_ETH_FLOW_TX_HASH, from) +} + +/** Recognizes `eth_getTransactionReceipt`/`eth_getTransactionByHash` for the ETH-flow creation tx, + * regardless of which entry in a batch it is. */ +function classifyTxLookup(entry: JsonRpcEntry): TxLookupEntry | undefined { + if (entry?.params?.[0] !== FAKE_ETH_FLOW_TX_HASH) return undefined + if (entry.method === 'eth_getTransactionReceipt') return { kind: 'receipt' } + if (entry.method === 'eth_getTransactionByHash') return { kind: 'transaction' } + return undefined +} + +/** Decodes `createOrder(EthFlowOrder.Data)`'s single struct argument straight off the sent calldata. */ +function decodeEthFlowOrderParams(data: Hex | undefined): EthFlowOrderParams | undefined { + if (!data) return undefined + try { + const payload = `0x${data.slice(10)}` as Hex + const [order] = decodeAbiParameters(ETH_FLOW_ORDER_TUPLE, payload) + return order as EthFlowOrderParams + } catch { + return undefined + } +} + +/** + * Same class of bug documented on `mockEthEstimateGas` (now `installEthEstimateGas`), but for the + * two polls the app runs *after* sending the creation tx rather than before it: tracing real RPC + * traffic for the bridging ETH-flow path (`[CC-13]`) found `eth_getTransactionReceipt` AND + * `eth_getTransactionByHash` for this exact tx hash going out to a real Infura/WalletConnect-relay + * host that sometimes 429s — not the configured `REACT_APP_NETWORK_URL_{chainId}` this file's + * `context.route(rpcUrl, ...)` handler below is scoped to, so that handler's own (receipt-only) + * mocking never saw them. Registered host-agnostically, alongside `installEthEstimateGas`, as a + * second line of defense: for the configured RPC host, `context.route(rpcUrl, ...)` (registered + * after this one) still wins and answers first, so there's no double-handling; this one only ever + * fires for the *other*, unpredictable hosts the app's own independent client happens to pick. + */ +async function mockEthFlowTxLookupFallback( + context: BrowserContext, + from: string, + isMined: () => boolean, +): Promise { + await context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + const entries = Array.isArray(body) ? body : [body] + const classified = entries.map(classifyTxLookup) + if (!entries.length || classified.some((c) => !c)) return route.fallback() + + const mined = isMined() + const payload = entries.map((entry, i) => ({ + jsonrpc: '2.0', + id: entry.id, + result: buildTxLookupResult(classified[i] as TxLookupEntry, mined, from), + })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + }) +} + +/** Wires the ETH-flow creation tx's `eth_sendTransaction` stub, decoding the sent value/order struct + * before handing them off to the caller — pulled out of `mockEthFlowTransaction` itself purely to + * keep that function under this repo's `max-lines-per-function` limit. */ +function stubEthFlowSend( + wallet: Pick, + onSent: (value: bigint, order: EthFlowOrderParams | undefined) => void, +): void { + wallet.stubRpc('eth_sendTransaction', (({ params }) => { + const tx = params[0] as { value?: string; data?: Hex } + onSent(BigInt(tx.value ?? '0x0'), decodeEthFlowOrderParams(tx.data)) + return FAKE_ETH_FLOW_TX_HASH + }) as RpcStub) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockFixedRateQuote.ts b/apps/cowswap-e2e-tests/src/support/mockFixedRateQuote.ts new file mode 100644 index 00000000000..cf2b049a77b --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockFixedRateQuote.ts @@ -0,0 +1,44 @@ +import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' + +export interface MockFixedRateQuoteOpts { + cowApi: CowProtocolApiMock + /** + * The rate applied to compute the amount on the other side of the trade. Omit it to leave both + * `sellAmount`/`buyAmount` as the fixture provides them and only zero out the fee — enough for + * tests that don't care about the exact rate, just that the fee doesn't skew a balance + * assertion. + */ + rate?: { numerator: bigint; denominator: bigint } + /** + * Which side of the trade is "fixed" (the amount the user actually typed) and therefore which + * gets computed from `rate`: `'sell'` computes `buyAmount` from `sellAmount` (a sell order), + * `'buy'` computes `sellAmount` from `buyAmount` (a buy order). Defaults to `'sell'`. + */ + direction?: 'sell' | 'buy' +} + +/** + * Zeroes out `protocolFeeBps`/`feeAmount` on every `/api/v1/quote` response and, when a `rate` is + * given, pins the non-fixed side of the trade to that rate — the combination this suite's tests + * repeatedly need to keep a post-trade balance assertion a round number instead of one skewed by + * an arbitrary fixture fee. + */ +export function mockFixedRateQuote(opts: MockFixedRateQuoteOpts): void { + const { cowApi, rate, direction = 'sell' } = opts + + cowApi.set('quote', (req) => { + const defaults = req.defaults as { quote: Record } + + const computedAmount = !rate + ? {} + : direction === 'sell' + ? { buyAmount: ((BigInt(defaults.quote.sellAmount as string) * rate.numerator) / rate.denominator).toString() } + : { sellAmount: ((BigInt(defaults.quote.buyAmount as string) * rate.numerator) / rate.denominator).toString() } + + return { + ...defaults, + protocolFeeBps: '0', + quote: { ...defaults.quote, ...computedAmount, feeAmount: '0' }, + } + }) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockOrderPosting.ts b/apps/cowswap-e2e-tests/src/support/mockOrderPosting.ts new file mode 100644 index 00000000000..e33098f3795 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockOrderPosting.ts @@ -0,0 +1,156 @@ +import { Order, OrderStatus, OrderCreation } from '@cowprotocol/sdk-order-book' + +import type { BalancesMock } from '../mocks/balances' +import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' + +/** + * Emulates the orderbook accepting whatever order gets posted next: makes `accountOrders` + * reflect it as `open` right away. Posting alone does **not** settle it — call the returned + * `fulfill()` whenever the test wants the trade to go through. `fulfill()` then keeps the + * balances mock in sync with the trade (debits the sell token, credits the buy token), flips + * the order to `fulfilled` in `accountOrders`, and makes `orderStatus` report it as `traded` — + * the three things the real backend would eventually reflect once the trade settles on-chain. + * + * `markExecuting()` is a lighter-weight intermediate step for tests that also want to observe + * the order-progress bar's `EXECUTING` stage (solver picked a winner, submitting on-chain) before + * the trade actually settles — it only advances `orderStatus`, none of the balance/`accountOrders` + * bookkeeping `fulfill()` does, since nothing has actually executed yet at that stage. + * + * Page-agnostic (only wires CoW API mocks) — shared by swap, limit and TWAP order flows. + * + * The returned handle also lets a caller read the posted buyAmount/sellAmount back once the + * order goes through, since the app applies its own slippage on top of the quote — asserting on + * the resulting balance needs the amount that was actually posted, not the pre-slippage quote + * (buyAmount varies for a sell order, sellAmount varies for a buy order). + */ +export function mockOrderPosting( + cowApi: CowProtocolApiMock, + owner: string, +): { + getPostedBuyAmount(): string + getPostedSellAmount(): string + markExecuting(): void + fulfill(balances: BalancesMock, chainId: number, sellTokenBalanceBefore: bigint, buyTokenBalanceBefore: bigint): void +} { + let postedBody: OrderCreation | null = null + let postedOrder: Order | null = null + + // Starts out as the plain fixture list; once an order is posted, this starts prepending it — + // open, then fulfilled once `fulfill()` runs — so "My orders" reflects the order's actual + // lifecycle without the app ever seeing a real fill on-chain. + cowApi.set('accountOrders', (req) => { + const defaults = req.defaults as unknown[] + return postedOrder ? [postedOrder, ...defaults] : defaults + }) + + cowApi.set('postOrder', (req) => { + const body = req.body as OrderCreation + const uid = req.defaults as string + postedBody = body + postedOrder = buildOpenOrder(body, uid, owner) + return req.defaults + }) + + // `PendingOrdersUpdater` classifies pending orders (and decides whether a dismissed + // progress modal should reopen) off this single-order endpoint rather than `orderStatus` — + // without it, an order dismissed before `fulfill()` never gets picked back up. + cowApi.set('order', (req) => postedOrder ?? req.defaults) + + return { + getPostedBuyAmount: () => postedBody?.buyAmount ?? '', + getPostedSellAmount: () => postedBody?.sellAmount ?? '', + + markExecuting(): void { + if (!postedBody) { + throw new Error('mockOrderPosting: markExecuting() called before an order was posted') + } + + cowApi.set('orderStatus', () => buildOrderStatus('executing', postedBody as OrderCreation)) + }, + + fulfill( + balances: BalancesMock, + chainId: number, + sellTokenBalanceBefore: bigint, + buyTokenBalanceBefore: bigint, + ): void { + if (!postedBody || !postedOrder) { + throw new Error('mockOrderPosting: fulfill() called before an order was posted') + } + + balances.set(owner, chainId, { + [postedBody.sellToken]: (sellTokenBalanceBefore - BigInt(postedBody.sellAmount)).toString(), + [postedBody.buyToken]: (buyTokenBalanceBefore + BigInt(postedBody.buyAmount)).toString(), + }) + + postedOrder = { ...postedOrder, ...buildFulfilledOrderPatch(postedBody) } + + // Order-progress polls this once the order exists — "traded" is what moves it past + // "still searching" to a fulfilled state, mirroring the same fill emulated above. + cowApi.set('orderStatus', () => buildOrderStatus('traded', postedBody as OrderCreation)) + }, + } +} + +/** The subset of `PostedOrder` fields that change once the order actually settles. */ +function buildFulfilledOrderPatch( + body: OrderCreation, +): Pick { + return { + status: OrderStatus.FULFILLED, + executedBuyAmount: body.buyAmount, + executedSellAmount: body.sellAmount, + executedSellAmountBeforeFees: body.sellAmount, + executedFee: '123000000000', + } +} + +/** The order as the orderbook would report it right after accepting it — not yet settled. */ +function buildOpenOrder(body: OrderCreation, uid: string, owner: string): Order { + return { + creationDate: new Date().toISOString(), + owner, + uid, + availableBalance: null, + executedBuyAmount: '0', + executedSellAmount: '0', + executedSellAmountBeforeFees: '0', + executedFeeAmount: '0', + executedFee: '0', + executedFeeToken: body.sellToken, + invalidated: false, + status: 'open', + class: 'market', + settlementContract: '0xf553d092b50bdcbdded1a99af2ca29fbe5e2cb13', + isLiquidityOrder: false, + fullAppData: body.appData, + sellToken: body.sellToken, + buyToken: body.buyToken, + receiver: body.receiver, + sellAmount: body.sellAmount, + buyAmount: body.buyAmount, + validTo: body.validTo, + appData: body.appDataHash, + feeAmount: body.feeAmount, + kind: body.kind, + partiallyFillable: body.partiallyFillable, + sellTokenBalance: body.sellTokenBalance, + buyTokenBalance: body.buyTokenBalance, + signingScheme: body.signingScheme, + signature: body.signature, + interactions: { pre: [], post: [] }, + } as Order +} + +/** What order-progress polls to learn how a trade is being handled by the competition. */ +function buildOrderStatus(type: 'executing' | 'traded', body: OrderCreation): { type: string; value: unknown[] } { + return { + type, + value: [ + { + solver: '0x99b4136666ca1d13020830350ca8d01a0e5e466b', + executedAmounts: { sell: body.sellAmount, buy: body.buyAmount }, + }, + ], + } +} diff --git a/apps/cowswap-e2e-tests/src/support/mockUnwrapTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockUnwrapTransaction.ts new file mode 100644 index 00000000000..bba67318088 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockUnwrapTransaction.ts @@ -0,0 +1,93 @@ +import { decodeAbiParameters, type Hex } from 'viem' + +import { installNativeBalanceRoute } from './mockEthFlowTransaction' + +import type { MockWalletApi } from '../fixtures/mockWallet' +import type { BalancesMock } from '../mocks/balances' +import type { RpcStub } from '../mockWallet/walletEngine' +import type { BrowserContext } from '@playwright/test' + +const UINT256 = [{ type: 'uint256' }] as const + +const FAKE_UNWRAP_TX_HASH = `0x${'20'.repeat(32)}` as const + +/** `withdraw(uint256)` on WETH9 — verified via `viem`'s `toFunctionSelector('withdraw(uint256)')`. */ +const WITHDRAW_SELECTOR = '0x2e1a7d4d' + +export interface MockUnwrapTransactionHandle { + /** The WETH amount (wei) actually passed to the fake `withdraw()` transaction, once sent. */ + getSentValue(): bigint | undefined + getTxHash(): string + /** Marks the unwrap tx as mined, so `eth_getTransactionReceipt` starts reporting success. */ + confirmMined(): void + isMined(): boolean +} + +export interface MockUnwrapTransactionOpts { + context: BrowserContext + wallet: Pick + balances: BalancesMock + chainId: number + wethToken: string + initialEthBalance: bigint + initialWethBalance: bigint +} + +/** + * Fakes the native-ETH unwrap transaction end-to-end — the reverse of `mockWrapTransaction`. + * Unwrapping is a plain `withdraw(uint256)` call on the WETH contract + * (`legacy/hooks/useWrapCallback.ts`'s `unwrapContractCall`) — not a CoW order at all, so none of + * this suite's order-posting mocks apply. `eth_sendTransaction` goes through the connected wallet + * (stubbed here), same channel `mockWrapTransaction`/`mockEthFlowTransaction` use. Unlike wrapping + * (whose sent amount is the tx's own `value`), `withdraw`'s amount is a calldata argument — there's + * no ETH sent *to* the WETH contract, ETH comes *back* from it. The WETH side is debited directly + * through the normal `mocks.balances` SSE-watcher mock (WETH is a real ERC-20, no special handling + * needed) the moment the tx is "sent". The ETH side needs the same Multicall3 `getEthBalance` + * decode/patch `mockEthFlowTransaction` already built (native ETH balance is read that way, not via + * a bare `eth_getBalance`), reused here rather than duplicated — just added to the starting balance + * instead of subtracted from it. + */ +export async function mockUnwrapTransaction(opts: MockUnwrapTransactionOpts): Promise { + const { context, wallet, balances, chainId, wethToken, initialEthBalance, initialWethBalance } = opts + const rpcUrl = process.env[`REACT_APP_NETWORK_URL_${chainId}`] + if (!rpcUrl) throw new Error(`REACT_APP_NETWORK_URL_${chainId} not set`) + + let sentValue: bigint | undefined + let mined = false + + const stub: RpcStub = ({ params }) => { + if (sentValue !== undefined) { + throw new Error('mockUnwrapTransaction: only one unwrap transaction is supported per handle') + } + const tx = params[0] as { data?: Hex } + const data = tx.data ?? '0x' + if (!data.toLowerCase().startsWith(WITHDRAW_SELECTOR)) { + throw new Error(`mockUnwrapTransaction: expected a withdraw() call, got calldata ${data}`) + } + const [amount] = decodeAbiParameters(UINT256, `0x${data.slice(10)}` as Hex) + sentValue = amount + balances.set(wallet.address, chainId, { [wethToken]: initialWethBalance - sentValue }) + return FAKE_UNWRAP_TX_HASH + } + wallet.stubRpc('eth_sendTransaction', stub) + + // ETH is credited back the moment the fake tx is "sent" — same timing `mockWrapTransaction` + // debits it, and the same reasoning as `mockEthFlowTransaction`'s own native-balance patch. + await installNativeBalanceRoute({ + context, + rpcUrl, + owner: wallet.address, + txHash: FAKE_UNWRAP_TX_HASH, + getBalance: () => initialEthBalance + (sentValue ?? 0n), + isMined: () => mined, + }) + + return { + getSentValue: () => sentValue, + getTxHash: () => FAKE_UNWRAP_TX_HASH, + confirmMined: () => { + mined = true + }, + isMined: () => mined, + } +} diff --git a/apps/cowswap-e2e-tests/src/support/mockWrapTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockWrapTransaction.ts new file mode 100644 index 00000000000..a182e0c575d --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockWrapTransaction.ts @@ -0,0 +1,76 @@ +import { installNativeBalanceRoute } from './mockEthFlowTransaction' + +import type { MockWalletApi } from '../fixtures/mockWallet' +import type { BalancesMock } from '../mocks/balances' +import type { RpcStub } from '../mockWallet/walletEngine' +import type { BrowserContext } from '@playwright/test' + +const FAKE_WRAP_TX_HASH = `0x${'10'.repeat(32)}` as const + +export interface MockWrapTransactionHandle { + /** The native ETH amount (wei) actually sent in the fake `deposit()` transaction, once sent. */ + getSentValue(): bigint | undefined + getTxHash(): string + /** Marks the wrap tx as mined, so `eth_getTransactionReceipt` starts reporting success. */ + confirmMined(): void + isMined(): boolean +} + +export interface MockWrapTransactionOpts { + context: BrowserContext + wallet: Pick + balances: BalancesMock + chainId: number + wethToken: string + initialEthBalance: bigint + /** WETH the trader already holds before wrapping — `balances.set` replaces the token's value + * rather than adding to it, so this must be folded into the post-wrap figure explicitly. */ + initialWethBalance: bigint +} + +/** + * Fakes the native-ETH wrap transaction end-to-end. Wrapping is a plain `deposit()` call on the + * WETH contract (`legacy/hooks/useWrapCallback.ts`) — not a CoW order at all, so none of this + * suite's order-posting mocks apply. `eth_sendTransaction` goes through the connected wallet + * (stubbed here), same channel `mockApproveTransaction`/`mockEthFlowTransaction` use. The WETH + * side is credited directly through the normal `mocks.balances` SSE-watcher mock (WETH is a real + * ERC-20, no special handling needed) the moment the tx is "sent" — same as `mockApproveTransaction` + * updates its allowance mock inline. The ETH side needs the same Multicall3 `getEthBalance` + * decode/patch `mockEthFlowTransaction` already built (native ETH balance is read that way, not + * via a bare `eth_getBalance` — see that file for how this was confirmed), reused here via + * `installNativeBalanceRoute` rather than duplicated. + */ +export async function mockWrapTransaction(opts: MockWrapTransactionOpts): Promise { + const { context, wallet, balances, chainId, wethToken, initialEthBalance, initialWethBalance } = opts + const rpcUrl = process.env[`REACT_APP_NETWORK_URL_${chainId}`] + if (!rpcUrl) throw new Error(`REACT_APP_NETWORK_URL_${chainId} not set`) + + let sentValue: bigint | undefined + let mined = false + + const stub: RpcStub = ({ params }) => { + const tx = params[0] as { value?: string } + sentValue = BigInt(tx.value ?? '0x0') + balances.set(wallet.address, chainId, { [wethToken]: initialWethBalance + sentValue }) + return FAKE_WRAP_TX_HASH + } + wallet.stubRpc('eth_sendTransaction', stub) + + await installNativeBalanceRoute({ + context, + rpcUrl, + owner: wallet.address, + txHash: FAKE_WRAP_TX_HASH, + getBalance: () => initialEthBalance - (sentValue ?? 0n), + isMined: () => mined, + }) + + return { + getSentValue: () => sentValue, + getTxHash: () => FAKE_WRAP_TX_HASH, + confirmMined: () => { + mined = true + }, + isMined: () => mined, + } +} diff --git a/apps/cowswap-e2e-tests/src/support/readTitledAmount.ts b/apps/cowswap-e2e-tests/src/support/readTitledAmount.ts new file mode 100644 index 00000000000..3baae5f8e28 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/readTitledAmount.ts @@ -0,0 +1,18 @@ +import { parseUnits } from 'viem' + +import type { Locator } from '@playwright/test' + +/** + * Reads a descendant `[title]` element's exact-precision `" "` string (the + * `TokenAmount`/`FiatAmount` convention this suite's page objects already rely on, e.g. + * `sellBalance`/`buyBalance`) and parses the amount into raw atoms. + * + * `decimals` defaults to 18: both Sepolia test tokens in this suite (WETH and the fake "USDC") + * report 18 decimals on-chain, not USDC's real-world 6 — `support/tokens.ts` already resolves + * this correctly. + */ +export async function readTitledAmount(container: Locator, decimals = 18): Promise { + const title = await container.locator('[title]').getAttribute('title') + const [value] = (title ?? '').split(' ') + return parseUnits(value, decimals) +} diff --git a/apps/cowswap-e2e-tests/src/support/seedTrader.ts b/apps/cowswap-e2e-tests/src/support/seedTrader.ts new file mode 100644 index 00000000000..050f2faa991 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/seedTrader.ts @@ -0,0 +1,26 @@ +import type { AllowanceValue, AllowancesMock } from '../mocks/allowances' +import type { BalanceValue, BalancesMock } from '../mocks/balances' + +export interface SeedTraderOpts { + balances?: Record + allowances?: Record +} + +/** + * Sets a trader's balances/allowances directly by token address, in raw atoms. + * + * This is the same thing `setupTestConditions`'s own `balances`/`allowances` options do, keyed by + * token *symbol* with human-readable amounts and resolved via `support/tokens.ts` (18 decimals for + * this Sepolia deployment's fake USDC, not its real-world 6 — already correct there). Use this + * directly instead when a test doesn't go through `setupTestConditions` at all, or needs to seed + * by address rather than by the symbols `support/tokens.ts` knows about. + */ +export function seedTrader( + mocks: { balances: BalancesMock; allowances: AllowancesMock }, + wallet: { address: string }, + chainId: number, + opts: SeedTraderOpts, +): void { + if (opts.balances) mocks.balances.set(wallet.address, chainId, opts.balances) + if (opts.allowances) mocks.allowances.set(wallet.address, chainId, opts.allowances) +} diff --git a/apps/cowswap-e2e-tests/src/support/selectTokens.ts b/apps/cowswap-e2e-tests/src/support/selectTokens.ts new file mode 100644 index 00000000000..4fb646663b3 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/selectTokens.ts @@ -0,0 +1,16 @@ +import type { SwapPage } from '../pages/SwapPage' + +/** + * Picks `sell`/`buy` tokens via the real dropdown UI (`searchAndPick` already handles the + * "already selected" duplicate-currency-guard no-op, see `TokenSelector`). + * + * Not a fit for every test: when a token needs an amount typed into the sell field *before* it's + * selected (working around `useSetupTradeAmountsFromUrl`'s 1-unit auto-fill race, see [MO-11]), + * that ordering has to stay inline instead of going through this helper. + */ +export async function selectTokens(swapPage: SwapPage, sell: string, buy: string): Promise { + await swapPage.tokens.openInput() + await swapPage.tokens.searchAndPick(sell) + await swapPage.tokens.openOutput() + await swapPage.tokens.searchAndPick(buy) +} diff --git a/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts b/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts index 6b9beccbb1b..d571f83b2e7 100644 --- a/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts +++ b/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts @@ -45,6 +45,7 @@ function fakeAllowances(): AllowancesMock & { calls: Array<[string, number, Reco reads: () => [], reportUnknownOwners() {}, reset() {}, + resolve: () => 0n, } } diff --git a/apps/cowswap-e2e-tests/src/support/tokens.test.ts b/apps/cowswap-e2e-tests/src/support/tokens.test.ts deleted file mode 100644 index ea31eb2705a..00000000000 --- a/apps/cowswap-e2e-tests/src/support/tokens.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { strict as assert } from 'node:assert' -import { test } from 'node:test' - -import { CHAIN_IDS } from './constants' -import { resolveToken } from './tokens' - -test('resolves a known symbol on a known chain', () => { - const weth = resolveToken(CHAIN_IDS.SEPOLIA, 'WETH') - - assert.equal(weth.address, '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14') - assert.equal(weth.decimals, 18) -}) - -test('resolves Sepolia USDC with 18 decimals (this test token is not the real 6-decimal USDC)', () => { - const usdc = resolveToken(CHAIN_IDS.SEPOLIA, 'USDC') - - assert.equal(usdc.address, '0xbe72E441BF55620febc26715db68d3494213D8Cb') - assert.equal(usdc.decimals, 18) -}) - -test('throws with the known symbols when the symbol is not registered', () => { - assert.throws( - () => resolveToken(CHAIN_IDS.SEPOLIA, 'DAI'), - /unknown token symbol "DAI".*WETH.*USDC|unknown token symbol "DAI".*USDC.*WETH/, - ) -}) - -test('throws when the chain has no tokens registered', () => { - assert.throws(() => resolveToken(CHAIN_IDS.MAINNET, 'WETH'), /no tokens registered for chain 1/) -}) diff --git a/apps/cowswap-e2e-tests/src/support/tokens.ts b/apps/cowswap-e2e-tests/src/support/tokens.ts index 6232e42846a..94a39c3a7c8 100644 --- a/apps/cowswap-e2e-tests/src/support/tokens.ts +++ b/apps/cowswap-e2e-tests/src/support/tokens.ts @@ -13,6 +13,8 @@ const TOKENS: Partial>> = { // (confirmed against ../mocks/cowProtocolApi/fixtures/quote.json's buyAmount) — do not // "fix" this to 6 to match mainnet USDC. USDC: { address: '0xbe72E441BF55620febc26715db68d3494213D8Cb', decimals: 18 }, + DAI: { address: '0xB4F1737Af37711e9A5890D9510c9bB60e170CB0D', decimals: 18 }, + USDT: { address: '0x58eb19ef91e8a6327fed391b51ae1887b833cc91', decimals: 6 }, }, [CHAIN_IDS.GNOSIS]: { WXDAI: { address: '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d', decimals: 18 }, diff --git a/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts b/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts index cd0520912b4..9025538da9e 100644 --- a/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts @@ -1,9 +1,11 @@ -import type { Hex } from 'viem' +import { parseUnits, type Hex } from 'viem' import { test, expect } from '../fixtures' import { CHAIN_IDS } from '../support/constants' const CHAIN_ID = CHAIN_IDS.SEPOLIA +const USDC = '0xbe72E441BF55620febc26715db68d3494213D8Cb' +const COW = '0x0625aFB445C3B6B7B929342a04A22599fd5dBB59' // Connected wallet is a viem account from INTEGRATION_TEST_PRIVATE_KEY (mock wallet, no MetaMask extension). test.use({ mockWalletKey: process.env.INTEGRATION_TEST_PRIVATE_KEY as Hex | undefined }) @@ -27,4 +29,51 @@ test.describe('Limit Orders', () => { await limitPage.placeOrder() await expect(confirmModal.confirmButton).toContainText('Place limit order') }) + + test('[LO-02] Place sell limit order: USDC → COW, order shows up in the orders table', async ({ + limitPage, + tradePage, + wallet, + confirmModal, + mocks, + }) => { + // Both Sepolia test tokens report 18 decimals on-chain (verified via `decimals()`), not + // USDC's real-world 6 — `support/tokens.ts` disagrees and doesn't register COW at all — so + // balances/allowances are set directly here (mirrors [MO-06]) instead of going through + // `setupTestConditions`. + const SELL_AMOUNT = parseUnits('120', 18) + // The posted order's sellAmount includes the fee on top of the typed 120, so an allowance of + // exactly `SELL_AMOUNT` flags the order "Unfillable" in the table — give it headroom. + const ALLOWANCE = parseUnits('1000', 18) + mocks.balances.set(wallet.address, CHAIN_ID, { [USDC]: SELL_AMOUNT, [COW]: 0n }) + mocks.allowances.set(wallet.address, CHAIN_ID, { [USDC]: ALLOWANCE }) + + // Page-agnostic (only wires CoW API mocks). The real trade flow tags the pending order + // `class: LIMIT` locally before dispatch, and that local class always wins over a fetched + // order's — so this helper's hardcoded `class: 'market'` on the fabricated order doesn't + // filter it out of the Limit tab. + tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + + await limitPage.goto({ chainId: CHAIN_ID, sell: USDC, buy: COW }) + await limitPage.enterSellAmount('120') + await limitPage.waitForQuote() + + // USDC is a recognized Sepolia stablecoin, so once both amounts are quoted the app's smart + // quote-selection auto-quotes this pair by the non-stable side: the rate field ends up asking + // "When 1 COW is worth ? USDC" rather than "When 1 USDC is worth ? COW". 0.2 is the exact + // reciprocal of 5, so it encodes the same "1 USDC = 5 COW" price regardless of orientation. + await limitPage.setLimitPrice('0.2') + + await limitPage.placeOrder() + await expect(confirmModal.confirmButton).toContainText('Place limit order') + await confirmModal.confirm() + + // The mock wallet signs and `postOrder` responds instantly, so the flow skips past any + // transient progress step straight to the confirm modal's "Order Submitted" screen. + await expect(limitPage.orderSubmittedHeading).toBeVisible() + await limitPage.continueButton.click() + + await limitPage.openOrdersTab.click() + await expect(limitPage.ordersTable).toContainText('COW') + }) }) diff --git a/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts b/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts index 609ba84aa0a..856458812c8 100644 --- a/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts @@ -1,182 +1,1419 @@ -import type { Hex } from 'viem' +import { formatUnits, parseUnits, type Hex } from 'viem' + +import { areAddressesEqual, bpsToPercentage } from '@cowprotocol/cow-sdk' import { test, expect } from '../fixtures' import { reply } from '../mocks/cowProtocolApi' import { CHAIN_IDS } from '../support/constants' +import { expectActivityStatus } from '../support/expectActivityStatus' +import { mockApproveTransaction } from '../support/mockApproveTransaction' +import { mockCancellableOrder } from '../support/mockCancellableOrder' +import { mockEthFlowOrderIndexing } from '../support/mockEthFlowOrderIndexing' +import { mockEthFlowTransaction } from '../support/mockEthFlowTransaction' +import { mockFixedRateQuote } from '../support/mockFixedRateQuote' +import { mockUnwrapTransaction } from '../support/mockUnwrapTransaction' +import { mockWrapTransaction } from '../support/mockWrapTransaction' +import { readTitledAmount } from '../support/readTitledAmount' +import { seedTrader } from '../support/seedTrader' +import { selectTokens } from '../support/selectTokens' const USDC = '0xbe72E441BF55620febc26715db68d3494213D8Cb' const WETH = '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14' +const DAI = '0xB4F1737Af37711e9A5890D9510c9bB60e170CB0D' +const USDT = '0x58eb19ef91e8a6327fed391b51ae1887b833cc91' const CHAIN_ID = CHAIN_IDS.SEPOLIA -const DEFAULT_WETH_BALANCE = 1_000_000_000_000_000_000n // 1 WETH -const DEFAULT_USDC_BALANCE = 0n +test.describe('Market Orders', () => { + test.describe('Connected EOA wallet', () => { + test.use({ mockWalletKey: process.env.INTEGRATION_TEST_PRIVATE_KEY as Hex | undefined }) -test.use({ mockWalletKey: process.env.INTEGRATION_TEST_PRIVATE_KEY as Hex | undefined }) + // A default for every test in this file, per `AGENTS.md`'s "Using mocks" note — a test that + // forgets to seed its own balance (e.g. [CS-62], which never asserts on a balance figure at + // all) would otherwise leave `mocks.balances` unconfigured for its owner. Tests that care about + // a specific starting balance already override this via their own `seedTrader`/ + // `setupTestConditions` call, which simply replaces these two entries. + test.beforeEach(({ mocks, wallet }) => { + mocks.balances.set(wallet.address, CHAIN_ID, { [USDC]: parseUnits('1500', 18), [WETH]: parseUnits('10', 18) }) + }) -test.describe('Market Orders', () => { - test('[MO-01] Sell order: WETH → USDC @smoke', async ({ setupTestConditions, swapPage, confirmModal }) => { - await setupTestConditions({ - chainId: CHAIN_ID, - tradeType: 'swap', - sellToken: 'WETH', - buyToken: 'USDC', - sellAmount: '0.5', - balances: { WETH: '1', USDC: '0' }, - allowances: { WETH: '10' }, - }) - await expect(swapPage.outputAmount).not.toHaveValue('') - await swapPage.clickSwap() - await expect(confirmModal.confirmButton).toContainText(/confirm swap/i) - }) + test('[CS-59] Sell order: ERC-20 → ERC-20 @smoke', async ({ + swapPage, + tradePage, + wallet, + confirmModal, + accountModal, + mocks, + }) => { + // On this Sepolia deployment both test tokens report 18 decimals on-chain (verified via + // `decimals()`), not USDC's real-world 6 — `support/tokens.ts` already accounts for this. + // Raw atoms are computed here via `parseUnits` with an explicit 18 as a local literal, since + // this test doesn't go through `setupTestConditions`/`resolveToken` at all. + const INITIAL_USDC_BALANCE = parseUnits('1500', 18) + const BUY_RATE_NUM = 804n + const BUY_RATE_DEN = 1_000_000n // quote buyAmount ~= 0.804 WETH per 1000 USDC sold, pre-slippage - test('[MO-02] Sufficient allowance: proceeds straight to confirm swap', async ({ - setupTestConditions, - swapPage, - confirmModal, - }) => { - await setupTestConditions({ - chainId: CHAIN_ID, - tradeType: 'swap', - sellToken: 'WETH', - buyToken: 'USDC', - sellAmount: '0.5', - balances: { WETH: '1', USDC: '0' }, - allowances: { WETH: '10' }, - }) - await expect(swapPage.outputAmount).not.toHaveValue('') - - await expect(swapPage.swapButton).not.toContainText(/approve/i) - await swapPage.clickSwap() - await expect(confirmModal.confirmButton).toContainText(/confirm swap/i) - }) + // Zeroing the fee keeps the posted sellAmount matching the typed amount exactly, so the + // sell-side balance assertion below is a round number. The buy side still goes through the + // app's own slippage, so it's asserted dynamically via `posting.getPostedBuyAmount()` rather + // than a hardcoded figure. + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: BUY_RATE_NUM, denominator: BUY_RATE_DEN } }) + + const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + + // `usdPrices` defaults every token to $1 — under that assumption this trade's quoted rate + // looks like a ~99.9% loss and trips the "Confirm Price Impact" dialog. Pricing WETH to match + // the quote rate keeps the trade looking fair so that extra screen doesn't appear. + mocks.usdPrices.setPrice(WETH, Number(BUY_RATE_DEN) / Number(BUY_RATE_NUM)) + + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [USDC]: INITIAL_USDC_BALANCE, [WETH]: 0n }, + allowances: { [USDC]: INITIAL_USDC_BALANCE }, + }) + + await swapPage.goto({ chainId: CHAIN_ID }) + + // Typed before selecting tokens, not after: selecting a token with no amount set yet + // auto-fills 1 whole unit of it (`useSetupTradeAmountsFromUrl`'s + // `!isAtLeastOneAmountIsSetRef.current` default), which races the real typed amount's own + // debounced quote fetch and can win under load — same race as [CS-68]'s ETH-flow note, just + // hit here via `selectTokens` instead of a manual token switch. Typing first against + // whatever's already selected trips the "amount already set" guard before `selectTokens` runs, + // and the typed amount carries over once USDC/WETH are picked. + await swapPage.enterSellAmount('1000') + await selectTokens(swapPage, 'USDC', 'WETH') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '1500 USDC') + await expect(swapPage.buyBalance).toHaveAttribute('title', '0 WETH') + await expect(swapPage.inputAmount).toHaveValue('1000') + + await swapPage.waitForQuote() + + await swapPage.clickSwap() + await confirmModal.confirm() + + // Step 1 (INITIAL, backend OPEN/SCHEDULED) — order just posted, competition not started yet. + await expect(swapPage.orderProgressBarModal).toContainText('Batching orders') + + // Step 2 (SOLVING, backend ACTIVE — the default `orderStatus` fixture) — competition started, + // solvers searching for the best price. All 4 steps' titles are always rendered together + // regardless of which one is active (`StepsWrapper` renders the full `STEPS` list, see + // `constants.ts`), so "Batching orders" alone wouldn't distinguish this step from step 1 — + // `SolvingStep`'s own body text is the part unique to it being the *active* step. + // `useOrderProgressBarProps.ts`'s `MINIMUM_STEP_DISPLAY_TIME` holds step 1 on screen for at + // least 5s before advancing here too, racing the default 5s assertion timeout — same reason + // step 3 below needs more room than the default. + await expect(swapPage.orderProgressBarModal).toContainText('best price wins', { timeout: 15_000 }) - test('[MO-03] Insufficient allowance: asks for approval', async ({ setupTestConditions, swapPage }) => { - await setupTestConditions({ - chainId: CHAIN_ID, - tradeType: 'swap', - sellToken: 'WETH', - buyToken: 'USDC', - sellAmount: '0.5', - balances: { WETH: '1', USDC: '0' }, - allowances: { WETH: '0' }, + // Step 3 (EXECUTING) — solver picked a winner, submitting the trade on-chain. + // `ExecutingStep` overrides that step's own title to "Best price found!" while active. + // `useOrderProgressBarProps.ts`'s `MINIMUM_STEP_DISPLAY_TIME` holds each step on screen for at + // least 5s before advancing to the next one, so this needs more room than the default 5s. + posting.markExecuting() + await expect(swapPage.orderProgressBarModal).toContainText('Best price found!', { timeout: 15_000 }) + + await expectActivityStatus(accountModal, 'Open') + + // Settle the order now that it's posted and confirmed. + posting.fulfill(mocks.balances, CHAIN_ID, INITIAL_USDC_BALANCE, 0n) + + // Step 4 (FINISHED, backend TRADED) — trade settled. + await expect(swapPage.orderProgressBarModal).toContainText('Transaction completed!', { timeout: 15_000 }) + + // `FinishedStep`'s "You sold"/"Received" rows render the order's actual executed amounts + // (`order.apiAdditionalInfo.executedSellAmount`/`executedBuyAmount`), not the originally + // quoted ones — cross-check them against what `fulfill()` actually settled the order at. + const soldAmountRow = swapPage.orderProgressBarModal.locator('span', { hasText: 'You sold' }).first() + const receivedAmountRow = swapPage.orderProgressBarModal.locator('span', { hasText: 'Received' }).first() + expect(await readTitledAmount(soldAmountRow)).toBe(BigInt(posting.getPostedSellAmount())) + expect(await readTitledAmount(receivedAmountRow)).toBe(BigInt(posting.getPostedBuyAmount())) + + await swapPage.page.keyboard.press('Escape') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '500 USDC', { timeout: 15_000 }) + await expect(swapPage.buyBalance).toHaveAttribute( + 'title', + `${formatUnits(BigInt(posting.getPostedBuyAmount()), 18)} WETH`, + { timeout: 15_000 }, + ) + + await expectActivityStatus(accountModal, 'Filled') }) - await expect(swapPage.approveButton).toContainText(/approve/i) - }) + test('[CS-60] Buy order: specify exact buy amount (ERC-20) @smoke', async ({ + swapPage, + tradePage, + wallet, + confirmModal, + accountModal, + mocks, + }) => { + // Same 18-decimals quirk as [CS-59]. + const INITIAL_USDC_BALANCE = parseUnits('1500', 18) + const RATE = 1000n // 1 WETH = 1000 USDC + + // Mirrors [CS-59]'s technique, but derived from the quote's `buyAmount` instead of its + // `sellAmount` — for a buy order the typed amount fixes buyAmount exactly, and it's sellAmount + // that's quoted/slippage-adjusted. Fee/protocolFeeBps stay zeroed so the posted buyAmount + // matches the typed amount exactly, keeping the buy-side balance assertion a round number. + mockFixedRateQuote({ cowApi: mocks.cowApi, direction: 'buy', rate: { numerator: RATE, denominator: 1n } }) + + const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + + // `usdPrices` defaults every token to $1 — pricing WETH to match the quote rate keeps the + // trade looking fair so the "Confirm Price Impact" dialog doesn't appear, same as [CS-59]. + mocks.usdPrices.setPrice(WETH, Number(RATE)) + + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [USDC]: INITIAL_USDC_BALANCE, [WETH]: 0n }, + allowances: { [USDC]: INITIAL_USDC_BALANCE }, + }) + + await swapPage.goto({ chainId: CHAIN_ID }) + await selectTokens(swapPage, 'USDC', 'WETH') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '1500 USDC') + await expect(swapPage.buyBalance).toHaveAttribute('title', '0 WETH') + + await swapPage.enterBuyAmount('1') + await swapPage.waitForQuote() + + await swapPage.clickSwap() + await confirmModal.confirm() + + await expect(swapPage.orderProgressBarModal).toContainText('Batching orders') + await swapPage.page.keyboard.press('Escape') + await expect(swapPage.orderProgressBarModal).toBeHidden() + + await expectActivityStatus(accountModal, 'Open') + + // Settle the order now that it's posted and confirmed — mirrors [CS-59]. + posting.fulfill(mocks.balances, CHAIN_ID, INITIAL_USDC_BALANCE, 0n) + + // Unlike a still-open progress modal, this order was dismissed before settling — reopening it + // goes through the surplus-modal queue driven by `PendingOrdersUpdater`'s own polling cadence, + // so it needs more room than the default 5s — mirrors [CS-59]. + await expect(swapPage.orderProgressBarModal).toContainText('Transaction completed!', { timeout: 15_000 }) + await swapPage.page.keyboard.press('Escape') + + // Buy amount is fixed by the order kind — it lands exactly on the typed amount, unlike the + // sell side, which carries the app's own slippage buffer on top of the quote (see [CS-61]). + await expect(swapPage.buyBalance).toHaveAttribute('title', '1 WETH', { timeout: 15_000 }) + await expect(swapPage.sellBalance).toHaveAttribute( + 'title', + `${formatUnits(INITIAL_USDC_BALANCE - BigInt(posting.getPostedSellAmount()), 18)} USDC`, + { timeout: 15_000 }, + ) + + await expectActivityStatus(accountModal, 'Filled') + }) + + test('[CS-61] Buy order: approval amount includes slippage buffer @smoke', async ({ + swapPage, + wallet, + mocks, + context, + header, + confirmModal, + }) => { + // Fixed rate (1 WETH = 2000 USDC) with zero fee/protocolFeeBps keeps the sell side a clean + // round number derived from whatever buy amount was actually requested + mockFixedRateQuote({ cowApi: mocks.cowApi, direction: 'buy', rate: { numerator: 1n, denominator: 2000n } }) + // Matches the quote's implied rate so the trade doesn't look like a loss against the fixture's + // flat $1-per-token USD prices, which would otherwise trip the "Confirm Price Impact" dialog. + mocks.usdPrices.setPrice(WETH, 2000) + + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [WETH]: parseUnits('2', 18), [USDC]: 0n }, + // Precondition: sell token not yet approved. + allowances: { [WETH]: 0n }, + }) + + await swapPage.goto({ chainId: CHAIN_ID, sell: WETH, buy: USDC }) + await swapPage.enterBuyAmount('1000') + await swapPage.waitForQuote() + + // Select "Partial approval" so the wallet requests a finite amount tied to the trade instead + // of the default infinite (MaxUint256) approval — only then is there a "maximum sent" figure + // to compare against. + await swapPage.approveModeSelector.getByText('Partial approval').click() + const approvalAmount = await readTitledAmount(swapPage.approveModeSelector) + + // Faking the approve() end-to-end instead of letting it broadcast for real — see + // `mockApproveTransaction` for why both `eth_sendTransaction` and `eth_getTransactionReceipt` + // need stubbing, and at two different layers. + const approveMock = await mockApproveTransaction({ + context, + wallet, + allowances: mocks.allowances, + chainId: CHAIN_ID, + token: WETH, + }) + + await swapPage.approveButton.click() + + await expect(header.snackbarPopup).toContainText('Approve WETH', { timeout: 15_000 }) + + // Approving a buy order auto-advances into the swap confirm screen. Its "Maximum sent" row is + // the slippage-adjusted sell amount *without* the buy-order's +1% buffer + // (`getOrderTypeReceiveAmounts.ts`) — a deliberately different figure from the approve amount + // (`useAmountsToSignFromQuote.ts`'s `maximumSendSellAmount`, which adds that 1% on top). + const maximumSentRaw = await readTitledAmount(confirmModal.amountRow('Maximum sent')) + + // What the toggle showed before signing matches the real approve() calldata's amount. + expect(approvalAmount).toBe(approveMock.getApprovedAmount()) + + // The core relationship: approval amount = "Maximum sent" + the 1% buy-order buffer. + expect(approveMock.getApprovedAmount()).toBe((maximumSentRaw * 101n) / 100n) + }) + + test('[CS-62] Buy order: ETH as sell token (ETH-flow buy not supported) @smoke', async ({ swapPage }) => { + await swapPage.goto({ chainId: CHAIN_ID }) + + await swapPage.tokens.openInput() + await swapPage.tokens.searchAndPick('ETH') + + // Selling native ETH as an EOA (`isEoaEthFlowAtom`) makes the buy field read-only — + // there's no separate Sell/Buy order-kind toggle in this UI, so this is the only signal + // that the order kind is locked to Sell. + await expect(swapPage.outputAmount).not.toBeEditable() + }) + + test('[CS-63] Swap form: To field amount calculation @smoke', async ({ + setupTestConditions, + swapPage, + wallet, + mocks, + }) => { + // Same technique as [CS-59]: zero out fee/protocolFeeBps so the displayed To-amount is an + // exact, round multiple of the typed sell amount. Quote rate: 100 USDC -> 8 WETH, i.e. 1 WETH + // = 12.5 USDC. + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: 8n, denominator: 100n } }) + + // Pricing WETH 2% below the quote's implied rate ($12.25 instead of $12.50) makes the buy + // side's fiat value ($98) 2% under the sell side's ($100), producing a deterministic -2% + // price impact instead of ~0%. + mocks.usdPrices.setPrice(WETH, 12.25) + + // Set balances/allowances directly (both Sepolia test tokens report 18 decimals on-chain, + // same quirk as [CS-59]) rather than via `setupTestConditions`'s own `balances`/`allowances` + // options. `support/tokens.ts` already resolves this deployment's USDC to 18 decimals + // correctly, so this is no longer required to work around a decimals mismatch — it's simply + // how the test happens to be structured today. + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [USDC]: parseUnits('1000', 18), [WETH]: 0n }, + allowances: { [USDC]: parseUnits('1000', 18) }, + }) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: 'USDC', + buyToken: 'WETH', + sellAmount: '100', + }) + + // To amount = Sell amount × Price (from BE): 100 USDC × (8/100) = 8 WETH. + await expect(swapPage.outputAmount).toHaveValue('8') + + // USD estimation shown for both fields. + await expect(swapPage.sellFiatAmount).toContainText('$100') + await expect(swapPage.buyFiatAmount).toContainText('$98') + + // Price impact shown near the USD estimation, with its explanatory tooltip. + await expect(swapPage.priceImpact).toContainText('-2%') + await swapPage.priceImpactTooltipTrigger.hover() + await expect(swapPage.page.getByText('Price impact due to current liquidity levels')).toBeVisible() + }) - test('[MO-04] Sell order: balances update in the UI after the order is posted', async ({ - swapPage, - wallet, - confirmModal, - mocks, - }) => { - const PRICE_FACTOR = 12_000n // buy-token units per 1 sell-token unit — arbitrary, just needs to stay proportional - - mocks.balances.set(wallet.address, CHAIN_ID, { - [WETH]: DEFAULT_WETH_BALANCE.toString(), - [USDC]: DEFAULT_USDC_BALANCE.toString(), - }) - mocks.allowances.set(wallet.address, CHAIN_ID, { [WETH]: '10000000000000000000' }) - - // Pin buyAmount proportional to whatever sellAmount was actually requested, not a fixed - // absolute value: the swap form defaults the sell input to the full wallet balance before - // the test types its own amount, firing its own quote for that default amount first. A - // fixed buyAmount would make that stale quote and the real one indistinguishable, hiding - // the race below. Fee and protocolFeeBps are zeroed: the former so the posted order's - // sellAmount (quote sellAmount + fee) matches the typed amount exactly, the latter because - // the fixture's "0.3" otherwise gets layered on top of the displayed buy amount — both - // would otherwise turn the balance assertions after the trade into non-round numbers. - mocks.cowApi.set('quote', (req) => { - const defaults = req.defaults as { quote: Record } - const sellAmount = BigInt(defaults.quote.sellAmount as string) - return { - ...defaults, - protocolFeeBps: '0', - quote: { ...defaults.quote, buyAmount: (sellAmount * PRICE_FACTOR).toString(), feeAmount: '0' }, + test('[CS-64] Swap form: "Receive (incl. fees)" field calculation @smoke', async ({ + setupTestConditions, + swapPage, + mocks, + }) => { + const RATE = 2000n // 1 WETH = 2000 USDC + + // Non-zero protocol fee (1%) and network cost (also modeled as 1% of the sell amount, in + // sell-token terms) so both the "Protocol fee" and "Network costs" tooltip rows render with + // real amounts instead of "Free". Everything below is read back from the DOM rather than + // hardcoded, so the exact numbers only need to be non-zero, not any particular value. + mocks.cowApi.set('quote', (req) => { + const defaults = req.defaults as { quote: Record } + const sellAmount = BigInt(defaults.quote.sellAmount as string) + return { + ...defaults, + protocolFeeBps: '100', + quote: { + ...defaults.quote, + feeAmount: (sellAmount / 100n).toString(), + buyAmount: (sellAmount * RATE).toString(), + }, + } + }) + + // Matches the quote's implied rate so the trade doesn't look like a loss against the + // fixture's flat $1-per-token USD prices, which would otherwise trip the "Confirm Price + // Impact" dialog — same technique as [CS-59]. + mocks.usdPrices.setPrice(WETH, Number(RATE)) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: 'WETH', + buyToken: 'USDC', + sellAmount: '10', + balances: { WETH: '10', USDC: '0' }, + allowances: { WETH: '10' }, + }) + + await swapPage.receiveAmountTooltipTrigger.hover() + + const tooltipBox = swapPage.page.getByText('Before costs', { exact: true }).locator('xpath=../..') + await expect(tooltipBox).toBeVisible() + await expect(tooltipBox.getByText('Protocol fee', { exact: true })).toBeVisible() + await expect(tooltipBox.getByText('Network costs', { exact: true })).toBeVisible() + await expect(tooltipBox.getByText('To', { exact: true })).toBeVisible() + + const readRowAmount = (label: string): Promise => + readTitledAmount(tooltipBox.getByText(label, { exact: true }).locator('xpath=following-sibling::*[1]')) + + const beforeCosts = await readRowAmount('Before costs') + const protocolFee = await readRowAmount('Protocol fee') + const networkCosts = await readRowAmount('Network costs') + const toAmount = await readRowAmount('To') + + // The core relationship: To = Before costs − Network costs − Protocol fee. + expect(toAmount).toBe(beforeCosts - networkCosts - protocolFee) + + // The main "Receive (incl. fees)" field displays the same amount as the tooltip's "To" row. + const receiveTitle = await swapPage.receiveAmountValue.getAttribute('title') + const [receiveValue] = (receiveTitle ?? '').split(' ') + expect(parseUnits(receiveValue, 18)).toBe(toAmount) + }) + + test('[CS-65] Swap form: "Minimum receive" calculation in Confirm modal @smoke', async ({ + setupTestConditions, + swapPage, + wallet, + mocks, + confirmModal, + }) => { + const RATE_NUM = 8n + const RATE_DEN = 100n // quote rate: 100 USDC -> 8 WETH, i.e. 1 WETH = 12.5 USDC + + // Zero out fee/protocolFeeBps so "Expected to receive" (amountAfterFees) is an exact, round + // multiple of the typed sell amount — same technique as [CS-59]. + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: RATE_NUM, denominator: RATE_DEN } }) + + // Matches the quote's implied rate so the trade doesn't look like a loss against the + // fixture's flat $1-per-token USD prices, which would otherwise trip the "Confirm Price + // Impact" dialog — same technique as [CS-59]. + mocks.usdPrices.setPrice(WETH, Number(RATE_DEN) / Number(RATE_NUM)) + + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [USDC]: parseUnits('1000', 18), [WETH]: 0n }, + allowances: { [USDC]: parseUnits('1000', 18) }, + }) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: 'USDC', + buyToken: 'WETH', + sellAmount: '1000', + }) + + // Sets slippage tolerance, opens the Confirm modal, and returns "Minimum receive" after + // checking it against "Expected to receive" and confirming it's read-only. + const readMinimumReceiveAt = async (slippagePercent: string, slippageBps: bigint): Promise => { + await swapPage.setSlippage(slippagePercent) + await swapPage.clickSwap() + + const expectedToReceive = await readTitledAmount(confirmModal.amountRow('Expected to receive')) + const minimumReceive = await readTitledAmount(confirmModal.amountRow('Minimum receive')) + + // The core relationship: Minimum receive = Expected to receive × (1 − slippage%). + expect(minimumReceive).toBe((expectedToReceive * (10_000n - slippageBps)) / 10_000n) + + // Read-only: rendered as plain text inside the row, not an editable control. + const minimumReceiveRow = confirmModal.amountRow('Minimum receive') + await expect(minimumReceiveRow.locator('input, textarea, [contenteditable]')).toHaveCount(0) + + await swapPage.page.keyboard.press('Escape') + return minimumReceive } + + const minimumReceiveAt1Pct = await readMinimumReceiveAt('1', 100n) + const minimumReceiveAt2Pct = await readMinimumReceiveAt('2', 200n) + + // Changing slippage tolerance in settings recalculates "Minimum receive". + expect(minimumReceiveAt2Pct).not.toBe(minimumReceiveAt1Pct) + }) + + test('[CS-68] ETH-flow: place ETH sell order (EOA wallet) @smoke', async ({ + swapPage, + wallet, + context, + confirmModal, + accountModal, + mocks, + }) => { + const INITIAL_ETH_BALANCE = parseUnits('1', 18) + const SELL_AMOUNT = parseUnits('0.5', 18) + + // Selling native ETH doesn't POST an off-chain signed order like every other trade in this + // file — it sends an on-chain `createOrder()` tx to a dedicated EthFlow contract instead. See + // `mockEthFlowTransaction` for why this needs its own mock rather than `mockOrderPosting`. + const ethFlow = await mockEthFlowTransaction({ + context, + wallet, + chainId: CHAIN_ID, + initialEthBalance: INITIAL_ETH_BALANCE, + }) + + // `GET /api/v1/orders/{uid}`'s default fixture already answers any uid with a valid `open` + // order — exactly what flips the order out of `creating` once polled. Withholding that + // success (independently of `ethFlow.confirmMined()`, which only gates the *tx receipt*) + // keeps the "Creating Order" state below observable instead of racing straight past it: the + // real app moves through "Sending ETH" → "Sent ETH"/"Creating Order" → "Order Created" as two + // separate gates (tx receipt, then order indexed), not one. See `mockEthFlowOrderIndexing` for + // why this needs its own `order` override rather than `mockOrderPosting`. + const orderIndexing = mockEthFlowOrderIndexing(mocks.cowApi, ethFlow) + + // For an ETH-flow order the wei sent as `tx.value` is sellAmount + the quote's feeAmount + // (there's no separate ERC-20 fee deduction to hide it in) — zeroing it out, same technique as + // [CS-59]/[CS-63]/[CS-64], keeps the sent value and the post-tx balance round numbers below. + // No `rate` needed: this order's buyAmount is never asserted on, only that fees don't skew it. + mockFixedRateQuote({ cowApi: mocks.cowApi }) + + await swapPage.goto({ chainId: CHAIN_ID }) + + // Typed before switching the sell token to ETH, not after: selecting a token with no amount + // set yet auto-fills 1 whole unit of it (`useSetupTradeAmountsFromUrl`'s + // `!isAtLeastOneAmountIsSetRef.current` default), which races the real typed amount's own + // debounced quote fetch and can win under load — the mocked wallet balance here is exactly + // 1 ETH, so that default is indistinguishable from "sold everything" when it wins. Typing an + // amount first (against the default WETH sell token) marks one as already set, so switching to + // ETH afterwards carries the typed amount over instead of triggering the default. + await swapPage.enterSellAmount('0.5') + await swapPage.tokens.openInput() + await swapPage.tokens.searchAndPick('ETH') + await swapPage.tokens.openOutput() + await swapPage.tokens.searchAndPick('USDC') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '1 ETH') + await expect(swapPage.inputAmount).toHaveValue('0.5') + await swapPage.waitForQuote() + await expect(swapPage.inputAmount).toHaveValue('0.5') + + await swapPage.clickSwap() + await confirmModal.confirm() + + // Confirming signs/sends the on-chain creation tx directly (`eth_sendTransaction`, stubbed by + // `mockEthFlowTransaction`) — there's no separate off-chain EIP-712 signature for this flow. + await expect.poll(() => ethFlow.getSentValue()).toBe(SELL_AMOUNT) + + // Before the mocked receipt confirms, `EthFlowStepper`'s step 1 reads "Sending ETH" — matched + // `exact` since an SVG `` elsewhere on the page repeats the same text non-visibly. + await expect(swapPage.page.getByText('Sending ETH', { exact: true })).toBeVisible() + + // The creation tx hash is linked right there as step 1's own "View transaction" explorer link, + // verbatim in its `href` — scoped by accessible name since the snackbar in the corner links + // the same hash via its own "View on Etherscan" links. + const viewTransactionLink = swapPage.page.getByRole('link', { name: /view transaction/i }) + await expect(viewTransactionLink).toHaveAttribute('href', new RegExp(ethFlow.getTxHash())) + + // Let the mocked creation tx "mine" — step 1 becomes "Sent ETH" and step 2 becomes "Creating + // Order", since the order-by-uid poll above is still withheld (`orderIndexing` isn't marked + // indexed yet). + ethFlow.confirmMined() + + // "Creating Order" (`EthFlowStepper`'s step-2 label) has no stable container to scope to: the + // regular `#order-progress-bar-modal` div isn't even mounted yet at this point (its own setup + // is disabled while the order is still `creating`), so this checks the text directly. Getting + // here requires the app to notice the mocked receipt, which it only rechecks on a new block — + // real Sepolia block time, not a fixed poll interval — hence the generous timeout. + await expect(swapPage.page.getByText('Creating Order', { exact: true })).toBeVisible({ timeout: 30_000 }) + + // Let the order-by-uid poll start succeeding — this is what flips the order from `creating` to + // `pending`, rendered in the activities list as "Open". + orderIndexing.markIndexed() + + await expectActivityStatus(accountModal, 'Open', { timeout: 15_000 }) + + // With the order indexed, `EthFlowStepper`'s step 3 becomes the active step: "Receive USDC", + // pending — order-progress hasn't reported a fill yet. + await expect(swapPage.page.getByText('Receive USDC', { exact: true })).toBeVisible() + + // Settle the order now that it's posted and confirmed — mirrors `mockOrderPosting.fulfill()`, + // minus the `postOrder` bookkeeping that flow never goes through. Credits the buy-side balance + // with the amount actually encoded in the sent `createOrder()` calldata, and flips the `order` + // override above to report `fulfilled`. + const orderParams = ethFlow.getOrderParams() + if (!orderParams) throw new Error('mockEthFlowTransaction: fulfill attempted before an order was sent') + seedTrader(mocks, wallet, CHAIN_ID, { balances: { [USDC]: orderParams.buyAmount } }) + ethFlow.confirmFilled() + + // Once truly fulfilled, `TransactionSubmittedContent` stops rendering `EthFlowStepper` + // (`!isFinished`) and shows the same generic completed screen every other order type uses — + // there's no "Received USDC" checkmark state to catch, the stepper disappears entirely. This + // is what makes `#order-progress-bar-modal` get mounted in the first place, per [CS-59]/[CS-60]. + await expect(swapPage.orderProgressBarModal).toContainText('Transaction completed!', { timeout: 15_000 }) + + await expectActivityStatus(accountModal, 'Filled', { timeout: 15_000 }) + + // The order-submitted view is still covering the swap form (`CurrencyInputPanel` only renders + // a balance while `!disabled`) — dismiss it the same way [CS-59]/[CS-60] do. + await swapPage.page.keyboard.press('Escape') + + // Native ETH leaves the wallet as soon as the creation tx is sent (it's the tx's own `value`, + // not a separate settlement step) — by the time the order shows "Open" it's already reflected + // here. + await expect(swapPage.sellBalance).toHaveAttribute('title', '0.5 ETH', { timeout: 15_000 }) + await expect(swapPage.buyBalance).toHaveAttribute('title', `${formatUnits(orderParams.buyAmount, 18)} USDC`, { + timeout: 15_000, + }) }) - // The orderbook is fully mocked already (the shared `mocks` fixture blocks and fails the - // test on any un-mocked CoW API URL) — this additionally keeps the rest of the mock stack - // in sync with what posting the order actually did, exactly as the real backend would once - // the trade settles on-chain. - const fulfillment = swapPage.mockSwapFulfillment( - mocks.cowApi, - mocks.balances, - wallet.address, - CHAIN_ID, - DEFAULT_WETH_BALANCE, - DEFAULT_USDC_BALANCE, - ) - - await swapPage.goto({ chainId: CHAIN_ID, sell: WETH, buy: USDC }) - await swapPage.waitForQuote() - // The swap form defaults the sell input to the full wallet balance (1 WETH), firing its - // own quote for a not-quite-round amount (a pre-existing app quirk unrelated to this - // test). Wait for that response to settle — two stable reads in a row — before typing, - // so it can't race the fresh quote below and overwrite it when it resolves later. - await expect(async () => { - const before = await swapPage.outputAmount.inputValue() - await swapPage.page.waitForTimeout(300) - const after = await swapPage.outputAmount.inputValue() - expect(before).not.toBe('') - expect(before).toBe(after) - }).toPass({ timeout: 10_000 }) - - await swapPage.enterSellAmount('0.5') - const sellAmount = 500_000_000_000_000_000n // 0.5 WETH - // Waiting for the specific expected value (not just "non-empty") is what actually waits - // out the debounce — the stale full-balance quote above already satisfies "non-empty". - await expect(swapPage.outputAmount).toHaveValue(String((sellAmount * PRICE_FACTOR) / 10n ** 18n)) - - await expect(swapPage.sellBalance).toHaveAttribute('title', '1 WETH') - await expect(swapPage.buyBalance).toHaveAttribute('title', '0 USDC') - - await swapPage.clickSwap() - await confirmModal.confirm() - - // The currency panels hide their balance while a trade is pending/just-submitted - // (`CurrencyInputPanel` only renders it when `!disabled`). Posting the order opens the - // order-progress screen; `orderStatus` reporting "traded" (mocked above) is what moves it - // to a completed state, whose back arrow has no accessible name but dismisses on Escape, - // returning to the normal, interactive swap form. - await expect(swapPage.orderProgressBarModal).toContainText('Transaction completed!') - await swapPage.page.keyboard.press('Escape') - - // Waits out the balances-watcher SSE reconnect that picks up `mockSwapFulfillment`'s - // update above — Playwright's `expect` polls until this passes. - await expect(swapPage.sellBalance).toHaveAttribute('title', '0.5 WETH', { timeout: 15_000 }) - // The order's buyAmount is the quote's buyAmount minus the app's own slippage — assert - // against what was actually posted rather than re-deriving that math. - await expect(swapPage.buyBalance).toHaveAttribute( - 'title', - `${BigInt(fulfillment.getPostedBuyAmount()) / 10n ** 18n} USDC`, - { timeout: 15_000 }, - ) + test('[CS-71] ETH-flow: order status lifecycle', async ({ + swapPage, + wallet, + context, + confirmModal, + accountModal, + mocks, + }) => { + const INITIAL_ETH_BALANCE = parseUnits('1', 18) + const SELL_AMOUNT = parseUnits('0.5', 18) + + // Same ETH-flow mocking setup as [CS-68] — see that test's comments for why this needs + // `mockEthFlowTransaction` (on-chain `createOrder()`, no off-chain signature) and an inlined + // `order` override (no `postOrder` call exists for this flow to hook via `mockOrderPosting`). + const ethFlow = await mockEthFlowTransaction({ + context, + wallet, + chainId: CHAIN_ID, + initialEthBalance: INITIAL_ETH_BALANCE, + }) + + const orderIndexing = mockEthFlowOrderIndexing(mocks.cowApi, ethFlow) + + mockFixedRateQuote({ cowApi: mocks.cowApi }) + + await swapPage.goto({ chainId: CHAIN_ID }) + + // Typed before switching the sell token to ETH — dodges the auto-fill race documented at + // [CS-68]. + await swapPage.enterSellAmount('0.5') + await swapPage.tokens.openInput() + await swapPage.tokens.searchAndPick('ETH') + await swapPage.tokens.openOutput() + await swapPage.tokens.searchAndPick('USDC') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '1 ETH') + await expect(swapPage.inputAmount).toHaveValue('0.5') + await swapPage.waitForQuote() + await expect(swapPage.inputAmount).toHaveValue('0.5') + + await swapPage.clickSwap() + await confirmModal.confirm() + + // Creating (tx sent, not yet mined): "Sending ETH" is the active step, and the tx hash is + // already linked as its "View transaction" explorer link — same signals as [CS-68]. + await expect.poll(() => ethFlow.getSentValue()).toBe(SELL_AMOUNT) + await expect(swapPage.page.getByText('Sending ETH', { exact: true })).toBeVisible() + + const viewTransactionLink = swapPage.page.getByRole('link', { name: /view transaction/i }) + await expect(viewTransactionLink).toHaveAttribute('href', new RegExp(ethFlow.getTxHash())) + + // Still Creating (tx mined, order not indexed yet): "Creating Order" — the explorer link still + // points at the same creation tx. + ethFlow.confirmMined() + await expect(swapPage.page.getByText('Creating Order', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(viewTransactionLink).toHaveAttribute('href', new RegExp(ethFlow.getTxHash())) + + // Open (order indexed by the backend). + orderIndexing.markIndexed() + await accountModal.open() + await accountModal.activitiesList.scrollIntoViewIfNeeded() + await expect(accountModal.activitiesList).toContainText('Open', { timeout: 15_000 }) + + // Cancellable while Open (`isOrderCancellable` gates on order status alone, not order kind) — + // precondition for the "no longer possible" check once Filled, below. + const cancelLink = accountModal.activitiesList.getByText('Cancel order', { exact: true }) + await expect(cancelLink).toBeVisible() + await accountModal.close() + + // The order-submitted view is still covering the swap form (`CurrencyInputPanel` only renders + // a balance while `!disabled`, same as [CS-68]) — dismiss it to read the sell balance. Native + // ETH leaves the wallet as soon as the creation tx is sent (it's the tx's own `value`, not a + // separate settlement step), so it's already reflected here even though the order only just + // reached "Open". + await swapPage.page.keyboard.press('Escape') + await expect(swapPage.sellBalance).toHaveAttribute('title', '0.5 ETH', { timeout: 15_000 }) + + // Filled: settle the order — mirrors [CS-68]'s `fulfill()`-equivalent inline logic. Unlike + // [CS-68] (which keeps the progress view open throughout), it was already dismissed above to + // read the sell balance — the ETH-flow progress view doesn't reopen itself the way the regular + // (off-chain-signed) flow's surplus-modal queue does at [CS-60], so status/balance here are + // read via the activities list and swap form directly instead of waiting on it to reappear. + const orderParams = ethFlow.getOrderParams() + if (!orderParams) throw new Error('mockEthFlowTransaction: fulfill attempted before an order was sent') + seedTrader(mocks, wallet, CHAIN_ID, { balances: { [USDC]: orderParams.buyAmount } }) + ethFlow.confirmFilled() + + await expect(swapPage.buyBalance).toHaveAttribute('title', `${formatUnits(orderParams.buyAmount, 18)} USDC`, { + timeout: 15_000, + }) + + await accountModal.open() + await accountModal.activitiesList.scrollIntoViewIfNeeded() + await expect(accountModal.activitiesList).toContainText('Filled', { timeout: 15_000 }) + + // No longer cancellable once Filled — `isOrderCancellable` only allows CREATING/PENDING. + await expect(cancelLink).toBeHidden() + await accountModal.close() + }) + + test('[CS-79] Slippage: dynamic mode defaults and range (regular flow) @smoke', async ({ + setupTestConditions, + swapPage, + context, + }) => { + let dynamicSlippageBps = 20 // 0.2% — comfortably under the 2% banner threshold + await context.route(/bff\.(?:barn\.)?cow\.fi\/\d+\/markets\/.*\/slippageTolerance$/i, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ slippageBps: dynamicSlippageBps }), + }) + }) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: 'WETH', + buyToken: 'USDC', + sellAmount: '0.5', + balances: { WETH: '1' }, + allowances: { WETH: '1' }, + }) + + await swapPage.settingsDialogButton.click() + + const slippageInput = swapPage.slippageInput + const adjustedBanner = swapPage.page.getByText(/Slippage adjusted to [\d.]+% to ensure quick execution/) + + const readPlaceholderPercent = async (): Promise => + Number((await slippageInput.getAttribute('placeholder')) ?? NaN) + + // Dynamic ("Auto") slippage is selected by default: the input holds no custom value, only a + // placeholder showing the currently suggested percentage, tracking the mocked suggestion. + // `setupTestConditions`'s `waitForQuote()` only clears once the first ("fast") quote response + // lands — the smart-slippage hook ignores that one, so the placeholder settles slightly later. + await expect(slippageInput).toHaveValue('') + await expect.poll(readPlaceholderPercent, { timeout: 15_000 }).toBeCloseTo(bpsToPercentage(dynamicSlippageBps), 0) + + // The suggested value stays under 2%, so the "adjusted" banner doesn't show. + await expect(adjustedBanner).toBeHidden() + + // Range check: min/max aren't shown as static copy anywhere in the UI — the only concrete + // signal is this validation message, triggered by typing a value outside [0, 50] for the + // regular ERC-20 flow (native-ETH-sell orders have a separate 0.5% floor, not covered here — + // see the not-yet-written [CS-81]). + // `.fill('60')` was observed to silently no-op here (value stays empty) — `pressSequentially` + // (real keystrokes) is what actually lands the value; unclear why, but empirically reliable. + await slippageInput.click() + await slippageInput.pressSequentially('60') + await expect(swapPage.page.getByText('Enter slippage percentage between 0% and 50%.')).toBeVisible() + + // Blurring an out-of-range value reverts to dynamic mode and clears the input — same as + // `useSlippageInput`'s `onSlippageInputBlur` does for a user clicking away without confirming + // an invalid custom value. + await slippageInput.blur() + await expect(slippageInput).toHaveValue('') + + // Push the suggested value clearly above the 2% banner threshold and force a fresh quote to + // pick it up — demonstrates the value adjusting automatically as conditions (the mocked + // suggestion) change, and that the banner appears once it clears the threshold. + dynamicSlippageBps = 900 // 9% + await swapPage.page.keyboard.press('Escape') + await swapPage.enterSellAmount('0.6') + await swapPage.waitForQuote() + await swapPage.settingsDialogButton.click() + + // `waitForQuote()` only waits out the loading spinner for the first ("fast") quote response — + // the smart-slippage hook explicitly ignores fast quotes and keeps the last valid value until + // the slower, BFF-informed quote lands, so the placeholder needs its own poll rather than a + // single read right after the spinner clears. + await expect.poll(readPlaceholderPercent, { timeout: 15_000 }).toBeGreaterThan(2) + expect(await readPlaceholderPercent()).toBeCloseTo(bpsToPercentage(dynamicSlippageBps), 0) + await expect(adjustedBanner).toBeVisible({ timeout: 15_000 }) + + const bannerText = (await adjustedBanner.textContent()) ?? '' + const [, adjustedPercent] = /Slippage adjusted to ([\d.]+)% to ensure quick execution/.exec(bannerText) ?? [] + expect(Number(adjustedPercent)).toBeGreaterThan(2) + }) + + test('[CS-87] Token not approved (non-permittable, no bundling): approval button shown @smoke', async ({ + swapPage, + wallet, + mocks, + context, + header, + confirmModal, + }) => { + // WETH is the non-permittable token already used throughout this file (no EIP-2612 support, + // so there's never a cached permit signature to fall back to) — `TradeApproveButton`'s + // `noCachedPermit` is therefore always true for it, which is what selects the "Approve and + // Swap" label (`useGetConfirmButtonLabel('approve', ...)`) over the plain "Swap" one. + // "Wallet does not support bundling" doesn't need separate setup: this suite's mock EOA wallet + // is a plain injected-provider wallet, not a smart-contract wallet capable of batching approve + // + swap into one transaction, so it already exercises the two-separate-transactions path this + // scenario is about. + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: 2000n, denominator: 1n } }) + mocks.usdPrices.setPrice(WETH, 2000) + + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [WETH]: parseUnits('2', 18), [USDC]: 0n }, + // Precondition: sell token has no existing approval. + allowances: { [WETH]: 0n }, + }) + + // No `mockOrderPosting` here — this test only needs to know an order was (or wasn't yet) + // posted, not settle it, so a bare flag on the `postOrder` override is enough to prove + // ordering against the approval below. + let orderPosted = false + mocks.cowApi.set('postOrder', (req) => { + orderPosted = true + return req.defaults + }) + + await swapPage.goto({ chainId: CHAIN_ID, sell: WETH, buy: USDC }) + await swapPage.enterSellAmount('1') + await swapPage.waitForQuote() + + // The action button reads "Approve and Swap" — not a generic "Approve" — confirming the + // single-button, non-permittable flow this scenario is about. + await expect(swapPage.approveButton).toContainText('Swap') + + // Faking the approve() end-to-end instead of letting it broadcast for real — see + // `mockApproveTransaction` for why both `eth_sendTransaction` and `eth_getTransactionReceipt` + // need stubbing, and at two different layers. + const approveMock = await mockApproveTransaction({ + context, + wallet, + allowances: mocks.allowances, + chainId: CHAIN_ID, + token: WETH, + }) + + await swapPage.approveButton.click() + await expect(header.snackbarPopup).toContainText('Approve WETH', { timeout: 15_000 }) + + // The approval transaction is sent — and, since approving auto-advances into the swap confirm + // screen without posting anything, no order exists yet at this point. + expect(approveMock.getApprovedAmount()).toBeDefined() + expect(orderPosted).toBe(false) + + // Only placing the swap from here on posts the order — proving the approval tx really did + // happen before it, not just alongside it. + await confirmModal.confirm() + await expect.poll(() => orderPosted).toBe(true) + }) + + test('[CS-99] Token approval: gasless approval (EIP-2612 permit) @smoke', async ({ + swapPage, + wallet, + mocks, + confirmModal, + context, + }) => { + // Whether a token supports EIP-2612 permit isn't decided by probing the token's own contract + // in this app (that on-chain fallback needs a real `nonces()`/`permit()`-implementing contract, + // which this suite's fake Sepolia "USDC" isn't) — it's resolved from a pre-generated list + // fetched from `files.cow.fi` first (`usePreGeneratedPermitInfo.ts`), and the on-chain probe is + // skipped entirely once that list responds. Mocking this CDN endpoint is enough to make the + // fake token register as permit-compatible. + await context.route(/files\.cow\.fi\/token-lists\/PermitInfo\.\d+\.json$/i, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ [USDC.toLowerCase()]: { type: 'eip-2612', name: 'USDC', version: '2' } }), + }) + }) + + // Clicking the action button is identical either way (same `#approve-trade-button`, same + // "Approve and Swap" label, see [CS-87]) — `useApproveAndSwap`'s `handlePermit()` branches on + // token support *inside* the click handler: a permit-supported token signs a typed-data + // message and skips the on-chain `approve()` call entirely. + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [USDC]: parseUnits('1500', 18), [WETH]: 0n }, + // Precondition: no existing on-chain approval — irrelevant to the permit path itself, but + // keeps this consistent with [CS-87] and confirms the button renders regardless of the reason. + allowances: { [USDC]: 0n }, + }) + + // Matches the quote's implied rate so the trade doesn't look like a loss against the fixture's + // flat $1-per-token USD prices, which would otherwise trip the "Confirm Price Impact" dialog. + mocks.usdPrices.setPrice(WETH, 2000) + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: 1n, denominator: 2000n } }) + + let uploadedAppData: string | undefined + let uploadedAppDataHash: string | undefined + mocks.cowApi.set('putAppData', (req) => { + uploadedAppData = (req.body as { fullAppData: string }).fullAppData + uploadedAppDataHash = req.params.hash + return req.params.hash + }) + + let postedOrderAppDataHash: string | undefined + mocks.cowApi.set('postOrder', (req) => { + postedOrderAppDataHash = (req.body as { appDataHash?: string }).appDataHash + return req.defaults + }) + + await swapPage.goto({ chainId: CHAIN_ID, sell: USDC, buy: WETH }) + await swapPage.enterSellAmount('1000') + await swapPage.waitForQuote() + + await expect(swapPage.approveButton).toContainText('Approve and Swap') + await swapPage.approveButton.click() + + // The wallet is asked to sign the permit — an EIP-712 `Permit` message, not a transaction — + // before the swap gets confirmed below. This suite's mock wallet already signs whatever + // typed-data it's handed (no special stub needed, see `walletEngine.ts`), so the request is + // read back from its own call log rather than mocked. + await expect.poll(() => wallet.rpcCalls('eth_signTypedData_v4').length).toBeGreaterThan(0) + const permitSignRequest = wallet + .rpcCalls('eth_signTypedData_v4') + .map((call) => JSON.parse(call.params[1] as string)) + .find((typedData) => typedData.primaryType === 'Permit') + expect(areAddressesEqual(permitSignRequest?.domain?.verifyingContract, USDC)).toBe(true) + + // No on-chain approval transaction is ever sent — the permit signature replaces it entirely. + expect(wallet.rpcCalls('eth_sendTransaction')).toHaveLength(0) + + // Signing auto-advances into the swap confirm screen, same as a real approval does. + await confirmModal.confirm() + + // The signed permit is what gets "executed with the swap settlement": it's uploaded as a + // pre-interaction CoW Hook on the order's appData, not a separate approve() call. + await expect.poll(() => uploadedAppData).toBeDefined() + const appData = JSON.parse(uploadedAppData as string) + const permitHook = appData.metadata.hooks.pre.find((hook: { target?: string }) => + areAddressesEqual(hook.target, USDC), + ) + expect(permitHook?.dappId).toBe('cow-swap://libs/hook-dapp-lib/permit') + + // The permit hook alone doesn't prove it's actually part of *this* order — the signed order + // must reference the exact appData hash that content was uploaded under, or the permit hook + // would never be picked up by the settlement. + await expect.poll(() => postedOrderAppDataHash).toBeDefined() + expect(postedOrderAppDataHash).toBe(uploadedAppDataHash) + }) + + test('[CS-103] Wrap ETH → WETH via swap form @smoke', async ({ swapPage, wallet, mocks, context }) => { + const INITIAL_ETH_BALANCE = parseUnits('1', 18) + const WRAP_AMOUNT = parseUnits('0.5', 18) + + // Selecting ETH as sell and WETH as buy isn't a CoW order at all — `validateTradeForm.ts` + // recognizes it as `WrapUnwrapFlow` and swaps in a local `deposit()` call on the WETH contract + // (`legacy/hooks/useWrapCallback.ts`) instead of the usual quote/sign/post flow. See + // `mockWrapTransaction` for why this needs its own mock rather than `mockEthFlowTransaction` + // (no order, no CoW API involvement at all) or `mockApproveTransaction` (different calldata). + const wrapTx = await mockWrapTransaction({ + context, + wallet, + balances: mocks.balances, + chainId: CHAIN_ID, + wethToken: WETH, + initialEthBalance: INITIAL_ETH_BALANCE, + initialWethBalance: 0n, + }) + + seedTrader(mocks, wallet, CHAIN_ID, { balances: { [WETH]: 0n } }) + + await swapPage.goto({ chainId: CHAIN_ID }) + + // Typed before switching the sell token to ETH, not after — see [CS-68]'s note on + // `useSetupTradeAmountsFromUrl`'s 1-unit auto-fill racing the real typed amount when a token + // with no amount set yet is selected. That mitigation alone isn't airtight for a *native* ETH + // pick specifically: selecting a new input currency awaits `crossChainFamilySwitch()` before + // applying the selection (`useOpenTokenSelectWidget.ts`), a real microtask gap that can still + // let the 1-unit default win under CI load. Retyping once both switches have landed removes + // any dependency on that race — there's no further currency switch left to lose the amount to. + await swapPage.enterSellAmount('0.5') + await swapPage.tokens.openInput() + await swapPage.tokens.searchAndPick('ETH') + await swapPage.tokens.openOutput() + await swapPage.tokens.searchAndPick('WETH') + await swapPage.enterSellAmount('0.5') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '1 ETH') + await expect(swapPage.inputAmount).toHaveValue('0.5') + + // The action button reads "Wrap", not "Swap" — this validation state's button doesn't carry + // the `#do-trade-button` id the ordinary swap/approve states do (same gap found in [CS-102]), + // so `swapPage.wrapButton` matches it by its own text instead of `swapPage.swapButton`. + await expect(swapPage.wrapButton).toBeVisible() + await swapPage.wrapButton.click() + + // Confirming signs/sends the on-chain `deposit()` tx directly — there's no off-chain signature + // step for a wrap, and no CoW API call of any kind. + await expect.poll(() => wrapTx.getSentValue()).toBe(WRAP_AMOUNT) + wrapTx.confirmMined() + + // ETH decreases and WETH increases by the same wrapped amount. + await expect(swapPage.sellBalance).toHaveAttribute('title', '0.5 ETH', { timeout: 15_000 }) + await expect(swapPage.buyBalance).toHaveAttribute('title', '0.5 WETH', { timeout: 15_000 }) + }) + + test('[CS-104] Unwrap WETH → ETH via swap form @smoke', async ({ swapPage, wallet, mocks, context }) => { + const INITIAL_WETH_BALANCE = parseUnits('1', 18) + const INITIAL_ETH_BALANCE = parseUnits('1', 18) + const UNWRAP_AMOUNT = parseUnits('0.5', 18) + + // Selecting WETH as sell (the default) and ETH as buy isn't a CoW order at all either — the + // reverse of [CS-103]: `validateTradeForm.ts` still recognizes it as `WrapUnwrapFlow`, but + // routes to a local `withdraw()` call on the WETH contract instead of `deposit()` + // (`legacy/hooks/useWrapCallback.ts`'s `unwrapContractCall`). See `mockUnwrapTransaction` for + // why this needs its own mock rather than reusing `mockWrapTransaction` directly — `withdraw`'s + // amount is a calldata argument, not the tx's own `value` the way `deposit`'s is. + const unwrapTx = await mockUnwrapTransaction({ + context, + wallet, + balances: mocks.balances, + chainId: CHAIN_ID, + wethToken: WETH, + initialEthBalance: INITIAL_ETH_BALANCE, + initialWethBalance: INITIAL_WETH_BALANCE, + }) + + seedTrader(mocks, wallet, CHAIN_ID, { balances: { [WETH]: INITIAL_WETH_BALANCE } }) + + await swapPage.goto({ chainId: CHAIN_ID }) + + // WETH is already the default sell token on Sepolia (see known quirks), so only the buy side + // needs switching — typed before switching, not after, same auto-fill race as [CS-68]/[CS-103]. + await swapPage.enterSellAmount('0.5') + await swapPage.tokens.openOutput() + await swapPage.tokens.searchAndPick('ETH') + await swapPage.enterSellAmount('0.5') + + await expect(swapPage.sellBalance).toHaveAttribute('title', '1 WETH') + await expect(swapPage.inputAmount).toHaveValue('0.5') + + // The action button reads "Unwrap", not "Swap" — same gap as [CS-103]'s "Wrap" button, which + // doesn't carry the `#do-trade-button` id the ordinary swap/approve states do. + await expect(swapPage.unwrapButton).toBeVisible() + await swapPage.unwrapButton.click() + + // Confirming signs/sends the on-chain `withdraw()` tx directly — there's no off-chain + // signature step for an unwrap, and no CoW API call of any kind. + await expect.poll(() => unwrapTx.getSentValue()).toBe(UNWRAP_AMOUNT) + unwrapTx.confirmMined() + + // WETH decreases and ETH increases by the same unwrapped amount — 1:1, no slippage or + // protocol fee, since this flow never goes through a quote at all. + await expect(swapPage.sellBalance).toHaveAttribute('title', '0.5 WETH', { timeout: 15_000 }) + await expect(swapPage.buyBalance).toHaveAttribute('title', '1.5 ETH', { timeout: 15_000 }) + }) + + test('[CS-111] Cancel market order: off-chain soft cancellation (EOA) @smoke', async ({ + swapPage, + wallet, + mocks, + accountModal, + }) => { + // Deliberately not created through the swap UI (per spec) — seeded directly via + // `mockCancellableOrder` instead. See that helper for why mocking `accountOrders` is the + // correct lever (not something reverse-engineered from localStorage). + const cancellableOrder = mockCancellableOrder({ + cowApi: mocks.cowApi, + owner: wallet.address, + sellToken: WETH, + buyToken: USDC, + sellAmount: parseUnits('1', 18), + buyAmount: parseUnits('2000', 18), + }) + + // `OrdersFromApiUpdater` only turns a fetched order into local state once it can resolve both + // its sell/buy tokens from `useAllActiveTokens()` — selecting them via the real dropdown UI, + // same as [CS-59]/[CS-60], is what gets them into that set (no order is ever created through + // this UI, only the token registration piggybacks on it). + await swapPage.goto({ chainId: CHAIN_ID }) + await selectTokens(swapPage, 'WETH', 'USDC') + + // `OrdersFromApiUpdater` only picks this up once its own effects settle — longer than the + // default 5s. + await accountModal.open() + await accountModal.activitiesList.scrollIntoViewIfNeeded() + await expect(accountModal.activitiesList).toContainText('Open', { timeout: 15_000 }) + + const cancelLink = accountModal.activitiesList.getByText('Cancel order', { exact: true }) + await expect(cancelLink).toBeVisible() + await cancelLink.click() + + // Clicking "Cancel order" only opens a confirmation modal (`RequestCancellationModal`) — the + // actual off-chain signature + DELETE only fire once this button is clicked too. + await accountModal.requestCancellationButton.click() + + // The wallet is asked to sign an `OrderCancellations` EIP-712 message (`orderUids: bytes[]`, + // see `@cowprotocol/sdk-contracts-ts`'s `CANCELLATIONS_TYPE_FIELDS`) — not a transaction. + await expect.poll(() => cancellableOrder.wasCancelRequested()).toBe(true) + const cancellationSignRequest = wallet + .rpcCalls('eth_signTypedData_v4') + .map((call) => JSON.parse(call.params[1] as string)) + .find((typedData) => typedData.primaryType === 'OrderCancellations') + expect(cancellationSignRequest?.message?.orderUids).toContain(cancellableOrder.uid) + + // No gas transaction is ever sent for a soft cancellation. + expect(wallet.rpcCalls('eth_sendTransaction')).toHaveLength(0) + + // The API now considers the order invalidated — the order's own `creationDate` hasn't cleared + // `PENDING_ORDERS_BUFFER` yet, so the UI shows the transient "Cancelling..." state first + // (`isCancelling: apiStatus === 'pending' && order.invalidated`, `OrdersFromApiUpdater.ts`). + cancellableOrder.markCancelled() + await expect(accountModal.activitiesList).toContainText('Cancelling...', { timeout: 45_000 }) + + // Once enough real time has passed since `creationDate`, `isOrderCancelled` flips true and the + // order settles into its final "Cancelled" state — genuinely time-dependent, hence the long + // timeout rather than a flaw in the mock. + await expect(accountModal.activitiesList).toContainText('Cancelled', { timeout: 60_000 }) + }) + + test('[CS-118] Progress bar: regular order happy path — steps 1 → 2 → 3 → 4', async ({ + swapPage, + tradePage, + wallet, + confirmModal, + mocks, + }) => { + // Same 18-decimals quirk as [CS-59]. + const INITIAL_USDC_BALANCE = parseUnits('1500', 18) + const BUY_RATE_NUM = 804n + const BUY_RATE_DEN = 1_000_000n // quote buyAmount ~= 0.804 WETH per 1000 USDC sold, pre-slippage + + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: BUY_RATE_NUM, denominator: BUY_RATE_DEN } }) + + const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + + // Matches the quote's implied rate so the trade doesn't look like a loss against the + // fixture's flat $1-per-token USD prices, which would otherwise trip the "Confirm Price + // Impact" dialog — same technique as [CS-59]. + mocks.usdPrices.setPrice(WETH, Number(BUY_RATE_DEN) / Number(BUY_RATE_NUM)) + + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [USDC]: INITIAL_USDC_BALANCE, [WETH]: 0n }, + allowances: { [USDC]: INITIAL_USDC_BALANCE }, + }) + + await swapPage.goto({ chainId: CHAIN_ID }) + + // Typed before selecting tokens, not after — dodges the auto-fill race documented at [CS-59]. + await swapPage.enterSellAmount('1000') + await selectTokens(swapPage, 'USDC', 'WETH') + await swapPage.waitForQuote() + + await swapPage.clickSwap() + await confirmModal.confirm() + + // Step 1 (INITIAL, backend OPEN/SCHEDULED) — order just signed and posted, competition hasn't + // started yet. + await expect(swapPage.orderProgressBarModal).toContainText('Batching orders') + + // Step 2 (SOLVING, backend ACTIVE — the default `orderStatus` fixture) — competition started, + // solvers searching for the best price. All 4 steps' titles are always rendered together + // (`StepsWrapper`), so `SolvingStep`'s own body text ("best price wins") is what distinguishes + // this step as the active one, same as [CS-59]. `MINIMUM_STEP_DISPLAY_TIME` holds step 1 on + // screen for at least 5s before advancing here, hence the longer timeout. + await expect(swapPage.orderProgressBarModal).toContainText('best price wins', { timeout: 15_000 }) + + // Step 3 (EXECUTING) — solver picked a winner, submitting the trade on-chain. `ExecutingStep` + // overrides that step's own title to "Best price found!" while active. + posting.markExecuting() + await expect(swapPage.orderProgressBarModal).toContainText('Best price found!', { timeout: 15_000 }) + + // Settle the order now that it's posted and confirmed. + posting.fulfill(mocks.balances, CHAIN_ID, INITIAL_USDC_BALANCE, 0n) + + // Step 4 (FINISHED, backend TRADED) — trade settled, filled confirmation shown. + await expect(swapPage.orderProgressBarModal).toContainText('Transaction completed!', { timeout: 15_000 }) + + // `FinishedStep`'s "You sold"/"Received" rows render the order's actual executed amounts, not + // the originally quoted ones — cross-check them against what `fulfill()` actually settled the + // order at, same as [CS-59]. + const soldAmountRow = swapPage.orderProgressBarModal.locator('span', { hasText: 'You sold' }).first() + const receivedAmountRow = swapPage.orderProgressBarModal.locator('span', { hasText: 'Received' }).first() + expect(await readTitledAmount(soldAmountRow)).toBe(BigInt(posting.getPostedSellAmount())) + expect(await readTitledAmount(receivedAmountRow)).toBe(BigInt(posting.getPostedBuyAmount())) + }) + + test('[CS-127] Swap form: protocol fee applied at 0.02% (2 bps) for standard token pair @smoke', async ({ + setupTestConditions, + swapPage, + mocks, + }) => { + const RATE = 2000n // 1 WETH = 2000 USDC — arbitrary, same convention as [CS-64] + + // `protocolFeeBps` is a top-level field on the quote response, not nested under `quote` (see + // `useTradeQuoteProtocolFee.ts`). Zeroing `feeAmount` (network cost) removes that term from + // "Before costs" entirely, so `protocolFee / beforeCosts` reduces to exactly + // `protocolFeeBps / 10000` instead of being diluted by an unrelated network-cost fraction — + // the SDK reverses a sell order's protocol fee out of `buyAmount` as + // `buyAmount * protocolFeeBps / (10000 - protocolFeeBps)`, so with `feeAmount = 0`: + // `beforeCosts = buyAmount + protocolFee = buyAmount * 10000 / (10000 - protocolFeeBps)`, and + // `protocolFee / beforeCosts = protocolFeeBps / 10000` exactly (mod integer-division rounding). + mocks.cowApi.set('quote', (req) => { + const defaults = req.defaults as { quote: Record } + const sellAmount = BigInt(defaults.quote.sellAmount as string) + return { + ...defaults, + protocolFeeBps: '2', + quote: { + ...defaults.quote, + feeAmount: '0', + buyAmount: (sellAmount * RATE).toString(), + }, + } + }) + + // Matches the quote's implied rate so the trade doesn't look like a loss against the + // fixture's flat $1-per-token USD prices, which would otherwise trip the "Confirm Price + // Impact" dialog — same technique as [CS-64]. + mocks.usdPrices.setPrice(WETH, Number(RATE)) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: 'WETH', + buyToken: 'USDC', + sellAmount: '10', + balances: { WETH: '10', USDC: '0' }, + allowances: { WETH: '10' }, + }) + + await swapPage.receiveAmountTooltipTrigger.hover() + + const tooltipBox = swapPage.page.getByText('Before costs', { exact: true }).locator('xpath=../..') + await expect(tooltipBox).toBeVisible() + + const protocolFeeCell = tooltipBox + .getByText('Protocol fee', { exact: true }) + .locator('xpath=following-sibling::*[1]') + + // The surplus/buy token (USDC), with a leading "-" — `FeeItem` renders a sell order's fee rows + // with `typeString = '-'` and `feeAmount.currency` (the buy token for a sell order's protocol + // fee, per `getQuoteAmountsAndCosts`), not the sell token being spent. + await expect(protocolFeeCell).toContainText('-') + const protocolFeeTitle = await protocolFeeCell.locator('[title]').getAttribute('title') + expect(protocolFeeTitle).toMatch(/ USDC$/) + + const readRowAmount = (label: string): Promise => + readTitledAmount(tooltipBox.getByText(label, { exact: true }).locator('xpath=following-sibling::*[1]')) + + // See [CS-128]'s comment on the identical read: four separately-awaited reads risk a + // re-render (the form's own default-amount probe quote settling into the typed one) landing + // in between two of them, tearing the snapshot and skewing the ratio. Re-reading all four + // together on every poll attempt rides out that race. + let beforeCosts = 0n + let protocolFee = 0n + let networkCosts = 0n + let toAmount = 0n + + await expect + .poll(async () => { + beforeCosts = await readRowAmount('Before costs') + protocolFee = await readRowAmount('Protocol fee') + networkCosts = await readRowAmount('Network costs') + toAmount = await readRowAmount('To') + return Number(protocolFee) / Number(beforeCosts) + }) + .toBeCloseTo(0.0002, 6) + + expect(protocolFee).toBeGreaterThan(0n) + expect(networkCosts).toBe(0n) + + // The core relationship: To = Before costs − Network costs − Protocol fee. + expect(toAmount).toBe(beforeCosts - networkCosts - protocolFee) + }) + + test('[CS-128] Swap form: protocol fee applied at 0.003% (0.3 bps) for correlated assets (stables/RWAs) @smoke', async ({ + setupTestConditions, + swapPage, + wallet, + mocks, + }) => { + const STANDARD_TIER_RATIO = 0.0002 // The non-correlated 2 bps tier from [CS-127], for the ~6.67× comparison below. + + // Same mechanism as [CS-127] (`protocolFeeBps` is a top-level quote field, applied identically + // regardless of which tokens are picked — correlation-based tier selection is a backend/solver + // decision this frontend just renders), just a different bps value and, for the USDC→USDT leg + // below, a buy side with real 6 decimals instead of 18. + function mockCorrelatedQuote(sellDecimals: number, buyDecimals: number): void { + mocks.cowApi.set('quote', (req) => { + const defaults = req.defaults as { quote: Record } + const sellAmount = BigInt(defaults.quote.sellAmount as string) + const decimalsDelta = sellDecimals - buyDecimals + const buyAmount = + decimalsDelta === 0 + ? sellAmount + : decimalsDelta > 0 + ? sellAmount / 10n ** BigInt(decimalsDelta) + : sellAmount * 10n ** BigInt(-decimalsDelta) + return { + ...defaults, + protocolFeeBps: '0.3', + quote: { ...defaults.quote, feeAmount: '0', buyAmount: buyAmount.toString() }, + } + }) + } + + async function checkProtocolFeeTier(opts: { + sellSymbol: string + buySymbol: string + sellAddress: string + buyAddress: string + sellDecimals: number + buyDecimals: number + }): Promise { + const { sellSymbol, buySymbol, sellAddress, buyAddress, sellDecimals, buyDecimals } = opts + + mockCorrelatedQuote(sellDecimals, buyDecimals) + + // `setupTestConditions`'s own `balances`/`allowances` option resolves decimals via + // `support/tokens.ts`, which already reports 18 decimals correctly for this deployment's + // USDC (see known quirks) — seeded directly by address/decimals here instead, same pattern + // as [CS-59]'s `seedTrader` use. + seedTrader(mocks, wallet, CHAIN_ID, { + balances: { [sellAddress]: parseUnits('1000', sellDecimals), [buyAddress]: 0n }, + allowances: { [sellAddress]: parseUnits('1000', sellDecimals) }, + }) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: sellSymbol, + buyToken: buySymbol, + sellAmount: '1000', + }) + + await swapPage.receiveAmountTooltipTrigger.hover() + + const tooltipBox = swapPage.page.getByText('Before costs', { exact: true }).locator('xpath=../..') + await expect(tooltipBox).toBeVisible() + + const protocolFeeCell = tooltipBox + .getByText('Protocol fee', { exact: true }) + .locator('xpath=following-sibling::*[1]') + + // The surplus/buy token, with a leading "-" — same rendering as [CS-127]. + await expect(protocolFeeCell).toContainText('-') + const protocolFeeTitle = await protocolFeeCell.locator('[title]').getAttribute('title') + expect(protocolFeeTitle).toMatch(new RegExp(` ${buySymbol}$`)) + + const readRowAmount = (label: string): Promise => + readTitledAmount( + tooltipBox.getByText(label, { exact: true }).locator('xpath=following-sibling::*[1]'), + buyDecimals, + ) + + // The tooltip briefly shows a stale quote (the form's own default-amount probe, fetched + // before the typed "1000" settles) — `waitForQuote()` only waits for the loading flag to + // clear once, not for these four rows to all reflect the *same* render. Reading them as + // four separately-awaited calls risks a re-render landing in between two of them, tearing + // the snapshot (e.g. `beforeCosts` from the stale quote, `protocolFee` from the fresh one) + // and skewing the ratio below by orders of magnitude. Re-reading all four together on every + // poll attempt, instead of trusting a single one-shot batch, rides out that race the same + // way the recipient-checkbox retry in `[CC-17]` rides out its own settling-debounce race. + let beforeCosts = 0n + let protocolFee = 0n + let networkCosts = 0n + let toAmount = 0n + + await expect + .poll(async () => { + beforeCosts = await readRowAmount('Before costs') + protocolFee = await readRowAmount('Protocol fee') + networkCosts = await readRowAmount('Network costs') + toAmount = await readRowAmount('To') + return Number(protocolFee) / Number(beforeCosts) + }) + .toBeCloseTo(0.00003, 6) + + expect(protocolFee).toBeGreaterThan(0n) + expect(networkCosts).toBe(0n) + + // Protocol fee ≈ Before costs × 0.00003 (0.3 bps) — ~6.67× smaller than [CS-127]'s 2 bps tier + // on equivalent volume. + const ratio = Number(protocolFee) / Number(beforeCosts) + expect(STANDARD_TIER_RATIO / ratio).toBeCloseTo(6.667, 1) + + // The core relationship: To = Before costs − Network costs − Protocol fee. + expect(toAmount).toBe(beforeCosts - networkCosts - protocolFee) + } + + await checkProtocolFeeTier({ + sellSymbol: 'USDC', + buySymbol: 'USDT', + sellAddress: USDC, + buyAddress: USDT, + sellDecimals: 18, + buyDecimals: 6, + }) + + await checkProtocolFeeTier({ + sellSymbol: 'DAI', + buySymbol: 'USDC', + sellAddress: DAI, + buyAddress: USDC, + sellDecimals: 18, + buyDecimals: 18, + }) + }) + + test('Shows "Price impact unknown" warning when USD prices are unavailable', async ({ + setupTestConditions, + swapPage, + mocks, + }) => { + // Break all three USD price sources `UsdPricesUpdater` tries (BFF, Defillama, and the CoW + // Protocol native price fallback) for both legs of the trade, so neither can resolve a fiat + // value and the price impact is left unknown rather than computed. + mocks.usdPrices.setUnknown(WETH) + mocks.usdPrices.setUnknown(USDC) + mocks.cowApi.set('nativePrice', () => reply(404, { errorType: 'NotFound', description: 'token not found' })) + + await setupTestConditions({ + chainId: CHAIN_ID, + tradeType: 'swap', + sellToken: 'WETH', + buyToken: 'USDC', + sellAmount: '0.5', + balances: { WETH: '1', USDC: '0' }, + allowances: { WETH: '10' }, + }) + + await expect(swapPage.page.getByText('Price impact unknown - trade carefully')).toBeVisible() + }) }) - test('[MO-05] Shows "Price impact unknown" warning when USD prices are unavailable', async ({ - setupTestConditions, - swapPage, - mocks, - }) => { - // Break all three USD price sources `UsdPricesUpdater` tries (BFF, Defillama, and the CoW - // Protocol native price fallback) for both legs of the trade, so neither can resolve a fiat - // value and the price impact is left unknown rather than computed. - mocks.usdPrices.setUnknown(WETH) - mocks.usdPrices.setUnknown(USDC) - mocks.cowApi.set('nativePrice', () => reply(404, { errorType: 'NotFound', description: 'token not found' })) - - await setupTestConditions({ - chainId: CHAIN_ID, - tradeType: 'swap', - sellToken: 'WETH', - buyToken: 'USDC', - sellAmount: '0.5', - balances: { WETH: '1', USDC: '0' }, - allowances: { WETH: '10' }, - }) - - await expect(swapPage.priceImpactWarning).toBeVisible() + test.describe('Disconnected wallet', () => { + // The shared `wallet` fixture is `auto: true` (always instantiated so the injected provider + // exists before the page loads), but with auto-connect seeding off it never reconnects the + // app on boot — `wallet.connectViaModal()` is there for specs that need to connect later, + // simply never calling it is what keeps this test's app state disconnected throughout. + test.use({ mockWalletAutoConnect: false }) + + test('[CS-102] Not connected state: Connect Wallet button shown @smoke', async ({ swapPage }) => { + await swapPage.goto({ chainId: CHAIN_ID }) + + // This validation state's button (`TradeFormBlankButton`) doesn't carry the `#do-trade-button` + // id the other validation states render under, and the header has its own, differently-cased + // "Connect wallet" button — `swapPage.connectWalletButton` matches `exact` to land on the swap + // form's "Connect Wallet" specifically (confirmed via a DOM dump: two buttons, only this one + // capitalizes "Wallet"). + await expect(swapPage.connectWalletButton).toBeVisible() + }) }) }) diff --git a/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts b/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts index 13df2fd2e61..29316f23588 100644 --- a/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts +++ b/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts @@ -2,7 +2,7 @@ import { useAtom } from 'jotai' import { useEffect, useMemo } from 'react' import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' -import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' +import { getUpdaterInterval, SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' import { Token } from '@cowprotocol/currency' import { useWalletInfo } from '@cowprotocol/wallet' @@ -20,7 +20,7 @@ const OPTIMISTIC_ALLOWANCE_TTL = ms`30s` const SWR_OPTIONS: SWRConfiguration = { ...SWR_NO_REFRESH_OPTIONS, revalidateIfStale: false, - refreshInterval: ms`10s`, + refreshInterval: getUpdaterInterval(ms`10s`), } export function useTokenAllowance( diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx index beaa1ef6c60..526cbf7f998 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -286,7 +286,7 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps): ReactNode {
{amount && !isUsdValuesMode && ( - + )} diff --git a/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx b/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx index 9728855a51e..f83bb38864a 100644 --- a/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx @@ -31,7 +31,7 @@ export function PriceImpactIndicator({ priceImpactParams, isBridging = false }: const { priceImpact, loading: priceImpactLoading } = priceImpactParams || {} return ( - + {priceImpact && !priceImpactLoading ? ( {' '} diff --git a/apps/cowswap-frontend/src/legacy/state/orders/consts.ts b/apps/cowswap-frontend/src/legacy/state/orders/consts.ts index d8bbd4971aa..aea0a03e37b 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/consts.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/consts.ts @@ -1,3 +1,4 @@ +import { getUpdaterInterval } from '@cowprotocol/common-const' import { SupportedChainId as ChainId } from '@cowprotocol/cow-sdk' import { Percent } from '@cowprotocol/currency' @@ -8,11 +9,11 @@ export const ContractDeploymentBlocks: Partial> = { [ChainId.GNOSIS_CHAIN]: 13566914, } -export const MARKET_OPERATOR_API_POLL_INTERVAL = ms`2s` +export const MARKET_OPERATOR_API_POLL_INTERVAL = getUpdaterInterval(ms`2s`) // We can have lots of limit orders and it creates a high load, so we poll them not so often as market orders -export const LIMIT_OPERATOR_API_POLL_INTERVAL = ms`15s` -export const PENDING_ORDERS_PRICE_CHECK_POLL_INTERVAL = ms`30s` -export const EXPIRED_ORDERS_CHECK_POLL_INTERVAL = ms`15s` +export const LIMIT_OPERATOR_API_POLL_INTERVAL = getUpdaterInterval(ms`15s`) +export const PENDING_ORDERS_PRICE_CHECK_POLL_INTERVAL = getUpdaterInterval(ms`30s`) +export const EXPIRED_ORDERS_CHECK_POLL_INTERVAL = getUpdaterInterval(ms`15s`) export const OUT_OF_MARKET_PRICE_DELTA_PERCENTAGE = new Percent(1, 100) // 1/100 => 0.01 => 1% diff --git a/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx b/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx index 61bba162904..13c86e6a9fd 100644 --- a/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx +++ b/apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx @@ -172,7 +172,7 @@ export function AccountDetails({ {activityTotalCount ? ( - + {' '}
@@ -201,7 +201,7 @@ export function AccountDetails({
) : ( - + Your activity will appear here... diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/pure/Toggle/Toggle.tsx b/apps/cowswap-frontend/src/modules/erc20Approve/pure/Toggle/Toggle.tsx index 8ec4ac8d8a5..0f44c8ebabf 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/pure/Toggle/Toggle.tsx +++ b/apps/cowswap-frontend/src/modules/erc20Approve/pure/Toggle/Toggle.tsx @@ -28,7 +28,7 @@ export function Toggle({ } return ( - +