diff --git a/.github/workflows/e2e-pw-nightly.yml b/.github/workflows/e2e-pw-nightly.yml index acb56ccb822..28bb4fe3a41 100644 --- a/.github/workflows/e2e-pw-nightly.yml +++ b/.github/workflows/e2e-pw-nightly.yml @@ -18,6 +18,7 @@ jobs: env: INTEGRATION_TEST_PRIVATE_KEY: ${{ secrets.INTEGRATION_TEST_PRIVATE_KEY }} REACT_APP_NETWORK_URL_11155111: ${{ secrets.REACT_APP_NETWORK_URL_11155111 }} + REACT_APP_NETWORK_URL_1: ${{ secrets.REACT_APP_NETWORK_URL_1 }} E2E_PW_MM_SEED: ${{ secrets.E2E_PW_MM_SEED }} CI: 'true' steps: diff --git a/.github/workflows/e2e-pw-smoke.yml b/.github/workflows/e2e-pw-smoke.yml index c581f404c3b..91d695faa28 100644 --- a/.github/workflows/e2e-pw-smoke.yml +++ b/.github/workflows/e2e-pw-smoke.yml @@ -22,6 +22,7 @@ jobs: env: INTEGRATION_TEST_PRIVATE_KEY: ${{ secrets.INTEGRATION_TEST_PRIVATE_KEY }} REACT_APP_NETWORK_URL_11155111: ${{ secrets.REACT_APP_NETWORK_URL_11155111 }} + REACT_APP_NETWORK_URL_1: ${{ secrets.REACT_APP_NETWORK_URL_1 }} E2E_PW_MM_SEED: ${{ secrets.E2E_PW_MM_SEED }} CI: 'true' steps: diff --git a/.gitignore b/.gitignore index 74e93b014d2..784ff3b4dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ CLAUDE.md # Serena project files .serena/ + +# Subagent-driven-development scratch workspace (ledgers, briefs, review packages) +.superpowers/ diff --git a/apps/cowswap-e2e-tests/.env.example b/apps/cowswap-e2e-tests/.env.example index 4d1dec43973..8b57deca001 100644 --- a/apps/cowswap-e2e-tests/.env.example +++ b/apps/cowswap-e2e-tests/.env.example @@ -1,2 +1,3 @@ REACT_APP_NETWORK_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com +REACT_APP_NETWORK_URL_1=https://ethereum-rpc.publicnode.com INTEGRATION_TEST_PRIVATE_KEY=0x000000 diff --git a/apps/cowswap-e2e-tests/AGENTS.md b/apps/cowswap-e2e-tests/AGENTS.md index 388f8023e93..0419c8e0b4e 100644 --- a/apps/cowswap-e2e-tests/AGENTS.md +++ b/apps/cowswap-e2e-tests/AGENTS.md @@ -1,7 +1,7 @@ --- author: agents status: normative -last_reviewed: 2026-08-04 +last_reviewed: 2026-08-13 source_of_truth_scope: cowswap-e2e-tests app-specific conventions, mocks, and debugging notes --- @@ -125,6 +125,212 @@ behavior, only to call its methods). Sub-mocks: `REACT_APP_NETWORK_URL_11155111` in its own environment (it's a separate process from the test runner, which has it), or a transient DNS blip in the sandbox. +## Diagnosing flaky tests + +A test that fails only under the full suite's parallel load, not alone or with `-g`, is almost +never a logic bug in the test — check infrastructure contention first. + +- **Reproduce the actual failure before touching anything.** A single flaky test run proves + nothing either way; run the full suite (or the same worker count) a couple of times with + `LOG_UNMOCKED_RPC=1 npx playwright test` and look at `test-results/unmocked-rpc-requests.log` for + real `429`s before assuming a code regression. One session's evidence, captured this way: + ``` + [CC-03] ... status: 429 ... url: https://mainnet.infura.io/v3/... + [CC-01] ... status: 429 ... url: https://mainnet.infura.io/v3/... + [CC-26] ... status: 429 ... url: https://mainnet.infura.io/v3/... + ``` +- **Root cause 1: a single shared real Infura key gets rate-limited under N-way parallel workers.** + `mocks.allowances` and `installMulticall3` deliberately fall back to a real `route.fetch()` + whenever a Multicall3 batch isn't *fully* recognized (see each one's own doc comment) — reliable + for one test at a time, but every worker's fallback hits the exact same hardcoded Infura key, and + enough concurrent workers trip its rate limit. `logUnmockedRpcRequests.ts` exists specifically to + make this observable; it's disabled by default because logging every request has its own cost. + (`mockSocketVerifier` used to be in this list too — it no longer makes any real-RPC fallback at + all, see the "connected wallet's own provider" note below; a *different* root cause than this one.) +- **Closing a real-RPC-fallback gap directly beats retrying around it.** `mocks/unmocked-rpc-requests.log` + entries are a to-do list, not just a diagnosis — each distinct `(method, selector, to)` still + hitting a real host is a mock this suite is missing, and adding it removes a 429 source instead + of just tolerating it. Example this session: an `approve(address,uint256)` preflight `eth_call` + (selector `0x095ea7b3`) was firing — and 429-ing — even on cross-chain tests that pre-seed + sufficient allowance and never click Approve, because the wallet-connector layer simulates it + unconditionally regardless of whether the UI will ever show that step. + `mockApproveTransaction.ts` already answered this exact selector, but only for its own specific + `token` and only for tests that call it — `mockApproveSimulation.ts` now answers it + host-agnostically for *any* token/spender, registered globally in the `mocks` fixture. Safe to + match on selector alone with no token/spender scoping: an ERC20 `approve()` succeeding is a fair + default assumption, no test in this suite asserts on one reverting, and Playwright's LIFO route + order means a more specific handler registered later (e.g. `mockApproveTransaction`'s own, set up + inside a test body) still wins for the token it cares about — this one only catches what nothing + more specific claimed. +- **A multi-row UI read can tear across a re-render — read the whole snapshot atomically, not row + by row.** `[CS-127]`/`[CS-128]` each read four tooltip rows (`Before costs`/`Protocol fee`/ + `Network costs`/`To`) as four separately-awaited `readRowAmount()` calls, then computed a ratio + from them. The swap form fires its own default-amount probe quote before the typed amount's real + quote lands (same root cause as the "full wallet balance" case already noted above, just a + different default-amount source) — `waitForQuote()` only waits for the loading flag to clear + *once*, so if the real quote's render lands in between two of the four reads, the result is a mix + of old and new state (e.g. `beforeCosts` from the stale 1-unit probe, `protocolFee` from the + fresh 1000-unit quote), producing a self-consistent-*looking* but wrong ratio — confirmed by + instrumenting the mock callback with `console.log` (prints to the Node process, not the browser) + and correlating its output against the same test's row-read output via a per-run random tag, + since parallel workers' console output interleaves. Fixed by moving all four reads inside a + single `expect.poll(async () => { ...four reads...; return ratio })` callback, so every retry + re-reads the full snapshot together instead of trusting a stale mix — the same idiom `[CC-17]`'s + checkbox retry already uses, just applied to a read instead of a click. +- **Root cause 2: the default 5s `expect` timeout is tight under CPU contention.** Several + known-load-sensitive assertions (the recipient-confirmation checkbox retry in `[CC-17]`, the + order-progress-modal reopen in `[CS-60]`) have their own comments acknowledging they only flake + under concurrent test load, not in isolation — heavy parallel Chromium + one shared dev server + competing for CPU cores makes debounces/polling cycles that normally settle in well under a + second take long enough to blow past a tight default. +- **Suite-wide mitigation applied in `playwright.config.ts`:** `expect: { timeout: 10_000 }` (was + the unconfigured 5s default) and `retries: 1` unconditionally (was `CI ? 1 : 0`) — a load-induced + flake should self-heal on retry rather than fail the run, locally too, not just in CI. These are + mitigations for contention, not a fix for the underlying rate limit — a real `429` under + sufficiently heavy load can still exhaust a retry. Per-assertion overrides above this floor (like + `[CS-60]`'s existing 15s wait) are still correct and still needed for the worst offenders; don't + remove them just because the global floor moved up. +- **Confirm a suspected regression by testing the *unmodified* code under the same load**, not just + by re-running your changed version and seeing it pass once. `git stash` the diff, rerun the exact + same failing test/suite, and only call something a regression if the clean baseline doesn't + reproduce it too. This is how CC-13's "insufficient balance"/"Error loading price" failures and + CS-128's flake were both confirmed pre-existing and unrelated to a same-session diff, twice. + +## Cross-chain bridging (`cross-chain-swaps.spec.ts`) + +- **LaunchDarkly can't be mocked via HTTP here.** With no `REACT_APP_LAUNCH_DARKLY_KEY` configured, + the real LD SDK never even attempts flag-evaluation polling (only a `/sdk/goals/` call fires, never + `/sdk/evalx/...`), so route-mocking its API is a dead end. Instead `useFeatureFlags()` + (`libs/common-hooks/src/useFeatureFlags.ts`) reads `window.__COWSWAP_E2E_FEATURE_FLAGS__` directly and + merges it over the real (permanently unresolved) flags; `mocks/launchDarkly.ts` sets that window + property via `context.addInitScript`, and `mocks.launchDarkly.setFlag(key, value)` is how a spec turns + on `isBungeeBridgeProviderEnabled` / `isNearIntentsBridgeProviderEnabled` / `isSolBridgeEnabled` / + `isBtcBridgeEnabled`, etc. Bungee alone doesn't need this — it's added to the provider set + unconditionally at module load in `tradingSdk/bridgingSdk.ts`. +- **Near Intents' attestation is a real ECDSA signature check and cannot be forged.** + `recoverDepositAddress` verifies the quote/attestation pair against Near's real attestor key — a + captured fixture pair only satisfies it if replayed byte-for-byte for the exact route it was captured + for. `bridgingSdk.ts` patches `nearIntentsBridgeProvider.recoverDepositAddress` to a no-op success, + gated behind the existing `window.__COWSWAP_E2E__` flag — a production-source-file edit, but scoped to + e2e only. Consequently the Near fixture (`mocks/bridge/fixtures/near-quote.json` / + `near-attestation.json`) can only be served verbatim for the one route it was recorded against + (Mainnet USDC → Base USDC) — don't edit its numbers. +- **`BridgingSdk.getBestQuote()` always fetches a *regular* CoW quote first** (swap leg: sell token → + intermediate token) and feeds that quote's `buyAmount` in as the amount the bridge provider itself + quotes. The default `/quote` fixture's scaling is tuned for a same-decimals WETH:testUSDC pair and + produces nonsense for any other pair — always pin the swap leg with `mockFixedRateQuote` for a + cross-chain test. **When sell and intermediate-buy token decimals differ (e.g. native ETH's 18dec sell + → a 6dec USDC intermediate), `mockFixedRateQuote`'s plain `sellAmount * numerator / denominator` is + decimals-*agnostic* and silently produces an amount ~12 orders of magnitude too large** (surfaces as + an absurd `"for at least 99.339B USDC"` in the confirm modal). Override `quote` a second time after + `mockFixedRateQuote` with a manually decimals-adjusted ratio in that case (see `[CC-13]`). +- **The app's own real-RPC traffic for a given chain does *not* reliably go through + `REACT_APP_NETWORK_URL_`.** That env var only backs this suite's own wallet-side + dispatch/proxy (`walletEngine.ts` → `rpcProxy.ts`) and the handful of reads `mockEthFlowTransaction` + intercepts by that exact URL (tx receipts, native-balance multicalls). Plenty of other calls the + *app itself* makes — `eth_estimateGas` before every `eth_sendTransaction` — go straight to + whichever of the app's own hardcoded providers it picks (Infura, the WalletConnect RPC relay, + publicnode, ...), unpredictable and outside this env var's control. The only reliable way to + intercept *those* is host-agnostic: `context.route('**/*', ...)`, decode the JSON-RPC body, and + match by `method` (see `mockEthEstimateGas` in `mockEthFlowTransaction.ts`), never by URL. Bungee's + on-chain SocketVerifier check is a *different* case entirely — see the next note. +- **Bungee's on-chain SocketVerifier check (`validateRotueId`/`validateSocketRequest`, + `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`) is mocked entirely by + `mocks/socketVerifier.ts` — a standalone, host-agnostic `context.route('**/*', ...)` mock, same + shape as `ethBlockNumber.ts`/`ethGetCode.ts`.** It decodes both a direct `eth_call` to the + SocketVerifier contract and one batched inside a Multicall3 `aggregate3` (mirroring + `installMulticall3`'s own batch decoding), resolving either to a safe empty success without + touching the network, and otherwise falling back untouched. It's registered *ahead of* both + `installMulticall3` and `installAllowances` in the `mocks` fixture (Playwright's route order is + LIFO — last registered gets first look), so it catches the check regardless of which real RPC + host the app's independent read-only client would otherwise have picked — e.g. + `https://ethereum-rpc.publicnode.com` for Mainnet, the same host `REACT_APP_NETWORK_URL_1` + configures and `mocks/allowances` owns. Deliberately *not* folded into + `mocks/allowances/codec.ts`: that codec is allowance-shaped (`ClassifiedCall` = allowance | + batch | opaque) and shared with `installMulticall3`'s own resolver, so adding a third mock's + selector there would have coupled two unrelated concerns for no benefit — a standalone mock + keeps this one deletable/testable on its own, same as every other single-purpose mock in + `mocks/`. + - **History, worth keeping in mind if this check ever silently stops being mocked again:** it was + first mocked with a `context.route('**/*', ...)` handler modeled on `mockEthEstimateGas`, and + that silently never matched anything under load, intermittently manifesting as `[CS-287]`/ + `[CS-297]`/etc. failing with "Error loading price" or a hung `BridgeRoutePanel.expand()`. Root + cause, found by having the app log the real `readContract` error instead of swallowing it: the + SDK adapter's `readContract` for this check ran against the *connected wallet's own provider* + (this suite's mock wallet resolves the chain from the currently-connected chain, which for + these tests happens to be the bridge's origin chain — Mainnet), not the app's separate HTTP + viem client — and `eth_call`s made through the wallet provider go `injectedShim.ts` → + `walletEngine.ts`'s `dispatch()` → `forward()`, a plain **Node-side** `fetch()` straight to + this suite's own RPC proxy (`support/rpcProxy.ts`), never touching the page's network layer at + all. That was fixed with a dedicated `support/mockSocketVerifier.ts`, stubbing the RPC proxy's + own `(to, selector)` primitive (`rpcProxy.stubCall(...)`) directly instead of routing pages. + Once `mocks/socketVerifier.ts` above existed and the suite kept passing without it, + `support/mockSocketVerifier.ts` and its `rpcProxy.stubCall` usage were deleted as redundant — + so today there is exactly one SocketVerifier mock, not two. **Lesson: if a mock built on + `context.route()` seems to work "sometimes" for a wallet-adjacent on-chain read, check whether + the call is actually reaching the wallet's own provider instead of the page's network layer + before adding more retry/timeout budget around it** — no amount of extra timeout fixes a mock + that's listening on the wrong layer. (Whether that still applies to *this* check specifically, + or the wallet-forwarded path simply doesn't fire for it anymore, wasn't re-diagnosed before + deleting the old mock — if this check ever starts flaking again the way `[CS-287]` did, that + wallet-provider path is the first thing to re-check before assuming `mocks/socketVerifier.ts` + itself regressed.) +- **A real native-ETH sell (`[CC-13]`, eth-flow) needs `eth_estimateGas` stubbed too, not just + `eth_sendTransaction`.** Left unmocked, gas estimation is a real simulation against the wallet's real + on-chain balance — zero on Mainnet, since this is a shared test key with no real funds (never fund it; + Sepolia's equivalent test works only because that address genuinely holds real, free Sepolia ETH) — and + fails with a genuine "exceeds the balance of the account" error before the stubbed send is ever + reached. +- **`mockOrderPosting` doesn't work for eth-flow orders** — there's no `postOrder` call to hook (the uid + is computed client-side before anything is sent on-chain). Override `order`/`orderStatus` manually + instead (mirrors `[MO-11]`). One extra step specific to *bridging* eth-flow orders: + `useSwapAndBridgeContext` resolves the bridge provider from `order.apiAdditionalInfo.fullAppData` + (`bridgingSdk.getProviderFromAppData`) — without it, `bridgingStatus` never resolves and the progress + modal sticks on "Executing" forever regardless of what `order`/`orderStatus` say. Since an eth-flow tx + only carries the app-data *hash* on-chain (no room for the full JSON in a `bytes32`), capture the real + document via a `putAppData` override (`(req.body as { fullAppData: string }).fullAppData`) and thread + it into the `order` override's own `fullAppData` field. +- **"Expected to receive" and "Min. to receive" are computed completely differently for a bridge leg, + and only one of them gets rescaled to match the swap leg.** `useEstimatedBridgeBuyAmount` rescales the + swap leg's real output through the bridge quote's own before-fee ratio, so form `Receive (incl. fees)`, + the *bridge* stop's `Expected to receive`, and (for Bungee, whose mock scales proportionally) roughly + the swap stop's own figure all end up self-consistent. `Min. to receive` at the bridge stop is **not** + rescaled — it's the bridge SDK quote's raw `amountsAndCosts.afterSlippage.buyAmount`, carrying that + provider's own real routeFee/slippage. Don't assert equality between a swap leg's and a bridge leg's + `Min. to receive` — assert presence instead. For Near Intents specifically, both the quote's `sellAmount` + and `buyAmount` come from the same static signed fixture, so its bridge-stop `Min. to receive` is an + absolute number from that fixture, unrelated to whatever amount the test actually trades. +- **Solana availability needs two independent flags, Bitcoin needs only one.** `isSolBridgeEnabled` / + `isBtcBridgeEnabled` (the LD-bypass flags above) gate chain *availability* in + `useSupportedTargetChains`, but Solana additionally needs `IS_SOLANA_ENABLED` — a plain + `localStorage.getItem('IS_SOLANA_ENABLED')` check (`libs/common-const/src/featureFlags.ts`), a + completely different mechanism — for `CHAIN_INFO` to have a Solana entry to look up at all. Set it via + `context.addInitScript(() => localStorage.setItem('IS_SOLANA_ENABLED', '1'))` before navigating. +- **Near Intents' real dest-tokens fixture has no usable exact-"BTC" entry.** Its one `blockchain: "btc"` + token with `symbol: "BTC"` (`nep141:btc.omft.near`) is on the SDK's own hardcoded deprecated-asset-id + list and gets filtered out client-side; the only Bitcoin-chain token that survives is + `symbol: "BTC(OMNI)"`. Search/pick `BTC(OMNI)`, not `BTC`. +- **Validation-blocking button states can render with no `id` at all.** `TradeFormButtons` only gives + `#do-trade-button` to the "no validation errors" case; a function-component validation state (e.g. + `RecipientNotSet`, `RecipientNotConfirmed` in `tradeButtonsMap.tsx`) renders its own + `TradeFormBlankButton` with no `id` prop. Match those by role/text + (`page.getByRole('button', { name: /.../i })`), not by a `#do-trade-button`/`swapButton` locator. +- **A controlled confirmation checkbox can lose a click under load.** `recipientConfirmationCheckbox` + (`#receiver-confirmation`) is driven by recipient-validation state that can still be settling right + after typing an address; a still-in-flight debounce can reset `confirmed` back to `false` immediately + after Playwright's `.check()` lands, surfacing as "Clicking the checkbox did not change its state" — + reproduces reliably only under concurrent test load (multiple workers), not in isolation. Retry via + `expect.poll(async () => { await checkbox.check(); return checkbox.isChecked() }).toBe(true)` instead + of a single `.check()`. +- The app's HashRouter makes `page.goto()` to a new `#/...` route a same-document navigation — + `bridgingSdk`'s available-provider set is a page-lifetime singleton seeded once at module load, so a + test that switches providers mid-test (`mocks.launchDarkly.setFlag` again) needs an actual + `page.reload()` after the new hash is already in the address bar for the switch to take effect. +- The Bungee quote fixture's `output.amount` is a single captured absolute number, unrelated to whatever + amount a given test's sell leg actually produces — `mocks/bungee.ts`'s `/quote` handler scales every + amount field (and their USD counterparts) proportionally to the live `inputAmount` query param to keep + the fixture's own input:output ratio (and therefore price impact) realistic for any sell amount. + ## Known issues (discovered this session, unresolved) - **`mocks.balances.set()` called after the app already has an open SSE connection (e.g. from inside a diff --git a/apps/cowswap-e2e-tests/README.md b/apps/cowswap-e2e-tests/README.md index ff6d62b69e1..fc8379671c6 100644 --- a/apps/cowswap-e2e-tests/README.md +++ b/apps/cowswap-e2e-tests/README.md @@ -2,12 +2,15 @@ Playwright + Synpress e2e suite for [swap.cow.fi](https://swap.cow.fi). -- An **automated** Playwright test (test title starts with `[XX-NN]`). -- A **manual** placeholder (`test.skip()` + `annotation.type === 'manual'`) for - scenarios that require a real wallet, real Safe iframe, real bridge fill, or - human interaction. -- A **todo** placeholder (`test.fixme()` + `annotation.type === 'todo'`) for - scenarios planned for later milestones. +For an architecture tour (mocking mechanics, page objects, support utils) see +[`docs/OVERVIEW.md`](docs/OVERVIEW.md). For debugging notes and conventions discovered while +writing tests (including known flakiness causes and how they were diagnosed), see +[`AGENTS.md`](AGENTS.md). This file is command/setup reference. + +Every test is a plain, fully automated Playwright test, titled `[XX-NN] description` — the prefix +maps to its spec file (`CS`/`MO` → `market-orders.spec.ts`, `CC` → `cross-chain-swaps.spec.ts`, +`LO` → `limit-orders.spec.ts`, `NW` → `network.spec.ts`). `@smoke`-tagged tests are the PR-gating +subset; everything runs on the nightly job. ## Prerequisites @@ -19,10 +22,12 @@ Playwright + Synpress e2e suite for [swap.cow.fi](https://swap.cow.fi). | Name | Required | Purpose | |---|---|---| -| `INTEGRATION_TEST_PRIVATE_KEY` | yes | Sepolia test account private key | +| `INTEGRATION_TEST_PRIVATE_KEY` | yes | Test account private key (shared by Sepolia and Mainnet specs) | | `REACT_APP_NETWORK_URL_11155111` | yes | Sepolia JSON-RPC URL | +| `REACT_APP_NETWORK_URL_1` | yes | Mainnet JSON-RPC URL — needed by `cross-chain-swaps.spec.ts`, which trades on Mainnet rather than Sepolia | | `E2E_PW_MM_SEED` | CI | Twelve-word seed used by the Synpress MetaMask cache | | `E2E_RPC_PROXY_PORT` | no | RPC proxy port (default `18545`) — must match between cache build and test runs | +| `LOG_UNMOCKED_RPC` | no | Set to `1` to log every real (unmocked) RPC request to `test-results/unmocked-rpc-requests.log` — see [`docs/OVERVIEW.md`](docs/OVERVIEW.md) | ## Building the MetaMask cache (required once, for Synpress specs only) @@ -79,8 +84,6 @@ test('my scenario', async ({ wallet, page }) => { - Keep Synpress (`../fixtures`) for scenarios that must exercise real extension UI (connect prompts, network-approval dialogs, popup handling). -Design: `docs/superpowers/specs/2026-07-26-mock-wallet-e2e-design.md`. - ## CoW Protocol API mocks Every request to `api.cow.fi` and `barn.api.cow.fi` is intercepted. Defaults come @@ -128,9 +131,32 @@ override `quote`. These still reach the network and are the next round of work: -- `bff.cow.fi` — `usdPrice`, `topHolders`, `simulateBundle`, affiliate endpoints +- `bff.cow.fi` — `topHolders`, `simulateBundle`, affiliate endpoints (`usdPrice` is now mocked, see below) - `partners.cow.fi` / `partners.barn.cow.fi` +## Other mocks + +CoW API and allowances (below) aren't the only concerns intercepted — every test gets the full +stack from the `mocks` fixture. Brief pointers; see +[`docs/OVERVIEW.md`](docs/OVERVIEW.md) +for the mechanics and gotchas behind each: + +| Concern | Handle | Notes | +|---|---|---| +| Token balances (SSE watcher stream) | `mocks.balances` | Give every test a default balance via `beforeEach`. | +| USD prices (BFF + Defillama + CoW native) | `mocks.usdPrices` | `setPrice(address, price)` / `setUnknown(address)`; defaults every token to $1. | +| Token lists | `mocks.tokenLists` | Empty by default; `setListForChain(chainId, list)`. | +| LaunchDarkly feature flags | `mocks.launchDarkly` | Can't be mocked over HTTP at all — routed through `window.__COWSWAP_E2E_FEATURE_FLAGS__` instead. Only relevant to cross-chain specs today. | +| Safe iframe context | `mocks.safeSdk` | Simulates the app running embedded in a Safe iframe. | +| Bungee / Near Intents bridge APIs | `mocks.bungee` / `mocks.nearIntents` | Cross-chain-swap specs only. | +| ERC-20 `approve()` preflight simulation, `eth_estimateGas`, `eth_getCode`, `eth_blockNumber`, `eth_getTransactionCount` | installed globally, no handle | Real, host-agnostic RPC calls the app fires regardless of what a test is checking — mocked unconditionally so nothing has to think about them. | + +`window.__COWSWAP_E2E__` is a separate, unrelated flag (a plain boolean, set by the `mocks` +fixture before every test) that a couple of production source files branch on directly — e.g. to +speed up polling intervals, and (combined with a build-time `NODE_ENV` guard) to bypass a +signature check the mocked Near Intents fixture can't satisfy. See `docs/OVERVIEW.md` before +touching either that flag or `__COWSWAP_E2E_FEATURE_FLAGS__`. + ## Token allowances Every ERC-20 `allowance()` read the app makes is intercepted on the app's RPC @@ -157,8 +183,13 @@ transport, batched into Multicall3. `JSON.parse` rounds it. - **Anything not listed reads as 0**, including an owner with no entry at all. So the default state of every test is "nothing is approved". -- **Spender is not part of the key.** Any spender gets the same value; the spender - is recorded in `reads()` if a spec needs to assert on it. +- **Only reads for the CoW VaultRelayer (prod or staging) are ever answered from + fixture/overrides.** Any other spender always reads as 0, regardless of what's configured for + the VaultRelayer — this is deliberate: it's the one spender every real trade in this suite + checks, and treating every other spender as unconfigured is what stopped a seeded allowance from + leaking into unrelated app behavior that also happens to read `allowance()` on the same token + (see `docs/OVERVIEW.md`'s allowance gotcha for the concrete incident). The queried spender is + still recorded in `reads()` regardless of whether it matched. - The committed file is `{}`. Use it for defaults tied to a fixed address. Because the wallet address comes from `INTEGRATION_TEST_PRIVATE_KEY`, a spec @@ -193,7 +224,7 @@ second install point in `src/mockWallet/walletEngine.ts` reusing `codec.ts`. | Command | Description | |---|---| | `pnpm e2e:build-cache` | Build the Synpress MetaMask profile cache (only needed by specs using the Synpress fixture; not run in CI today) | -| `pnpm e2e` | Full suite — all 362 tests | +| `pnpm e2e` | Full suite — every spec in `src/tests/` (31 tests across 4 files as of this writing; run `pnpm exec playwright test --list` for the current count) | | `pnpm e2e:smoke` | PR smoke subset — `--grep @smoke` | | `pnpm e2e:ui` | Playwright UI mode for interactive debugging | | `npx nx test cowswap-e2e-tests` | Unit tests for the mocks and support code (`node:test` via tsx) | @@ -206,9 +237,6 @@ pnpm exec playwright test src/tests/market-orders.spec.ts pnpm exec playwright test --grep '\[MO-01\]' ``` -If `scaffold.ts` adds new placeholders, commit those spec-file changes -alongside the xlsx update. - ## Troubleshooting - **Synpress MetaMask version drift.** Synpress is pinned to a specific @@ -219,5 +247,11 @@ alongside the xlsx update. forwards transactions and receipts to real Sepolia. If the upstream RPC flakes, the suite will surface as e2e flake. Switch `REACT_APP_NETWORK_URL_11155111` to a different provider. +- **Flaky test under the full parallel suite, but not alone.** Almost never a logic bug — check + infrastructure contention first: a real, rate-limited RPC endpoint 429ing under N-way parallel + workers, or a tight timeout under CPU contention. `AGENTS.md`'s "Diagnosing flaky tests" section + has the full diagnostic workflow (`LOG_UNMOCKED_RPC=1`, reproducing under load, confirming a + regression by testing the unmodified code under the same load) and the concrete root causes + found so far. - **Selector drift.** When the cowswap-frontend UI changes selectors, update the relevant page object in `src/pages/` rather than each test. diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index 3c00d16975a..baef17b2aa2 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -9,8 +9,9 @@ export default defineConfig({ timeout: 90_000, fullyParallel: true, forbidOnly: !!process.env.CI, + expect: { timeout: 10_000 }, retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 2 : 6, reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : [['list'], ['html', { open: 'never' }]], globalSetup: path.resolve(__dirname, 'src/support/globalSetup.ts'), globalTeardown: path.resolve(__dirname, 'src/support/globalTeardown.ts'), diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 33f7635a138..3bfc2c2d136 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -2,6 +2,7 @@ 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 { installEthBlockNumber } from '../mocks/ethBlockNumber' import { installEthEstimateGas } from '../mocks/ethEstimateGas' @@ -9,8 +10,11 @@ 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 { installNearIntents, type NearIntentsMock } from '../mocks/nearIntents' +import { installOrdersMock, type OrdersMock } from '../mocks/orders' import { installSafeSdk, type SafeSdkMock } from '../mocks/safeSdk' -import { installTokenLists, type TokenListsMock } from '../mocks/tokenLists' +import { installSocketVerifier } from '../mocks/socketVerifier' +import { installTokenNonce } from '../mocks/tokenNonce' import { installUsdPrices, type UsdPricesMock } from '../mocks/usdPrices' import { AccountModal } from '../pages/AccountModal' import { AccountPage } from '../pages/AccountPage' @@ -21,7 +25,6 @@ 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' @@ -36,15 +39,15 @@ export interface SharedFixtures { 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 + orders: OrdersMock ethGetCode: EthGetCodeMock - tokenLists: TokenListsMock safeSdk: SafeSdkMock + bungee: BungeeMock + nearIntents: NearIntentsMock launchDarkly: LaunchDarklyMock usdPrices: UsdPricesMock } @@ -85,9 +88,6 @@ 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() @@ -102,8 +102,8 @@ export const sharedFixtures: Fixtures< 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. + // installed one too, e.g. `mockApproveTransaction`, 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 }) } @@ -117,17 +117,24 @@ export const sharedFixtures: Fixtures< const allowances = installAllowances(context) const balances = installBalances(context) const cowApi = await installCowProtocolApi(context) + const orders = installOrdersMock(cowApi) const ethGetCode = installEthGetCode(context) installEthBlockNumber(context) installEthEstimateGas(context) installEthGetTransactionCount(context) + installTokenNonce(context) installMulticall3(context, { allowances }) + // Registered after `installMulticall3`/`installAllowances` so it always gets first look at + // a matching request (Playwright's route order is LIFO) — see its own doc comment for why + // neither of those two mocks can catch this on their own. + installSocketVerifier(context) // 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) @@ -135,15 +142,18 @@ export const sharedFixtures: Fixtures< allowances, balances, cowApi, + orders, ethGetCode, - tokenLists, safeSdk, + bungee, + nearIntents, launchDarkly, usdPrices, }) ethGetCode.reset() - tokenLists.reset() + bungee.reset() + nearIntents.reset() await launchDarkly.reset() usdPrices.reset() await safeSdk.disable() @@ -152,6 +162,7 @@ export const sharedFixtures: Fixtures< allowances.reset() balances.reportUnknownOwners() balances.reset() + orders.reset() // Runs last: it throws when the test hit an un-mocked CoW API URL, and the // resets above must still happen. try { diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-dest-tokens.json b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-dest-tokens.json new file mode 100644 index 00000000000..293a7640907 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-dest-tokens.json @@ -0,0 +1,79 @@ +{ + "success": true, + "statusCode": 200, + "result": [ + { + "chainId": 8453, + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602", + "icon": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602" + }, + { + "chainId": 8453, + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "name": "Ether", + "symbol": "ETH", + "decimals": 18, + "logoURI": "https://media.socket.tech/networks/ethereum.svg", + "icon": "https://media.socket.tech/networks/ethereum.svg" + }, + { + "chainId": 8453, + "address": "0x4e107a0000db66f0e9fd2039288bf811dd1f9c74", + "name": "Velora", + "symbol": "VLR", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/55593/large/PNG_Round.png?1746809175", + "icon": "https://assets.coingecko.com/coins/images/55593/large/PNG_Round.png?1746809175" + }, + { + "chainId": 8453, + "address": "0xd652c5425aea2afd5fb142e120fecf79e18fafc3", + "name": "PoolTogether", + "symbol": "POOL", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/14003/large/PoolTogether.png?1696513732", + "icon": "https://assets.coingecko.com/coins/images/14003/large/PoolTogether.png?1696513732" + }, + { + "chainId": 8453, + "address": "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", + "name": "L2 Standard Bridged USDT Base ", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/39963/large/usdt.png?1724952731", + "icon": "https://assets.coingecko.com/coins/images/39963/large/usdt.png?1724952731" + }, + { + "chainId": 8453, + "address": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "name": "Bridged USDC Base ", + "symbol": "USDBC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/31164/large/baseusdc.jpg?1696529993", + "icon": "https://assets.coingecko.com/coins/images/31164/large/baseusdc.jpg?1696529993" + }, + { + "chainId": 8453, + "address": "0x50c5725949a6f0c72e6c4a641f24049a917db0cb", + "name": "L2 Standard Bridged DAI Base ", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/39807/large/dai.png?1724126571", + "icon": "https://assets.coingecko.com/coins/images/39807/large/dai.png?1724126571" + }, + { + "chainId": 8453, + "address": "0x4158734d47fc9692176b5085e0f52ee0da5d47f1", + "name": "Balancer", + "symbol": "BAL", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/11683/large/Balancer.png?1696511572", + "icon": "https://assets.coingecko.com/coins/images/11683/large/Balancer.png?1696511572" + } + ], + "message": null +} diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-intermediate-tokens.json b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-intermediate-tokens.json new file mode 100644 index 00000000000..ac5fa70a9c4 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-intermediate-tokens.json @@ -0,0 +1,25 @@ +{ + "success": true, + "statusCode": 200, + "result": [ + { + "chainId": 1, + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602", + "icon": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602" + }, + { + "chainId": 1, + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "name": "Tether", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/325/large/Tether.png?1696501661", + "icon": "https://assets.coingecko.com/coins/images/325/large/Tether.png?1696501661" + } + ], + "message": null +} diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-quote.json b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-quote.json new file mode 100644 index 00000000000..dc68752f652 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-quote.json @@ -0,0 +1,232 @@ +{ + "success": true, + "statusCode": 200, + "result": { + "originChainId": 1, + "destinationChainId": 8453, + "userAddress": "0x862a6f33094065aefe76aa1bad4e4409705d5b2e", + "receiverAddress": "0xfb3c7eb936caa12b5a884d612393969a557d4307", + "input": { + "token": { + "chainId": 1, + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602", + "icon": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602" + }, + "amount": "4961514", + "priceInUsd": 1, + "valueInUsd": 4.961514 + }, + "autoRoute": null, + "manualRoutes": [ + { + "quoteId": "563bbbc427e459e4", + "quoteExpiry": 1786437192, + "output": { + "token": { + "chainId": 8453, + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602", + "icon": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602" + }, + "amount": "4958461", + "priceInUsd": 1, + "valueInUsd": 4.958461, + "effectiveAmount": "4958461", + "effectiveValueInUsd": 4.958461, + "minAmountOut": "4958461", + "effectiveReceivedInUsd": 4.931196188937761 + }, + "affiliateFee": null, + "approvalData": { + "spenderAddress": "0x3a23F943181408EAC424116Af7b7790c94Cb97a5", + "amount": "4961514", + "tokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "userAddress": "0x862a6f33094065aefe76aa1bad4e4409705d5b2e" + }, + "gasFee": { + "gasToken": { + "chainId": 1, + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "gasLimit": "138600", + "gasPrice": "102224800", + "estimatedFee": "14539836742200", + "feeInUsd": 0.027264811062238596 + }, + "slippage": 0.3, + "estimatedTime": 60, + "routeDetails": { + "name": "Across", + "logoURI": "https://media.socket.tech/bridges/across.png", + "routeFee": { + "token": { + "chainId": 1, + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "symbol": "USDC", + "name": "USDCoin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "amount": "3053", + "feeInUsd": 0.003051, + "priceInUsd": 0.9993449066491976 + }, + "dexDetails": null + }, + "refuel": null + }, + { + "quoteId": "5baf95527b4a685b", + "quoteExpiry": 1786437192, + "output": { + "token": { + "chainId": 8453, + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602", + "icon": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602" + }, + "amount": "4931514", + "priceInUsd": 1, + "valueInUsd": 4.931514, + "effectiveAmount": "4931514", + "effectiveValueInUsd": 4.931514, + "minAmountOut": "4931514", + "effectiveReceivedInUsd": 4.899252607401103 + }, + "affiliateFee": null, + "approvalData": { + "spenderAddress": "0x3a23F943181408EAC424116Af7b7790c94Cb97a5", + "amount": "4961514", + "tokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "userAddress": "0x862a6f33094065aefe76aa1bad4e4409705d5b2e" + }, + "gasFee": { + "gasToken": { + "chainId": 1, + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "gasLimit": "164000", + "gasPrice": "102224800", + "estimatedFee": "17204424428000", + "feeInUsd": 0.032261392598897036 + }, + "slippage": 0.3, + "estimatedTime": 1200, + "routeDetails": { + "name": "Circle CCTP V2", + "logoURI": "https://media.socket.tech/bridges/cctp.svg", + "routeFee": { + "token": { + "chainId": 1, + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "symbol": "USDC", + "name": "USDCoin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "amount": "30000", + "feeInUsd": 0.029988, + "priceInUsd": 0.9996 + }, + "dexDetails": null + }, + "refuel": null + }, + { + "quoteId": "d36e7c185bcaa9c8", + "quoteExpiry": 1786437192, + "output": { + "token": { + "chainId": 8453, + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602", + "icon": "https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602" + }, + "amount": "4931008", + "priceInUsd": 1, + "valueInUsd": 4.931008, + "effectiveAmount": "4931008", + "effectiveValueInUsd": 4.931008, + "minAmountOut": "4931008", + "effectiveReceivedInUsd": 4.898746607401104 + }, + "affiliateFee": null, + "approvalData": { + "spenderAddress": "0x3a23F943181408EAC424116Af7b7790c94Cb97a5", + "amount": "4961514", + "tokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "userAddress": "0x862a6f33094065aefe76aa1bad4e4409705d5b2e" + }, + "gasFee": { + "gasToken": { + "chainId": 1, + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "gasLimit": "164000", + "gasPrice": "102224800", + "estimatedFee": "17204424428000", + "feeInUsd": 0.032261392598897036 + }, + "slippage": 0.3, + "estimatedTime": 60, + "routeDetails": { + "name": "Circle CCTP V2 Fast", + "logoURI": "https://media.socket.tech/bridges/cctp.svg", + "routeFee": { + "token": { + "chainId": 1, + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "symbol": "USDC", + "name": "USDCoin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "amount": "30506", + "feeInUsd": 0.030494, + "priceInUsd": 0.999606634760375 + }, + "dexDetails": null + }, + "refuel": null + } + ] + }, + "message": null +} diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-attestation.json b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-attestation.json new file mode 100644 index 00000000000..c4df4656596 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-attestation.json @@ -0,0 +1,4 @@ +{ + "signature": "0x724ea00c80e6eec08e1ff179dac1fa590a907e593fde1c17e6c5261e0a33c6fa44be22bef24abbc5325790299b489cc0bc0bb05216eba5e801a5386529c83ff31b", + "version": 0 +} diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-dest-tokens.json b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-dest-tokens.json new file mode 100644 index 00000000000..e13b3b6426d --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-dest-tokens.json @@ -0,0 +1,1820 @@ +[ + { + "assetId": "nep141:wrap.near", + "decimals": 24, + "blockchain": "near", + "symbol": "wNEAR", + "price": 1.59, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "wrap.near", + "coingeckoId": "wrapped-near" + }, + { + "assetId": "nep141:eth.bridge.near", + "decimals": 18, + "blockchain": "near", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "eth.bridge.near", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + "decimals": 6, + "blockchain": "near", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:853d955acef822db058eb8505911ed77f175b99e.factory.bridge.near", + "decimals": 18, + "blockchain": "near", + "symbol": "FRAX", + "price": 0.990293, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "853d955acef822db058eb8505911ed77f175b99e.factory.bridge.near", + "coingeckoId": "frax" + }, + { + "assetId": "nep141:aaaaaa20d9e0e2461697782ef11675f668207961.factory.bridge.near", + "decimals": 18, + "blockchain": "near", + "symbol": "AURORA", + "price": 0.01503679, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "aaaaaa20d9e0e2461697782ef11675f668207961.factory.bridge.near", + "coingeckoId": "aurora-near" + }, + { + "assetId": "nep141:2260fac5e5542a773aa44fbcfedf7c193bc2c599.factory.bridge.near", + "decimals": 8, + "blockchain": "near", + "symbol": "wBTC", + "price": 64016, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "2260fac5e5542a773aa44fbcfedf7c193bc2c599.factory.bridge.near", + "coingeckoId": "bitcoin" + }, + { + "assetId": "nep141:blackdragon.tkn.near", + "decimals": 24, + "blockchain": "near", + "symbol": "BLACKDRAGON", + "price": 4.555e-9, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "blackdragon.tkn.near", + "coingeckoId": "black-dragon" + }, + { + "assetId": "nep141:token.0xshitzu.near", + "decimals": 18, + "blockchain": "near", + "symbol": "SHITZU", + "price": 0.00083096, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "token.0xshitzu.near", + "coingeckoId": "shitzu" + }, + { + "assetId": "nep141:abg-966.meme-cooking.near", + "decimals": 18, + "blockchain": "near", + "symbol": "ABG", + "price": 0, + "priceUpdatedAt": "2026-08-07T15:30:00.459Z", + "contractAddress": "abg-966.meme-cooking.near", + "coingeckoId": "abg" + }, + { + "assetId": "nep141:noear-324.meme-cooking.near", + "decimals": 18, + "blockchain": "near", + "symbol": "NOEAR", + "price": 0, + "priceUpdatedAt": "2026-08-07T15:30:00.459Z", + "contractAddress": "noear-324.meme-cooking.near", + "coingeckoId": "noear" + }, + { + "assetId": "nep141:mpdao-token.near", + "decimals": 6, + "blockchain": "near", + "symbol": "mpDAO", + "price": 0.00357182, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "mpdao-token.near", + "coingeckoId": "meta-pool" + }, + { + "assetId": "nep141:zec.omft.near", + "decimals": 8, + "blockchain": "zec", + "symbol": "ZEC", + "price": 485.93, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "zcash" + }, + { + "assetId": "nep141:jambo-1679.meme-cooking.near", + "decimals": 18, + "blockchain": "near", + "symbol": "JAMBO", + "price": 0.00019018794221039044, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "jambo-1679.meme-cooking.near", + "coingeckoId": "jambo-2" + }, + { + "assetId": "nep141:kat.token0.near", + "decimals": 18, + "blockchain": "near", + "symbol": "NearKat", + "price": 0.00004587, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "kat.token0.near", + "coingeckoId": "nearkat" + }, + { + "assetId": "nep141:gnear-229.meme-cooking.near", + "decimals": 18, + "blockchain": "near", + "symbol": "GNEAR", + "price": 0, + "priceUpdatedAt": "2026-08-07T15:30:00.459Z", + "contractAddress": "gnear-229.meme-cooking.near", + "coingeckoId": "gnear" + }, + { + "assetId": "nep141:test-token.highdome3013.near", + "decimals": 8, + "blockchain": "near", + "symbol": "TESTNEBULA", + "price": 0, + "priceUpdatedAt": "2026-08-07T15:30:00.459Z", + "contractAddress": "test-token.highdome3013.near", + "coingeckoId": "testnebula" + }, + { + "assetId": "nep141:token.rhealab.near", + "decimals": 18, + "blockchain": "near", + "symbol": "RHEA", + "price": 0.0110406, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "token.rhealab.near", + "coingeckoId": "rhea-2" + }, + { + "assetId": "nep141:token.publicailab.near", + "decimals": 18, + "blockchain": "near", + "symbol": "PUBLIC", + "price": 0.00454695, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "token.publicailab.near", + "coingeckoId": "publicai" + }, + { + "assetId": "nep141:d9c2d319cd7e6177336b0a9c93c21cb48d84fb54.factory.bridge.near", + "decimals": 18, + "blockchain": "near", + "symbol": "HAPI", + "price": 0.215497, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "d9c2d319cd7e6177336b0a9c93c21cb48d84fb54.factory.bridge.near", + "coingeckoId": "hapi" + }, + { + "assetId": "nep141:itlx.intellex_xyz.near", + "decimals": 24, + "blockchain": "near", + "symbol": "ITLX", + "price": 0.00028433725417336683, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "itlx.intellex_xyz.near", + "coingeckoId": "itlx" + }, + { + "assetId": "nep141:cfi.consumer-fi.near", + "decimals": 18, + "blockchain": "near", + "symbol": "CFI", + "price": 0.00051737, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "cfi.consumer-fi.near", + "coingeckoId": "consumerfi-protocol" + }, + { + "assetId": "nep141:base-0xc2bc2a4cd04358281c7cf36a057fc15e5552b18b.omdep.near", + "decimals": 18, + "blockchain": "base", + "symbol": "SSC1_PIT", + "price": 0.10126230706419258, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xc2bc2a4cd04358281c7cf36a057fc15e5552b18b", + "coingeckoId": "custom:ssc1-pit" + }, + { + "assetId": "nep141:npro.nearmobile.near", + "decimals": 24, + "blockchain": "near", + "symbol": "NPRO", + "price": 0.2227221577650965, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "npro.nearmobile.near", + "coingeckoId": "npro" + }, + { + "assetId": "nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near", + "decimals": 6, + "blockchain": "eth", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:eth-0x68749665ff8d2d112fa859aa293f07a622782f38.omft.near", + "decimals": 6, + "blockchain": "eth", + "symbol": "XAUT", + "price": 4347.42, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x68749665ff8d2d112fa859aa293f07a622782f38", + "coingeckoId": "tether-gold" + }, + { + "assetId": "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near", + "decimals": 6, + "blockchain": "eth", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:eth-0xaaaaaa20d9e0e2461697782ef11675f668207961.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "AURORA", + "price": 0.01503679, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xaaaaaa20d9e0e2461697782ef11675f668207961", + "coingeckoId": "aurora-near" + }, + { + "assetId": "nep141:eth.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:abs.omdep.near", + "decimals": 18, + "blockchain": "abs", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:sui.omft.near", + "decimals": 9, + "blockchain": "sui", + "symbol": "SUI", + "price": 0.687945, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "sui" + }, + { + "assetId": "nep141:btc.omft.near", + "decimals": 8, + "blockchain": "btc", + "symbol": "BTC", + "price": 64016, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "bitcoin" + }, + { + "assetId": "nep141:nbtc.bridge.near", + "decimals": 8, + "blockchain": "near", + "symbol": "BTC", + "price": 64016, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "nbtc.bridge.near", + "coingeckoId": "bitcoin" + }, + { + "assetId": "nep141:eth-0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf.omft.near", + "decimals": 8, + "blockchain": "eth", + "symbol": "cbBTC", + "price": 63996, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf", + "coingeckoId": "coinbase-wrapped-btc" + }, + { + "assetId": "nep141:sol.omft.near", + "decimals": 9, + "blockchain": "sol", + "symbol": "SOL", + "price": 75.68, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "solana" + }, + { + "assetId": "nep141:fogo.omdep.near", + "decimals": 9, + "blockchain": "fogo", + "symbol": "FOGO", + "price": 0.00915219, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "fogo" + }, + { + "assetId": "nep141:arb-0x912ce59144191c1204e64559fe8253a0e49e6548.omft.near", + "decimals": 18, + "blockchain": "arb", + "symbol": "ARB", + "price": 0.079852, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x912ce59144191c1204e64559fe8253a0e49e6548", + "coingeckoId": "arbitrum" + }, + { + "assetId": "nep141:doge.omft.near", + "decimals": 8, + "blockchain": "doge", + "symbol": "DOGE", + "price": 0.070093, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "dogecoin" + }, + { + "assetId": "nep141:xrp.omft.near", + "decimals": 6, + "blockchain": "xrp", + "symbol": "XRP", + "price": 1.003, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ripple" + }, + { + "assetId": "nep141:eth-0xdefa4e8a7bcba345f687a2f1456f5edd9ce97202.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "KNC", + "price": 0.103531, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xdefa4e8a7bcba345f687a2f1456f5edd9ce97202", + "coingeckoId": "kyber-network-crystal" + }, + { + "assetId": "nep141:eth-0xa35923162c49cf95e6bf26623385eb431ad920d3.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "TURBO", + "price": 0.00082741, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xa35923162c49cf95e6bf26623385eb431ad920d3", + "coingeckoId": "turbo" + }, + { + "assetId": "nep141:sol-b9c68f94ec8fd160137af8cdfe5e61cd68e2afba.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "$WIF", + "price": 0.140485, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", + "coingeckoId": "dogwifcoin" + }, + { + "assetId": "nep141:sol-57d087fd8c460f612f8701f5499ad8b2eec5ab68.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "BOME", + "price": 0.00075052, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "ukHH6c7mMyiWCf1b9pnWe25TSpkDDt3H5pQZgZ74J82", + "coingeckoId": "book-of-meme" + }, + { + "assetId": "nep141:sol-c58e6539c2f2e097c251f8edf11f9c03e581f8d4.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "TRUMP", + "price": 1.49, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "coingeckoId": "official-trump" + }, + { + "assetId": "nep141:eth-0x6b175474e89094c44da98b954eedeac495271d0f.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "DAI", + "price": 0.99989, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x6b175474e89094c44da98b954eedeac495271d0f", + "coingeckoId": "dai" + }, + { + "assetId": "nep141:gnosis-0x9c58bacc331c9aa871afd802db6379a98e80cedb.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "GNO", + "price": 103.81, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x9c58bacc331c9aa871afd802db6379a98e80cedb", + "coingeckoId": "gnosis" + }, + { + "assetId": "nep141:gnosis-0x177127622c4a00f3d409b75571e12cb3c8973d3c.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "COW", + "price": 0.104668, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "coingeckoId": "cow-protocol" + }, + { + "assetId": "nep141:eth-0x5afe3855358e112b5647b952709e6165e1c1eeee.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "SAFE", + "price": 0.092534, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x5afe3855358e112b5647b952709e6165e1c1eeee", + "coingeckoId": "safe" + }, + { + "assetId": "nep141:eth-0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "AAVE", + "price": 88.67, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", + "coingeckoId": "aave" + }, + { + "assetId": "nep141:eth-0x1f9840a85d5af5bf1d1762f925bdaddc4201f984.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "UNI", + "price": 3.94, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "coingeckoId": "uniswap" + }, + { + "assetId": "nep141:eth-0x514910771af9ca656af840dff83e8264ecf986ca.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "LINK", + "price": 8.47, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x514910771af9ca656af840dff83e8264ecf986ca", + "coingeckoId": "chainlink" + }, + { + "assetId": "nep141:starknet.omft.near", + "decimals": 18, + "blockchain": "starknet", + "symbol": "STRK", + "price": 0.02384205, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "starknet" + }, + { + "assetId": "nep141:bera.omft.near", + "decimals": 18, + "blockchain": "bera", + "symbol": "BERA", + "price": 0.147175, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "berachain-bera" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_11111111111111111111", + "decimals": 18, + "blockchain": "bsc", + "symbol": "BNB", + "price": 604.46, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "binancecoin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_12zbnsg6xndDVj25QyL82YMPudb", + "decimals": 18, + "blockchain": "bsc", + "symbol": "ASTER", + "price": 0.602017, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x000ae314e2a2172a039b26378814c252734f556a", + "coingeckoId": "aster-2" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:143_11111111111111111111", + "decimals": 18, + "blockchain": "monad", + "symbol": "MON", + "price": 0.02213032, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "monad" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:196_11111111111111111111", + "decimals": 18, + "blockchain": "xlayer", + "symbol": "OKB", + "price": 95.01, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "okb" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:9745_11111111111111111111", + "decimals": 18, + "blockchain": "plasma", + "symbol": "XPL_(DEPRECATED)", + "price": 0.078898, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "plasma" + }, + { + "assetId": "nep141:plasma.omft.near", + "decimals": 18, + "blockchain": "plasma", + "symbol": "XPL", + "price": 0.078898, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "plasma" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:137_11111111111111111111", + "decimals": 18, + "blockchain": "pol", + "symbol": "POL", + "price": 0.075895, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "polygon-ecosystem-token" + }, + { + "assetId": "nep141:base-0x98d0baa52b2d063e780de12f615f963fe8537553.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "KAITO", + "price": 0.659734, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x98d0baa52b2d063e780de12f615f963fe8537553", + "coingeckoId": "kaito" + }, + { + "assetId": "nep141:tron.omft.near", + "decimals": 6, + "blockchain": "tron", + "symbol": "TRX", + "price": 0.331365, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "tron" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:1117_", + "decimals": 9, + "blockchain": "ton", + "symbol": "GRAM", + "price": 1.33, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "the-open-network" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:10_vLAiSt9KfUGKpw5cD3vsSyNYBo7", + "decimals": 18, + "blockchain": "op", + "symbol": "OP", + "price": 0.090224, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x4200000000000000000000000000000000000042", + "coingeckoId": "optimism" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:43114_11111111111111111111", + "decimals": 18, + "blockchain": "avax", + "symbol": "AVAX", + "price": 6.48, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "avalanche-2" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz", + "decimals": 7, + "blockchain": "stellar", + "symbol": "XLM", + "price": 0.161025, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "stellar" + }, + { + "assetId": "nep141:eth-0x2260fac5e5542a773aa44fbcfedf7c193bc2c599.omft.near", + "decimals": 8, + "blockchain": "eth", + "symbol": "WBTC", + "price": 64024, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "coingeckoId": "wrapped-bitcoin" + }, + { + "assetId": "nep141:cardano.omft.near", + "decimals": 6, + "blockchain": "cardano", + "symbol": "ADA", + "price": 0.188696, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "cardano" + }, + { + "assetId": "nep141:aptos.omft.near", + "decimals": 8, + "blockchain": "aptos", + "symbol": "APT", + "price": 0.584611, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "aptos" + }, + { + "assetId": "nep141:ltc.omft.near", + "decimals": 8, + "blockchain": "ltc", + "symbol": "LTC", + "price": 45.11, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "litecoin" + }, + { + "assetId": "nep141:eth-0xe0f63a424a4439cbe457d80e4f4b51ad25b2c56c.omft.near", + "decimals": 8, + "blockchain": "eth", + "symbol": "SPX", + "price": 0.314205, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xe0f63a424a4439cbe457d80e4f4b51ad25b2c56c", + "coingeckoId": "spx6900" + }, + { + "assetId": "nep141:bch.omft.near", + "decimals": 8, + "blockchain": "bch", + "symbol": "BCH", + "price": 214.68, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "bitcoin-cash" + }, + { + "assetId": "nep141:eth-0x8b1484d57abbe239bb280661377363b03c89caea.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "ADI", + "price": 6.83, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x8b1484d57abbe239bb280661377363b03c89caea", + "coingeckoId": "adi-token" + }, + { + "assetId": "nep141:eth-0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "SHIB", + "price": 0.00000449, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", + "coingeckoId": "shiba-inu" + }, + { + "assetId": "nep141:eth-0x6982508145454ce325ddbe47a25d4ec3d2311933.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "PEPE", + "price": 0.00000285, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x6982508145454ce325ddbe47a25d4ec3d2311933", + "coingeckoId": "pepe" + }, + { + "assetId": "nep141:eth-0xdef1b2d939edc0e4d35806c59b3166f790175afe.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "INX", + "price": 0.00846838, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xdef1b2d939edc0e4d35806c59b3166f790175afe", + "coingeckoId": "infinex-2" + }, + { + "assetId": "nep141:sol-0xaad74c68eecfc9f8c5bdcea614f6167048c795ef.omdep.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "PENGU", + "price": 0.00638895, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv", + "coingeckoId": "pudgy-penguins" + }, + { + "assetId": "nep141:base-0xe62bfbe57763ec24c0f130426f34dbce11fc5b06.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "TITN", + "price": 0.00747427, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xe62bfbe57763ec24c0f130426f34dbce11fc5b06", + "coingeckoId": "thor-wallet" + }, + { + "assetId": "nep141:aleo.omft.near", + "decimals": 6, + "blockchain": "aleo", + "symbol": "ALEO", + "price": 0.01595715, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "aleo" + }, + { + "assetId": "nep141:dash.omft.near", + "decimals": 8, + "blockchain": "dash", + "symbol": "DASH", + "price": 30.56, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "dash" + }, + { + "assetId": "nep141:sol-0x936420c6ae310eb29511d139991654f922456fbe.omdep.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "USD1", + "price": 0.999401, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB", + "coingeckoId": "usd1-wlfi" + }, + { + "assetId": "nep141:base-0xacfe6019ed1a7dc6f7b508c02d1b04ec88cc21bf.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "VVV", + "price": 11.78, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xacfe6019ed1a7dc6f7b508c02d1b04ec88cc21bf", + "coingeckoId": "venice-token" + }, + { + "assetId": "nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near", + "decimals": 6, + "blockchain": "tron", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:sol-91914f13d3b54f8126a2824d71632d4b078d7403.omft.near", + "decimals": 8, + "blockchain": "sol", + "symbol": "xBTC", + "price": 64016, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "CtzPWv73Sn1dMGVU3ZtLv9yWSyUAanBni19YWDaznnkn", + "coingeckoId": "bitcoin" + }, + { + "assetId": "nep141:usdt.tether-token.near", + "decimals": 6, + "blockchain": "near", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "usdt.tether-token.near", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:a35923162c49cf95e6bf26623385eb431ad920d3.factory.bridge.near", + "decimals": 18, + "blockchain": "near", + "symbol": "TURBO", + "price": 0.00082741, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "a35923162c49cf95e6bf26623385eb431ad920d3.factory.bridge.near", + "coingeckoId": "turbo" + }, + { + "assetId": "nep141:arb.omft.near", + "decimals": 18, + "blockchain": "arb", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:arb-0x82af49447d8a07e3bd95bd0d56f35241523fbab1.omft.near", + "decimals": 18, + "blockchain": "arb", + "symbol": "WETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:arb-0xaf88d065e77c8cc2239327c5edb3a432268e5831.omft.near", + "decimals": 6, + "blockchain": "arb", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:arb-0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9.omft.near", + "decimals": 6, + "blockchain": "arb", + "symbol": "USDT0", + "price": 0.998884, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", + "coingeckoId": "usdt0" + }, + { + "assetId": "nep141:eth-0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "WETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:base.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:base-0x4200000000000000000000000000000000000006.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "WETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x4200000000000000000000000000000000000006", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near", + "decimals": 6, + "blockchain": "base", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:sol-df27d7abcc1c656d4ac3b1399bbfbba1994e6d8c.omft.near", + "decimals": 8, + "blockchain": "sol", + "symbol": "TURBO", + "price": 0.00082741, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "2Dyzu65QA9zdX1UeE7Gx71k7fiwyUK6sZdrvJ7auq5wm", + "coingeckoId": "turbo" + }, + { + "assetId": "nep141:gnosis.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "xDAI", + "price": 1.005, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "xdai" + }, + { + "assetId": "nep141:gnosis-0x2a22f9c3b484c3629090feed35f17ff8f88f76f0.omft.near", + "decimals": 6, + "blockchain": "gnosis", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:gnosis-0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "WETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep141:gnosis-0x4ecaba5870353805a9f068101a40e0f32ed605c6.omft.near", + "decimals": 6, + "blockchain": "gnosis", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x4ecaba5870353805a9f068101a40e0f32ed605c6", + "coingeckoId": "tether" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:137_2jwTGwKRX3AEe7tyzDrxtDjEFgSt", + "decimals": 18, + "blockchain": "pol", + "symbol": "WETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:137_qiStmoQJDQPTebaPjgx5VBxZv6L", + "decimals": 6, + "blockchain": "pol", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:137_3hpYoaLtt8MP1Z2GH1U473DMRKgr", + "decimals": 6, + "blockchain": "pol", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "coingeckoId": "tether" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_2w93GqMcEmQFDru84j3HZZWt557r", + "decimals": 18, + "blockchain": "bsc", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_2CMMyVTGZkeyNZTSvS5sarzfir6g", + "decimals": 18, + "blockchain": "bsc", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x55d398326f99059ff775485246999027b3197955", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:purge-558.meme-cooking.near", + "decimals": 18, + "blockchain": "near", + "symbol": "PURGE", + "price": 0.00048594, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "purge-558.meme-cooking.near", + "coingeckoId": "forgive-me-father" + }, + { + "assetId": "nep141:eth-0xd9c2d319cd7e6177336b0a9c93c21cb48d84fb54.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "HAPI", + "price": 0.215497, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xd9c2d319cd7e6177336b0a9c93c21cb48d84fb54", + "coingeckoId": "hapi" + }, + { + "assetId": "nep141:base-0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf.omft.near", + "decimals": 8, + "blockchain": "base", + "symbol": "cbBTC", + "price": 63996, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf", + "coingeckoId": "coinbase-wrapped-btc" + }, + { + "assetId": "nep141:base-0x227d920e20ebac8a40e7d6431b7d724bb64d7245.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "SWEAT", + "price": 0.00034004, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x227d920e20ebac8a40e7d6431b7d724bb64d7245", + "coingeckoId": "sweatcoin" + }, + { + "assetId": "nep141:eth-0xb4b9dc1c77bdbb135ea907fd5a08094d98883a35.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "SWEAT", + "price": 0.00034004, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xb4b9dc1c77bdbb135ea907fd5a08094d98883a35", + "coingeckoId": "sweatcoin" + }, + { + "assetId": "nep141:arb-0xca7dec8550f43a5e46e3dfb95801f64280e75b27.omft.near", + "decimals": 18, + "blockchain": "arb", + "symbol": "SWEAT", + "price": 0.00034004, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xca7dec8550f43a5e46e3dfb95801f64280e75b27", + "coingeckoId": "sweatcoin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_28V9BijGeZDBFEEtkAcnJo4tPRH4", + "decimals": 18, + "blockchain": "bsc", + "symbol": "SWEAT", + "price": 0.00034004, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x510ad22d8c956dcc20f68932861f54a591001283", + "coingeckoId": "sweatcoin" + }, + { + "assetId": "nep141:token.sweat", + "decimals": 18, + "blockchain": "near", + "symbol": "SWEAT", + "price": 0.00034004, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "token.sweat", + "coingeckoId": "sweatcoin" + }, + { + "assetId": "nep141:aptos-88cb7619440a914fe6400149a12b443c3ac21d59.omft.near", + "decimals": 6, + "blockchain": "aptos", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:aptos-34ee497f210c5a511e8d5b53bc56d75b63612bb5.omft.near", + "decimals": 6, + "blockchain": "aptos", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:10_11111111111111111111", + "decimals": 18, + "blockchain": "op", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:10_vLAiSt9KfUGKpw5cD3vsSyNYBn5", + "decimals": 18, + "blockchain": "op", + "symbol": "WETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x4200000000000000000000000000000000000006", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:10_359RPSJVdTxwTJT9TyGssr2rFoWo", + "decimals": 6, + "blockchain": "op", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", + "coingeckoId": "tether" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:10_A2ewyUyDp6qsue1jqZsGypkCxRJ", + "decimals": 6, + "blockchain": "op", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:43114_372BeH7ENZieCaabwkbWkBiTTgXp", + "decimals": 6, + "blockchain": "avax", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7", + "coingeckoId": "tether" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:43114_3atVJH3r5c4GqiSYmg9fECvjc47o", + "decimals": 6, + "blockchain": "avax", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:1117_3tsdfyziyc7EJbP2aULWSKU4toBaAcN4FdTgfm5W1mC4ouR", + "decimals": 6, + "blockchain": "ton", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:sui-c1b81ecaf27933252d31a963bc5e9458f13c18ce.omft.near", + "decimals": 6, + "blockchain": "sui", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:1100_111bzQBB65GxAPAVoxqmMcgYo5oS3txhqs1Uh1cgahKQUeTUq1TJu", + "decimals": 7, + "blockchain": "stellar", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_24S22V8GMmQN8t6PbCdRb3mBewAd", + "decimals": 18, + "blockchain": "bsc", + "symbol": "RHEA", + "price": 0.0110406, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x4c067de26475e1cefee8b8d1f6e2266b33a2372e", + "coingeckoId": "rhea-2" + }, + { + "assetId": "nep141:sol-1f00bb36e75cfc8e1274c1507cc3054f5b3f3ce1.omft.near", + "decimals": 9, + "blockchain": "sol", + "symbol": "PUBLIC", + "price": 0.00454695, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "AXCp86262ZPfpcV9bmtmtnzmJSL5sD99mCVJD4GR9vS", + "coingeckoId": "publicai" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_SZzgw3HSudhZcTwPWUTi2RJB19t", + "decimals": 18, + "blockchain": "bsc", + "symbol": "NEAR", + "price": 1.61, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x1fa4a73a3f0133f0025378af00236f3abdee5d63", + "coingeckoId": "near" + }, + { + "assetId": "nep141:sol-c634d063ceff771aff0c972ec396fd915a6bbd0e.omft.near", + "decimals": 8, + "blockchain": "sol", + "symbol": "SPX", + "price": 0.314205, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "J3NKxxXZcnNiMjKw9hYb2K4LUxgwB6t1FtPtQVsv3KFr", + "coingeckoId": "spx6900" + }, + { + "assetId": "nep141:base-0x1c4a802fd6b591bb71daa01d8335e43719048b24.omft.near", + "decimals": 6, + "blockchain": "base", + "symbol": "sUSDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x1c4a802fd6b591bb71daa01d8335e43719048b24", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:sol-2dc7b64e5dd3c717fc85abaf51cdcd4b18687f09.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "sUSDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "3tMdx4g4grCgqHjELqALfTPnZnG1BLwsPntD3tGREgvp", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:143_4EJiJxSALvGoTZbnc8K7Ft9533et", + "decimals": 6, + "blockchain": "monad", + "symbol": "USDT0", + "price": 0.998884, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xe7cd86e13ac4309349f30b3435a9d337750fc82d", + "coingeckoId": "usdt0" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:143_2dmLwYWkCQKyTjeUPAsGJuiVLbFx", + "decimals": 6, + "blockchain": "monad", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x754704bc059f8c67012fed69bc8a327a5aafb603", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:bera-0x779ded0c9e1022225f8e0630b35a9b54be713736.omft.near", + "decimals": 6, + "blockchain": "bera", + "symbol": "USDT0", + "price": 0.998884, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x779ded0c9e1022225f8e0630b35a9b54be713736", + "coingeckoId": "usdt0" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:196_2fezDCvVYRsG8wrK6deJ2VRPiAS1", + "decimals": 6, + "blockchain": "xlayer", + "symbol": "USDT0", + "price": 0.998884, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x779ded0c9e1022225f8e0630b35a9b54be713736", + "coingeckoId": "usdt0" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:196_2dK9kLNR7Ekq7su8FxNGiUW3djTw", + "decimals": 6, + "blockchain": "xlayer", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x74b7f16337b8972027f6196a17a631ac6de26d22", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:9745_3aL9skCy1yhPoDB8oKMmRHRN7SJW", + "decimals": 6, + "blockchain": "plasma", + "symbol": "USDT0(DEPRECATED)", + "price": 0.998884, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb", + "coingeckoId": "usdt0" + }, + { + "assetId": "nep141:plasma-0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb.omft.near", + "decimals": 6, + "blockchain": "plasma", + "symbol": "USDT0", + "price": 0.998884, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb", + "coingeckoId": "usdt0" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:36900_11111111111111111111", + "decimals": 18, + "blockchain": "adi", + "symbol": "ADI", + "price": 6.83, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "adi-token" + }, + { + "assetId": "nep141:gnosis-0x4d18815d14fe5c3304e87b3fa18318baa5c23820.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "SAFE", + "price": 0.092534, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x4d18815d14fe5c3304e87b3fa18318baa5c23820", + "coingeckoId": "safe" + }, + { + "assetId": "nep141:aleo-usad.omft.near", + "decimals": 6, + "blockchain": "aleo", + "symbol": "USAD", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "usad", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep141:aleo-usdcx.omft.near", + "decimals": 6, + "blockchain": "aleo", + "symbol": "USDCx", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "usdcx", + "coingeckoId": "usd-coin" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:534352_11111111111111111111", + "decimals": 18, + "blockchain": "scroll", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "ethereum" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:534352_4RG3Q2wFsMQmd45m5m89RjsLfupA", + "decimals": 6, + "blockchain": "scroll", + "symbol": "USDT", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xf55bec9cafdbe8730f096aa55dad6d22d44099df", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:meta-pool.near", + "decimals": 24, + "blockchain": "near", + "symbol": "stNEAR", + "price": 2.39, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "meta-pool.near", + "coingeckoId": "staked-near" + }, + { + "assetId": "nep141:sol-0xa69aa1bcb03a369e338156a8718ad60271145803.omdep.near", + "decimals": 9, + "blockchain": "sol", + "symbol": "kV-gtSOLb", + "price": 79.01664005196815, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "5EBsGgVTubrd7ShJgE89k6nC2bnLzqGCjXb2ejrhtdBK" + }, + { + "assetId": "nep141:arb-0xfc5a1a6eb076a2c7ad06ed22c90d7e710e35ad0a.omft.near", + "decimals": 18, + "blockchain": "arb", + "symbol": "GMX", + "price": 6.46, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xfc5a1a6eb076a2c7ad06ed22c90d7e710e35ad0a", + "coingeckoId": "gmx" + }, + { + "assetId": "nep141:eth-0x0f38f1ce62776d4a0038bc6cac66877a5687383b.omft.near", + "decimals": 8, + "blockchain": "eth", + "symbol": "TLO", + "price": 1.0122672753790145, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x0f38f1ce62776d4a0038bc6cac66877a5687383b" + }, + { + "assetId": "nep141:eth-0xaf08e292d62df255f7953665a44ed65f0380aa60.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "steakUSDC", + "price": 0.000001135319894253304, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xaf08e292d62df255f7953665a44ed65f0380aa60" + }, + { + "assetId": "nep141:eth-0xaaee1a9723aadb7afa2810263653a34ba2c21c7a.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "MOG", + "price": 1.01182e-7, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xaaee1a9723aadb7afa2810263653a34ba2c21c7a", + "coingeckoId": "mog-coin" + }, + { + "assetId": "nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "gtUSDCp", + "price": 0.0000011060143019396261, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x0bb69b79bc829e1cfcc34a740110886d98d2bd14" + }, + { + "assetId": "nep141:base-0x7429743f8adbbe932b27bc02267b0e70f1ba688b.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "sparkUSDC", + "price": 0.000001074651780614601, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x7429743f8adbbe932b27bc02267b0e70f1ba688b" + }, + { + "assetId": "nep141:base-0x3388d158fdcc31398b99478420e6945cdaace009.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "mwUSDC", + "price": 0.0000010837782231147, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x3388d158fdcc31398b99478420e6945cdaace009" + }, + { + "assetId": "nep141:base-0x532f27101965dd16442e59d40670faf5ebb142e4.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "BRETT", + "price": 0.00409498, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x532f27101965dd16442e59d40670faf5ebb142e4", + "coingeckoId": "based-brett" + }, + { + "assetId": "nep141:base-0xa5c67d8d37b88c2d88647814da5578128e2c93b2.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "FMS", + "price": 0, + "priceUpdatedAt": "2026-08-07T15:30:00.459Z", + "contractAddress": "0xa5c67d8d37b88c2d88647814da5578128e2c93b2" + }, + { + "assetId": "nep141:sol-d600e625449a4d9380eaf5e3265e54c90d34e260.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "MELANIA", + "price": 0.075246, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "FUAfBo2jgks6gB4Z4LfZkqSZgzNucisEHqnNebaRxM1P", + "coingeckoId": "melania-meme" + }, + { + "assetId": "nep141:sol-bb27241c87aa401cc963c360c175dd7ca7035873.omft.near", + "decimals": 6, + "blockchain": "sol", + "symbol": "LOUD", + "price": 0.00016471, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "EJZJpNa4tDZ3kYdcRZgaAtaKm3fLJ5akmyPkCaKmfWvd", + "coingeckoId": "loud" + }, + { + "assetId": "nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "EURe", + "price": 1.15, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430", + "coingeckoId": "monerium-eur-money-2" + }, + { + "assetId": "nep141:gnosis-0x5cb9073902f2035222b9749f8fb0c9bfe5527108.omft.near", + "decimals": 18, + "blockchain": "gnosis", + "symbol": "GBPe", + "price": 1.35, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x5cb9073902f2035222b9749f8fb0c9bfe5527108", + "coingeckoId": "monerium-gbp-emoney" + }, + { + "assetId": "nep141:eth-0xfa2b947eec368f42195f24f36d2af29f7c24cec2.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "USDf", + "price": 0.995885, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xfa2b947eec368f42195f24f36d2af29f7c24cec2", + "coingeckoId": "falcon-finance" + }, + { + "assetId": "nep141:eth-0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d.omft.near", + "decimals": 18, + "blockchain": "eth", + "symbol": "USD1", + "price": 0.999401, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d", + "coingeckoId": "usd1-wlfi" + }, + { + "assetId": "nep141:stjack.tkn.primitives.near", + "decimals": 18, + "blockchain": "near", + "symbol": "STJACK", + "price": 0, + "priceUpdatedAt": "2026-08-07T15:30:00.459Z", + "contractAddress": "stjack.tkn.primitives.near" + }, + { + "assetId": "nep245:v2_1.omni.hot.tg:56_3NNshCLCt8r8E7x9FoDuiwoNQWgp", + "decimals": 18, + "blockchain": "bsc", + "symbol": "EVAA", + "price": 0.823167, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xaa036928c9c0df07d525b55ea8ee690bb5a628c1", + "coingeckoId": "evaa-protocol" + }, + { + "assetId": "nep141:lsd-usdt.rhealab.near", + "decimals": 18, + "blockchain": "near", + "symbol": "nrUsdt", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "lsd-usdt.rhealab.near", + "coingeckoId": "tether" + }, + { + "assetId": "nep141:eth-0x06ea695b91700071b161a434fed42d1dcbad9f00.omft.near", + "decimals": 8, + "blockchain": "eth", + "symbol": "hemiBTC", + "price": 63766, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x06ea695b91700071b161a434fed42d1dcbad9f00", + "coingeckoId": "hemi-bitcoin" + }, + { + "assetId": "nep141:movement.omft.near", + "decimals": 8, + "blockchain": "movement", + "symbol": "MOVE", + "price": 0.00651188, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "coingeckoId": "movement" + }, + { + "assetId": "nep141:movement-6f9a70ef4605e7d9174f1abf8d8d3c15012f48f3.omft.near", + "decimals": 6, + "blockchain": "movement", + "symbol": "USDCx", + "price": 0.983707, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xba11833544a2f99eec743f41a228ca6ffa7f13c3b6b04681d5a79a8b75ff225e", + "coingeckoId": "usdcx-movement" + }, + { + "assetId": "nep141:pol-0x7b12598e3616261df1c05ec28de0d2fb10c1f206.omdep.near", + "decimals": 18, + "blockchain": "pol", + "symbol": "COCA", + "price": 1.66, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x7b12598e3616261df1c05ec28de0d2fb10c1f206", + "coingeckoId": "coca" + }, + { + "assetId": "nep141:base-0x959fc04dbf97a27073f89237cd62605f4d1b906d.omft.near", + "decimals": 18, + "blockchain": "base", + "symbol": "COCA", + "price": 1.66, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x959fc04dbf97a27073f89237cd62605f4d1b906d", + "coingeckoId": "coca" + }, + { + "assetId": "1cs_v1:sol:spl:A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + "decimals": 8, + "blockchain": "sol", + "symbol": "ZEC", + "price": 485.93, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + "coingeckoId": "zcash" + }, + { + "assetId": "1cs_v1:starknet:erc20:0x05ce53b9b68fb8e9ecab9283a96d97948914733fd6ed8d9a53a276a419497841", + "decimals": 8, + "blockchain": "starknet", + "symbol": "ZEC", + "price": 485.93, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x05ce53b9b68fb8e9ecab9283a96d97948914733fd6ed8d9a53a276a419497841", + "coingeckoId": "zcash" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x261c85bc1bb5acc3ffcf769530c732d4182c3bbd84936d427125fcd4732e9879", + "decimals": 8, + "blockchain": "aptos", + "symbol": "ZEC", + "price": 485.93, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x261c85bc1bb5acc3ffcf769530c732d4182c3bbd84936d427125fcd4732e9879", + "coingeckoId": "zcash" + }, + { + "assetId": "1cs_v1:starknet:erc20:0x07bc19585817a78f2304b2f3b31f954d80e8a1eff6e8d81a84eb5cedb7267728", + "decimals": 6, + "blockchain": "starknet", + "symbol": "XRP", + "price": 1.003, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x07bc19585817a78f2304b2f3b31f954d80e8a1eff6e8d81a84eb5cedb7267728", + "coingeckoId": "ripple" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x692a35763b3715e910bd7207937d3695c185cde7292f3d89a4d36a907b22dca4", + "decimals": 6, + "blockchain": "aptos", + "symbol": "XRP", + "price": 1.003, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x692a35763b3715e910bd7207937d3695c185cde7292f3d89a4d36a907b22dca4", + "coingeckoId": "ripple" + }, + { + "assetId": "1cs_v1:near:nep141:zec.omft.near", + "decimals": 8, + "blockchain": "near", + "symbol": "ZEC", + "price": 485.93, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "zec.omft.near", + "coingeckoId": "zcash" + }, + { + "assetId": "1cs_v1:base:erc20:0x0382e3fee4a420bd446367d468a6f00225853420", + "decimals": 18, + "blockchain": "base", + "symbol": "CFI", + "price": 0.00051737, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x0382e3fee4a420bd446367d468a6f00225853420", + "coingeckoId": "consumerfi-protocol" + }, + { + "assetId": "1cs_v1:bsc:bep20:0x5382555840ef9f54ef6d3ee5da60f12bcabf4b87", + "decimals": 18, + "blockchain": "bsc", + "symbol": "nrUsdt", + "price": 0.999128, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x5382555840ef9f54ef6d3ee5da60f12bcabf4b87", + "coingeckoId": "tether" + }, + { + "assetId": "1cs_v1:btc:native:coin", + "decimals": 8, + "blockchain": "btc", + "symbol": "BTC(OMNI)", + "price": 64016, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "coin", + "coingeckoId": "bitcoin" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x0b0b819dcf8d9517ed14195a95adfae6a49bfdb49de33a532ca0aa7ee588e8e0", + "decimals": 8, + "blockchain": "aptos", + "symbol": "BTC", + "price": 64016, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x0b0b819dcf8d9517ed14195a95adfae6a49bfdb49de33a532ca0aa7ee588e8e0", + "coingeckoId": "bitcoin" + }, + { + "assetId": "1cs_v1:hypercore:erc20:0xb88339CB7199b77E23DB6E890353E22632Ba630f", + "decimals": 6, + "blockchain": "hypercore", + "symbol": "USDC", + "price": 0.999619, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xb88339CB7199b77E23DB6E890353E22632Ba630f", + "coingeckoId": "usd-coin" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0xb07cf73714a0980fd589e1602ea98fc9c18b8c9c82b828ad3662ee873629be1d", + "decimals": 8, + "blockchain": "aptos", + "symbol": "ETH", + "price": 1875.65, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xb07cf73714a0980fd589e1602ea98fc9c18b8c9c82b828ad3662ee873629be1d", + "coingeckoId": "ethereum" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x71dbd0b8854d5fe062988570d8ba5d0f046a8e91bbd4ddf2890ab291bad86e22", + "decimals": 8, + "blockchain": "aptos", + "symbol": "LINK", + "price": 8.47, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x71dbd0b8854d5fe062988570d8ba5d0f046a8e91bbd4ddf2890ab291bad86e22", + "coingeckoId": "chainlink" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x3c521aac00b811b330a2bc168544fc7ceab8a6546c9ed5abfead2c628642a0d3", + "decimals": 8, + "blockchain": "aptos", + "symbol": "UNI", + "price": 3.94, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x3c521aac00b811b330a2bc168544fc7ceab8a6546c9ed5abfead2c628642a0d3", + "coingeckoId": "uniswap" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x74303480a6caa440a5f328dc76037840cc02378e77f60f7e75bd3c4ab4941cbb", + "decimals": 8, + "blockchain": "aptos", + "symbol": "AAVE", + "price": 88.67, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x74303480a6caa440a5f328dc76037840cc02378e77f60f7e75bd3c4ab4941cbb", + "coingeckoId": "aave" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0xc4f728166326df289538e70a91d3cea6ed451812ac6941d26f8309022b850393", + "decimals": 8, + "blockchain": "aptos", + "symbol": "DOGE", + "price": 0.070093, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0xc4f728166326df289538e70a91d3cea6ed451812ac6941d26f8309022b850393", + "coingeckoId": "dogecoin" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x09b9c9075b83d724a1439675e10af6012e25f36f0eabc1f77ee0236dc8229365", + "decimals": 8, + "blockchain": "aptos", + "symbol": "LTC", + "price": 45.11, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x09b9c9075b83d724a1439675e10af6012e25f36f0eabc1f77ee0236dc8229365", + "coingeckoId": "litecoin" + }, + { + "assetId": "1cs_v1:aptos:aptos-fa:0x0d829a23c3a3760e2fed60ca24d83cee8943f7c61859608ae0726fdb2e4f2784", + "decimals": 8, + "blockchain": "aptos", + "symbol": "SOL", + "price": 75.68, + "priceUpdatedAt": "2026-08-11T08:30:31.362Z", + "contractAddress": "0x0d829a23c3a3760e2fed60ca24d83cee8943f7c61859608ae0726fdb2e4f2784", + "coingeckoId": "solana" + } +] diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-quote.json b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-quote.json new file mode 100644 index 00000000000..871c0633d6e --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-quote.json @@ -0,0 +1,40 @@ +{ + "quote": { + "amountIn": "5251177", + "amountInFormatted": "5.251177", + "amountInUsd": "5.249249818041", + "minAmountIn": "5224921", + "amountOut": "5248240", + "amountOutFormatted": "5.24824", + "amountOutUsd": "5.246313895920", + "minAmountOut": "5221998", + "timeEstimate": 47, + "refundFee": "300000", + "withdrawFee": "2400", + "deadline": "2026-08-14T09:03:45.000Z", + "timeWhenInactive": "2026-08-14T09:03:45.000Z", + "depositAddress": "0x844Cf53c3aB4388b29988d019875eB955db01Cb5" + }, + "quoteRequest": { + "dry": false, + "depositMode": "SIMPLE", + "swapType": "FLEX_INPUT", + "slippageTolerance": 50, + "originAsset": "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near", + "depositType": "ORIGIN_CHAIN", + "destinationAsset": "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near", + "amount": "5251177", + "refundTo": "0xfb3c7eb936cAA12B5A884d612393969A557d4307", + "refundType": "ORIGIN_CHAIN", + "recipient": "0xfb3c7eb936cAA12B5A884d612393969A557d4307", + "recipientType": "DESTINATION_CHAIN", + "deadline": "2026-08-11T09:03:45.000Z", + "confidentiality": "public", + "referral": "cow", + "quoteWaitingTimeMs": 0, + "insured": false + }, + "signature": "ed25519:mewrJzX3R7chvf3K3ko3oaKZQ5LG6qWKH9DQgjV9sGViyx5zmZREgsYPaJjWSkXkZaQm2KsN2EqGX2QMoW5wgKj", + "timestamp": "2026-08-11T08:33:45.277Z", + "correlationId": "f0200a32-441a-4921-909b-a8fb27782930" +} diff --git a/apps/cowswap-e2e-tests/src/mocks/bridge/loadFixture.ts b/apps/cowswap-e2e-tests/src/mocks/bridge/loadFixture.ts new file mode 100644 index 00000000000..0e794f375b7 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bridge/loadFixture.ts @@ -0,0 +1,8 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +const FIXTURES_DIR = path.join(__dirname, 'fixtures') + +export function loadFixture(name: string): unknown { + return JSON.parse(readFileSync(path.join(FIXTURES_DIR, name), 'utf8')) as unknown +} diff --git a/apps/cowswap-e2e-tests/src/mocks/bungee.ts b/apps/cowswap-e2e-tests/src/mocks/bungee.ts new file mode 100644 index 00000000000..713f64281e1 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/bungee.ts @@ -0,0 +1,178 @@ +import { loadFixture } from './bridge/loadFixture' + +import type { BrowserContext, Route } from '@playwright/test' + +// Matches both the real Bungee backend (prod-like builds) and the barn proxy CoW falls back to +// otherwise — see `getBungeeApiBase()` in `apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts`. +const BUNGEE_URL_PATTERN = + /^https:\/\/(?:backend\.bungee\.exchange|bff\.barn\.cow\.fi\/proxies\/socket)\/api\/v1\/(?:bungee|bungee-manual)\//i + +const BUNGEE_APPROVE_AND_BRIDGE_V1_ADDRESS = '0xD06a673fe1fa27B1b9E5BA0be980AB15Dbce85cc' +// Selector for the `across` family's `bridgeERC20To` (see `BungeeTxDataBytesIndices` in +// `@cowprotocol/sdk-bridging`). `bungee-quote.json`'s manual routes sort with "Across" first +// (highest `output.amount`), and `createBungeeDepositCall()` looks this selector up by whichever +// bridge family the selected route belongs to when it later builds the real deposit call. +const ACROSS_BRIDGE_ERC20_TO_SELECTOR = 'cc54d224' + +export interface BungeeMock { + reset(): void +} + +interface BungeeAmountField { + amount: string + valueInUsd: number + token: { decimals: number } + effectiveAmount?: string + effectiveValueInUsd?: number + minAmountOut?: string + effectiveReceivedInUsd?: number +} + +interface BungeeQuoteFixture { + result: { + input: BungeeAmountField + manualRoutes: ReadonlyArray<{ output: BungeeAmountField }> + } +} + +export function installBungee(context: BrowserContext): BungeeMock { + const quoteFixture = loadFixture('bungee-quote.json') as BungeeQuoteFixture + const destTokensFixture = loadFixture('bungee-dest-tokens.json') + const intermediateTokensFixture = loadFixture('bungee-intermediate-tokens.json') + + void context.route(BUNGEE_URL_PATTERN, async (route: Route) => { + const pathname = new URL(route.request().url()).pathname + + if (pathname.endsWith('/quote')) { + // The app briefly requests a quote at amount=0 while a typed amount is still debouncing in. + // Echoing the fixture's success response back unconditionally feeds that zero into the SDK's + // own amount-based math (`calculateFeeBps`), dividing by it and crashing instead of the + // harmless "no routes for this (nonsensical) request" the real API would produce — answer + // with an empty (but schema-valid, see `isValidQuoteResponse`) route list instead, which the + // SDK turns into a clean, expected `NO_ROUTES` rather than an unhandled exception. + const params = new URL(route.request().url()).searchParams + const amount = params.get('inputAmount') + if (!amount || amount === '0') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + statusCode: 200, + result: { + originChainId: Number(params.get('originChainId')), + destinationChainId: Number(params.get('destinationChainId')), + userAddress: params.get('userAddress'), + receiverAddress: params.get('receiverAddress'), + input: null, + autoRoute: null, + manualRoutes: [], + }, + message: null, + }), + }) + return + } + const scaledFixture = scaleBungeeQuoteFixture(quoteFixture, BigInt(amount)) + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(scaledFixture) }) + return + } + if (pathname.endsWith('/build-tx')) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(buildTxResponse()) }) + return + } + if (pathname.endsWith('/dest-tokens')) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(destTokensFixture) }) + return + } + if (pathname.endsWith('/intermediate-tokens')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(intermediateTokensFixture), + }) + return + } + await route.fallback() + }) + + return { + reset() { + // Fixtures are served as-is for every test — nothing mutable to reset yet. + }, + } +} + +function buildTxResponse(): unknown { + // `decodeBungeeBridgeTxData` just needs a 4-byte routeId followed by a function selector it + // recognizes for the quote's bridge family — on-chain verification (not this payload) is what + // actually gates whether the quote is accepted, see `mocks/socketVerifier.ts`. + const routeId = '00000001' + const data = `0x${routeId}${ACROSS_BRIDGE_ERC20_TO_SELECTOR}${'0'.repeat(64)}` + return { + success: true, + statusCode: 200, + result: { txData: { to: BUNGEE_APPROVE_AND_BRIDGE_V1_ADDRESS, data, value: '0' } }, + message: null, + } +} + +/** + * `bungee-quote.json` was captured for one specific sell amount (~4.96 USDC-worth of input) — its + * `output.amount` is a static number unrelated to whatever amount an individual test actually + * requests. `BungeeBridgeProvider.toAmountsAndCosts()` (in `@cowprotocol/sdk-bridging`) builds + * `sellAmount` from the *live* request amount but `buyAmount` straight from this static fixture, + * so serving it unscaled makes the bridge leg's own before/after ratio wildly wrong for any sell + * amount other than the one it was captured for — enough to trip the "Confirm Price Impact" dialog + * (see `useEstimatedBridgeBuyAmount`, which rescales the swap leg's real output through exactly + * that ratio). Scaling every amount field by the fixture's own input:output ratio keeps the ratio + * — and therefore price impact — realistic regardless of the amount a given test asks for. + */ +function scaleBungeeQuoteFixture(fixture: BungeeQuoteFixture, requestedInputAmount: bigint): unknown { + const { input, manualRoutes } = fixture.result + const fixtureInputAmount = BigInt(input.amount) + const scale = (amount: string): string => ((BigInt(amount) * requestedInputAmount) / fixtureInputAmount).toString() + + return { + ...fixture, + result: { + ...fixture.result, + input: { + ...input, + amount: requestedInputAmount.toString(), + valueInUsd: toUsd(requestedInputAmount.toString(), input.token.decimals), + }, + manualRoutes: manualRoutes.map((route) => { + const { output } = route + const amount = scale(output.amount) + const effectiveAmount = output.effectiveAmount ? scale(output.effectiveAmount) : undefined + const minAmountOut = output.minAmountOut ? scale(output.minAmountOut) : undefined + // Preserves the fixture's own (small) effective-vs-gross fee ratio rather than assuming one. + const feeRatio = + output.effectiveReceivedInUsd && output.effectiveValueInUsd + ? output.effectiveReceivedInUsd / output.effectiveValueInUsd + : 1 + const effectiveValueInUsd = effectiveAmount ? toUsd(effectiveAmount, output.token.decimals) : undefined + return { + ...route, + output: { + ...output, + amount, + valueInUsd: toUsd(amount, output.token.decimals), + ...(effectiveAmount ? { effectiveAmount } : {}), + ...(effectiveValueInUsd !== undefined ? { effectiveValueInUsd } : {}), + ...(minAmountOut ? { minAmountOut } : {}), + ...(effectiveValueInUsd !== undefined ? { effectiveReceivedInUsd: effectiveValueInUsd * feeRatio } : {}), + }, + } + }), + }, + } +} + +// `Number(amount)` on a raw base-unit string stays inside Number.MAX_SAFE_INTEGER for the +// committed 6-decimal USDC fixture — an 18-decimal fixture's scaled amount could exceed it and +// silently lose precision here. +function toUsd(amount: string, decimals: number): number { + return Number(amount) / 10 ** decimals +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts b/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts index 6315984b754..f9d7d6456f0 100644 --- a/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts +++ b/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts @@ -24,7 +24,7 @@ const FAKE_GAS_ESTIMATE = '0x7a120' as const * 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. + * (like `mocks/socketVerifier.ts`) 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) => { diff --git a/apps/cowswap-e2e-tests/src/mocks/multicall3.ts b/apps/cowswap-e2e-tests/src/mocks/multicall3.ts index 109f1243e01..7f3cc6e41ee 100644 --- a/apps/cowswap-e2e-tests/src/mocks/multicall3.ts +++ b/apps/cowswap-e2e-tests/src/mocks/multicall3.ts @@ -75,13 +75,10 @@ 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()`. + * RPC client (see 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, ...). * * 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, @@ -95,9 +92,10 @@ const ZERO: ZeroCall = { kind: 'zero' } * 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. + * resolves those locally. Anything it doesn't own inside the batch gets a safe empty success slot + * instead of a real network round-trip — except Bungee's SocketVerifier selectors, which + * `mocks/socketVerifier.ts` (registered after this mock, so it gets first look) already resolves + * before a matching request ever reaches here. */ export function installMulticall3(context: BrowserContext, deps: { allowances: AllowancesMock }): void { const configuredChainIdByUrl = resolveRpcChainIds() @@ -219,7 +217,7 @@ function encodeBatchResult(call: BatchCall, chainId: number, allowances: Allowan /** * 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`, + * try/catch as every other host-agnostic mock in this suite (`installSocketVerifier`, * `installEthBlockNumber`, `installEthGetCode`), patching only the recognized slots and forwarding * the rest of the real response untouched. */ diff --git a/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts new file mode 100644 index 00000000000..8924f28421c --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts @@ -0,0 +1,55 @@ +import { loadFixture } from './bridge/loadFixture' + +import type { BrowserContext, Route } from '@playwright/test' + +// The 1click SDK's `OpenAPI.BASE` — see `NearIntentsBridgeProvider` in `@cowprotocol/sdk-bridging`. +const NEAR_INTENTS_URL_PATTERN = /^https:\/\/1click\.chaindefuser\.com\/v0\//i + +export interface NearIntentsMock { + reset(): void +} + +export function installNearIntents(context: BrowserContext): NearIntentsMock { + const tokensFixture = loadFixture('near-dest-tokens.json') + // `quote` and `attestation` are served byte-for-byte and paired: the SDK recovers a + // signer address from `attestation.signature` over a hash of the *exact* quote fields + // (`hashQuote({ quote, quoteRequest, timestamp })` in `@cowprotocol/sdk-bridging`) and rejects + // the quote unless that recovered address matches Near's hardcoded attestor address. Both + // fixtures were captured together from the real API — changing either one independently + // (including the quote's numeric fields) invalidates the signature and breaks every test that + // reaches this quote. + const quoteFixture = loadFixture('near-quote.json') + const attestationFixture = loadFixture('near-attestation.json') + + void context.route(NEAR_INTENTS_URL_PATTERN, async (route: Route) => { + const pathname = new URL(route.request().url()).pathname + + if (pathname.endsWith('/tokens')) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(tokensFixture) }) + return + } + if (pathname.endsWith('/quote')) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(quoteFixture) }) + return + } + if (pathname.endsWith('/attestation')) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(attestationFixture) }) + return + } + if (pathname.endsWith('/status')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'SUCCESS', quoteResponse: quoteFixture }), + }) + return + } + await route.fallback() + }) + + return { + reset() { + // Fixtures are served as-is for every test — nothing mutable to reset yet. + }, + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts new file mode 100644 index 00000000000..d600bf0c4cb --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts @@ -0,0 +1,286 @@ +import { strict as assert } from 'node:assert' +import { beforeEach, test } from 'node:test' + +import { installCowProtocolApi } from '../cowProtocolApi' + +import { generateOrderId, installOrdersMock } from './index' + +import type { BalancesMock } from '../balances' +import type { CowProtocolApiMock } from '../cowProtocolApi' +import type { OrdersMock } from './index' +import type { MockEthFlowTransactionHandle } from '../../support/mockEthFlowTransaction' +import type { BrowserContext, Route } from '@playwright/test' + +const OWNER = `0x${'1'.repeat(40)}` + +function createStubRoute(url: string, method: string, postData?: unknown): Route { + const request = { + url: () => url, + method: () => method, + postDataJSON: () => { + if (postData === undefined) throw new Error('no post data on this stub request') + return postData + }, + } + let fulfilled: { status: number; body: string } | undefined + return { + request: () => request, + fulfill: (opts: { status: number; body: string }) => { + fulfilled = opts + return Promise.resolve() + }, + abort: () => Promise.resolve(), + fallback: () => Promise.resolve(), + get fulfilled() { + return fulfilled + }, + } as unknown as Route +} + +let cowApi: CowProtocolApiMock +let orders: OrdersMock +let capturedHandler: (route: Route) => Promise + +beforeEach(async () => { + const context = { + route: (_pattern: unknown, handlerFn: (route: Route) => Promise) => { + capturedHandler = handlerFn + return Promise.resolve() + }, + } as unknown as BrowserContext + cowApi = await installCowProtocolApi(context) + orders = installOrdersMock(cowApi) +}) + +function orderByUidRoute(uid: string): Route { + return createStubRoute(`https://api.cow.fi/mainnet/api/v1/orders/${uid}`, 'GET') +} + +function postOrderRoute(body: unknown): Route { + return createStubRoute('https://api.cow.fi/mainnet/api/v1/orders', 'POST', body) +} + +test('expectOrderToBePosted forces the postOrder response to the given orderId', async () => { + const orderId = generateOrderId() + const body = { sellToken: '0xaaa', buyToken: '0xbbb', sellAmount: '100', buyAmount: '200', receiver: OWNER } + + await orders.expectOrderToBePosted({ + orderId, + owner: OWNER, + trigger: async () => { + await capturedHandler(postOrderRoute(body)) + }, + }) + + const order = orders.getOrder(orderId) + assert.equal(order?.uid, orderId) + assert.equal(order?.sellAmount, '100') + assert.equal(order?.status, 'open') +}) + +test('expectOrderToBePosted throws when trigger never posts', async () => { + const orderId = generateOrderId() + // `timeoutMs` is a test-only escape hatch (default 10_000 in production) — without it this + // negative case would burn 10 real seconds every run. + await assert.rejects( + orders.expectOrderToBePosted({ orderId, owner: OWNER, trigger: async () => {}, timeoutMs: 50 }), + /no postOrder request observed/, + ) +}) + +test('order-by-uid dispatches to the matching registry entry, not "the last posted order"', async () => { + const firstId = generateOrderId() + const secondId = generateOrderId() + const firstBody = { sellToken: '0xaaa', buyToken: '0xbbb', sellAmount: '100', buyAmount: '200', receiver: OWNER } + const secondBody = { sellToken: '0xccc', buyToken: '0xddd', sellAmount: '9', buyAmount: '9', receiver: OWNER } + + await orders.expectOrderToBePosted({ + orderId: firstId, + owner: OWNER, + trigger: async () => capturedHandler(postOrderRoute(firstBody)), + }) + await orders.expectOrderToBePosted({ + orderId: secondId, + owner: OWNER, + trigger: async () => capturedHandler(postOrderRoute(secondBody)), + }) + + await capturedHandler(orderByUidRoute(firstId)) + await capturedHandler(orderByUidRoute(secondId)) + + assert.equal(orders.getOrder(firstId)?.sellAmount, '100') + assert.equal(orders.getOrder(secondId)?.sellAmount, '9') +}) + +test('fulfillOrder debits sell, credits buy, and flips status/orderStatus', async () => { + const orderId = generateOrderId() + const body = { sellToken: '0xaaa', buyToken: '0xbbb', sellAmount: '100', buyAmount: '200', receiver: OWNER } + await orders.expectOrderToBePosted({ + orderId, + owner: OWNER, + trigger: async () => capturedHandler(postOrderRoute(body)), + }) + + const sets: Array<[string, number, Record]> = [] + const balances = { + set: (owner: string, chainId: number, b: Record) => sets.push([owner, chainId, b]), + } as unknown as BalancesMock + + orders.fulfillOrder(orderId, balances, 1, 1000n, 0n) + + assert.deepEqual(sets, [[OWNER, 1, { '0xaaa': '900', '0xbbb': '200' }]]) + assert.equal(orders.getOrder(orderId)?.status, 'fulfilled') +}) + +test('fulfillOrder throws for an unknown orderId', () => { + const balances = { set: () => {} } as unknown as BalancesMock + assert.throws(() => orders.fulfillOrder(generateOrderId(), balances, 1, 0n, 0n), /unknown orderId/) +}) + +test('reset() clears the registry', async () => { + const orderId = generateOrderId() + const body = { sellToken: '0xaaa', buyToken: '0xbbb', sellAmount: '1', buyAmount: '1', receiver: OWNER } + await orders.expectOrderToBePosted({ + orderId, + owner: OWNER, + trigger: async () => capturedHandler(postOrderRoute(body)), + }) + orders.reset() + assert.equal(orders.getOrder(orderId), undefined) +}) + +test('seedOpenOrder registers a cancellable order without any postOrder call', () => { + const orderId = generateOrderId() + orders.seedOpenOrder({ + orderId, + owner: OWNER, + sellToken: '0xaaa', + buyToken: '0xbbb', + sellAmount: 1_000_000n, + buyAmount: 2_000_000n, + }) + + const order = orders.getOrder(orderId) + assert.equal(order?.uid, orderId) + assert.equal(order?.sellAmount, '1000000') + assert.equal(order?.invalidated, false) + assert.equal(orders.wasCancelRequested(orderId), false) +}) + +test('accountOrders answers with only the seeded order, dropping the default fixture list', async () => { + const orderId = generateOrderId() + orders.seedOpenOrder({ + orderId, + owner: OWNER, + sellToken: '0xaaa', + buyToken: '0xbbb', + sellAmount: 1n, + buyAmount: 1n, + }) + + const route = createStubRoute(`https://api.cow.fi/mainnet/api/v1/account/${OWNER}/orders`, 'GET') + await capturedHandler(route) + const body = JSON.parse((route as unknown as { fulfilled?: { body: string } }).fulfilled?.body ?? '[]') as Array<{ + uid: string + }> + assert.equal(body.length, 1) + assert.equal(body[0]?.uid, orderId) +}) + +test('cancelOrders sets wasCancelRequested only for the named uid', async () => { + const cancelledId = generateOrderId() + const otherId = generateOrderId() + orders.seedOpenOrder({ + orderId: cancelledId, + owner: OWNER, + sellToken: '0xa', + buyToken: '0xb', + sellAmount: 1n, + buyAmount: 1n, + }) + orders.seedOpenOrder({ + orderId: otherId, + owner: OWNER, + sellToken: '0xa', + buyToken: '0xb', + sellAmount: 1n, + buyAmount: 1n, + }) + + await capturedHandler( + createStubRoute('https://api.cow.fi/mainnet/api/v1/orders', 'DELETE', { orderUids: [cancelledId] }), + ) + + assert.equal(orders.wasCancelRequested(cancelledId), true) + assert.equal(orders.wasCancelRequested(otherId), false) +}) + +test('markCancelled sets invalidated on the seeded order', () => { + const orderId = generateOrderId() + orders.seedOpenOrder({ orderId, owner: OWNER, sellToken: '0xa', buyToken: '0xb', sellAmount: 1n, buyAmount: 1n }) + orders.markCancelled(orderId) + assert.equal(orders.getOrder(orderId)?.invalidated, true) +}) + +test('wasCancelRequested and markCancelled throw for an unknown orderId', () => { + assert.throws(() => orders.wasCancelRequested(generateOrderId()), /unknown orderId/) + assert.throws(() => orders.markCancelled(generateOrderId()), /unknown orderId/) +}) + +function fakeEthFlow( + params: { sellAmount: bigint; buyAmount: bigint; buyToken: string }, + filled = false, +): MockEthFlowTransactionHandle { + return { + getOrderParams: () => params, + isFilled: () => filled, + } as unknown as import('../../support/mockEthFlowTransaction').MockEthFlowTransactionHandle +} + +test('trackEthFlowOrder 404s any uid until markIndexed is called', async () => { + const tracker = orders.trackEthFlowOrder(fakeEthFlow({ sellAmount: 1n, buyAmount: 2n, buyToken: '0xbbb' })) + const route = orderByUidRoute(generateOrderId()) + await capturedHandler(route) + assert.equal((route as unknown as { fulfilled?: { status: number } }).fulfilled?.status, 404) + + tracker.markIndexed() + const route2 = orderByUidRoute(generateOrderId()) + await capturedHandler(route2) + assert.equal((route2 as unknown as { fulfilled?: { status: number } }).fulfilled?.status, 200) +}) + +test('trackEthFlowOrder reports fields from ethFlow.getOrderParams(), reflecting isFilled() live', async () => { + let filled = false + const ethFlow = { + getOrderParams: () => ({ sellAmount: 5n, buyAmount: 9n, buyToken: '0xbbb' }), + isFilled: () => filled, + } as unknown as import('../../support/mockEthFlowTransaction').MockEthFlowTransactionHandle + + const tracker = orders.trackEthFlowOrder(ethFlow) + tracker.markIndexed() + + const route = orderByUidRoute(generateOrderId()) + await capturedHandler(route) + let body = JSON.parse((route as unknown as { fulfilled?: { body: string } }).fulfilled?.body ?? '{}') as { + status: string + } + assert.equal(body.status, 'open') + + filled = true + const route2 = orderByUidRoute(generateOrderId()) + await capturedHandler(route2) + body = JSON.parse((route2 as unknown as { fulfilled?: { body: string } }).fulfilled?.body ?? '{}') as { + status: string + } + assert.equal(body.status, 'fulfilled') +}) + +test('reset() clears the eth-flow tracker too', async () => { + orders.trackEthFlowOrder(fakeEthFlow({ sellAmount: 1n, buyAmount: 1n, buyToken: '0xbbb' })).markIndexed() + orders.reset() + + const route = orderByUidRoute(generateOrderId()) + await capturedHandler(route) + // No tracker and no registry entry left — falls through to the default fixture (200, not 404). + assert.equal((route as unknown as { fulfilled?: { status: number } }).fulfilled?.status, 200) +}) diff --git a/apps/cowswap-e2e-tests/src/mocks/orders/index.ts b/apps/cowswap-e2e-tests/src/mocks/orders/index.ts new file mode 100644 index 00000000000..6aac1eaa3ca --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.ts @@ -0,0 +1,351 @@ +import { OrderStatus } from '@cowprotocol/sdk-order-book' +import type { Order, OrderCreation } from '@cowprotocol/sdk-order-book' + +import { randomBytes } from 'node:crypto' + +import { reply } from '../cowProtocolApi' + +import type { MockEthFlowTransactionHandle } from '../../support/mockEthFlowTransaction' +import type { BalancesMock } from '../balances' +import type { CowProtocolApiMock } from '../cowProtocolApi' + +export interface EthFlowOrderTracker { + /** Lets the `order`-by-uid poll start succeeding — flips the order from `creating` to `pending`/`open`. */ + markIndexed(): void +} + +export interface OrdersMock { + /** Forces the next `postOrder` response to `orderId`, runs `trigger`, and waits for that request to land. */ + expectOrderToBePosted(opts: { + orderId: OrderUid + owner: string + trigger: () => Promise + /** Overrides the 10s default — a test-only escape hatch for the negative-case unit test. */ + timeoutMs?: number + }): Promise + /** Debits sell / credits buy on `balances`, and flips the order to `fulfilled` (`order`, `orderStatus`). */ + fulfillOrder( + orderId: OrderUid, + balances: BalancesMock, + chainId: number, + sellTokenBalanceBefore: bigint, + buyTokenBalanceBefore: bigint, + ): void + /** Advances `orderStatus` to the `executing` competition stage, without settling anything. */ + markExecuting(orderId: OrderUid): void + /** Seeds a fake "open" order directly, without ever posting one through the UI. */ + seedOpenOrder(opts: SeedOpenOrderOpts): void + /** True once `DELETE /api/v1/orders` (`cancelOrders`) named this uid. */ + wasCancelRequested(orderId: OrderUid): boolean + /** Marks the order invalidated on the backend — starts the "Cancelling..." → "Cancelled" transition. */ + markCancelled(orderId: OrderUid): void + /** Wires the `order` endpoint for the eth-flow trade currently in flight (there's no `postOrder` call to hook for that flow, and no client-known uid up front). */ + trackEthFlowOrder(ethFlow: MockEthFlowTransactionHandle): EthFlowOrderTracker + getOrder(orderId: OrderUid): Order | undefined + reset(): void +} + +export type OrderUid = string + +export interface SeedOpenOrderOpts { + orderId: OrderUid + owner: string + sellToken: string + buyToken: string + sellAmount: bigint + buyAmount: bigint + /** Seconds to backdate `creationDate` by — see `PENDING_ORDERS_BUFFER` note on `markCancelled`. */ + createdSecondsAgo?: number +} + +interface RegistryEntry { + owner: string + body: OrderCreation | null + order: Order | null + stage: Stage + cancelRequested: boolean + includeInAccountOrders: boolean + /** `accountOrders` answers with only this order, dropping the default fixture list — see `seedOpenOrder`. */ + soleAccountOrder: boolean +} + +type Stage = 'open' | 'executing' | 'fulfilled' + +const DEFAULT_TIMEOUT_MS = 10_000 + +interface State { + registry: Map + ethFlowTracker: { ethFlow: MockEthFlowTransactionHandle; indexed: boolean } | null +} + +/** A random, valid-shaped 56-byte order uid, independent of any order body. */ +export function generateOrderId(): OrderUid { + return `0x${randomBytes(56).toString('hex')}` +} + +// eslint-disable-next-line max-lines-per-function +export function installOrdersMock(cowApi: CowProtocolApiMock): OrdersMock { + const state: State = { registry: new Map(), ethFlowTracker: null } + + setupOrderHandlers(state, cowApi) + + return { + async expectOrderToBePosted({ orderId, owner, trigger, timeoutMs = DEFAULT_TIMEOUT_MS }) { + let arrived: () => void = () => {} + const posted = new Promise((resolve) => { + arrived = resolve + }) + + cowApi.set('postOrder', (req) => { + const body = req.body as OrderCreation + state.registry.set(orderId, { + owner, + body, + order: buildOpenOrder(body, orderId, owner), + stage: 'open', + cancelRequested: false, + includeInAccountOrders: true, + soleAccountOrder: false, + }) + arrived() + return orderId + }) + + await withTimeout( + Promise.all([posted, trigger()]), + timeoutMs, + `expectOrderToBePosted: no postOrder request observed for ${orderId} within ${timeoutMs}ms`, + ) + }, + + fulfillOrder(orderId, balances, chainId, sellTokenBalanceBefore, buyTokenBalanceBefore) { + const entry = state.registry.get(orderId) + if (!entry?.body || !entry.order) { + throw new Error(`fulfillOrder: unknown orderId ${orderId} — was it posted or seeded first?`) + } + const body = entry.body + balances.set(entry.owner, chainId, { + [body.sellToken]: (sellTokenBalanceBefore - BigInt(body.sellAmount)).toString(), + [body.buyToken]: (buyTokenBalanceBefore + BigInt(body.buyAmount)).toString(), + }) + entry.order = { ...entry.order, ...buildFulfilledOrderPatch(body) } + entry.stage = 'fulfilled' + }, + + markExecuting(orderId) { + const entry = state.registry.get(orderId) + if (!entry) throw new Error(`markExecuting: unknown orderId ${orderId}`) + entry.stage = 'executing' + }, + + seedOpenOrder({ orderId, owner, sellToken, buyToken, sellAmount, buyAmount, createdSecondsAgo = 30 }) { + state.registry.set(orderId, { + owner, + body: null, + order: buildSeededOrder({ orderId, owner, sellToken, buyToken, sellAmount, buyAmount, createdSecondsAgo }), + stage: 'open', + cancelRequested: false, + includeInAccountOrders: true, + soleAccountOrder: true, + }) + }, + + wasCancelRequested(orderId) { + const entry = state.registry.get(orderId) + if (!entry) throw new Error(`wasCancelRequested: unknown orderId ${orderId}`) + return entry.cancelRequested + }, + + markCancelled(orderId) { + const entry = state.registry.get(orderId) + if (!entry?.order) throw new Error(`markCancelled: unknown orderId ${orderId}`) + entry.order = { ...entry.order, invalidated: true } + }, + trackEthFlowOrder(ethFlow) { + state.ethFlowTracker = { ethFlow, indexed: false } + return { + markIndexed: () => { + if (state.ethFlowTracker) state.ethFlowTracker.indexed = true + }, + } + }, + + getOrder(orderId) { + return state.registry.get(orderId)?.order ?? undefined + }, + + reset() { + state.registry.clear() + state.ethFlowTracker = null + }, + } +} + +/** Every amount/status field is read straight off `ethFlow`'s decoded `createOrder()` calldata (and + * its own `isFilled()` flag) rather than trusted from the UI. */ +function buildEthFlowOrder(ethFlow: MockEthFlowTransactionHandle, defaults: Record): unknown { + const orderParams = ethFlow.getOrderParams() + 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, + } +} + +/** The subset of 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 }, + }, + ], + } +} + +/** A fake "open" order seeded directly, without ever posting one through the UI. */ +function buildSeededOrder(opts: { + orderId: string + owner: string + sellToken: string + buyToken: string + sellAmount: bigint + buyAmount: bigint + createdSecondsAgo: number +}): Order { + const { orderId, owner, sellToken, buyToken, sellAmount, buyAmount, createdSecondsAgo } = opts + return { + creationDate: new Date(Date.now() - createdSecondsAgo * 1000).toISOString(), + owner, + uid: orderId, + availableBalance: null, + executedBuyAmount: '0', + executedSellAmount: '0', + executedSellAmountBeforeFees: '0', + executedFeeAmount: '0', + executedFee: '0', + executedFeeToken: sellToken, + invalidated: false, + 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: [] }, + } as Order +} + +function setupOrderHandlers(state: State, cowApi: CowProtocolApiMock): void { + cowApi.set('order', (req) => { + if (state.ethFlowTracker) { + if (!state.ethFlowTracker.indexed) return reply(404, { errorType: 'NotFound' }) + return buildEthFlowOrder(state.ethFlowTracker.ethFlow, req.defaults as Record) + } + const entry = state.registry.get(req.params.uid) + return entry?.order ?? req.defaults + }) + + cowApi.set('accountOrders', (req) => { + const entries = [...state.registry.values()].filter((entry) => entry.includeInAccountOrders && entry.order) + const mine = entries.map((entry) => entry.order as Order) + const excludeDefaults = entries.some((entry) => entry.soleAccountOrder) + return excludeDefaults ? mine : [...mine, ...(req.defaults as unknown[])] + }) + + cowApi.set('orderStatus', (req) => { + const entry = state.registry.get(req.params.uid) + if (!entry || entry.stage === 'open' || !entry.body) return req.defaults + return buildOrderStatus(entry.stage === 'fulfilled' ? 'traded' : 'executing', entry.body) + }) + + cowApi.set('cancelOrders', (req) => { + const body = req.body as { orderUids?: OrderUid[] } | undefined + for (const uid of body?.orderUids ?? []) { + const entry = state.registry.get(uid) + if (entry) entry.cancelRequested = true + } + return req.defaults + }) +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise +} diff --git a/apps/cowswap-e2e-tests/src/mocks/socketVerifier.ts b/apps/cowswap-e2e-tests/src/mocks/socketVerifier.ts new file mode 100644 index 00000000000..ba77dd91beb --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/socketVerifier.ts @@ -0,0 +1,232 @@ +import { decodeAbiParameters, encodeAbiParameters, type Hex, toFunctionSelector } from 'viem' + +import { areAddressesEqual } from '@cowprotocol/cow-sdk' + +import type { BrowserContext, Route } from '@playwright/test' + +const SOCKET_VERIFIER_ADDRESS = '0xa27a3f5a96df7d8be26ee2790999860c00eb688d' +// Both `nonpayable` with no outputs, called via `eth_call`; the SDK only checks the call doesn't +// revert (see `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`). Derived from the real +// signatures (note the SDK's own typo: `validateRotueId`, not `validateRouteId`) rather than +// hardcoded hex, so a signature change in the SDK surfaces as a diff here instead of silently +// going stale. +const STUBBED_SELECTORS = [ + toFunctionSelector('validateRotueId(bytes,uint32)'), + toFunctionSelector('validateSocketRequest(bytes,(uint32,(uint256,address,uint256,address,bytes4)))'), +] + +/** `aggregate3((address,bool,bytes)[])` on Multicall3 — the same selector `mocks/multicall3.ts` + * and `mocks/allowances/codec.ts` each derive independently; duplicated here too rather than + * imported so this mock stays a standalone, dependency-free unit like `ethBlockNumber.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 + +export interface BatchCall { + kind: 'batch' + calls: ClassifiedCall[] +} + +export type ClassifiedCall = BatchCall | OpaqueCall | StubbedCall + +export interface OpaqueCall { + kind: 'opaque' +} + +export interface StubbedCall { + kind: 'stubbed' +} + +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] + result?: unknown +} + +interface ResultSlot { + success: boolean + returnData: Hex +} + +const OPAQUE: OpaqueCall = { kind: 'opaque' } +const STUBBED: StubbedCall = { kind: 'stubbed' } + +/** + * Classifies one `eth_call` by its calldata: a match on `SOCKET_VERIFIER_ADDRESS` and one of + * `STUBBED_SELECTORS`, an `aggregate3` batch (recursed regardless of `to` — same rationale as + * `allowances/codec.ts`'s `classifyCall`: calldata that decodes as `aggregate3` is a batch + * whatever it's addressed to), or opaque. + */ +export function classifyCall(to: string, data: string): ClassifiedCall { + const selector = data.slice(0, 10).toLowerCase() + if (areAddressesEqual(to, SOCKET_VERIFIER_ADDRESS) && STUBBED_SELECTORS.includes(selector as Hex)) return STUBBED + if (selector === AGGREGATE3_SELECTOR) return decodeBatch(data) + return OPAQUE +} + +/** Encodes the result for a call already known to be fully stubbed (`isFullyStubbed` true) — a + * bare stub resolves to empty `returnData` (both stubbed functions are `nonpayable` with no + * outputs; the SDK only checks the call doesn't revert), a batch nests one such result per child, + * all `success: true`. */ +export function encodeResult(call: ClassifiedCall): Hex { + if (call.kind !== 'batch') return '0x' + const slots: ResultSlot[] = call.calls.map((inner) => ({ success: true, returnData: encodeResult(inner) })) + return encodeAbiParameters(RESULT_TUPLE, [slots]) +} + +/** + * Bungee's on-chain SocketVerifier check (`validateRotueId`/`validateSocketRequest`, + * `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`). The app's own independent read-only + * client can issue this as a real page-level `eth_call`, batched inside a Multicall3 + * `aggregate3`, on whatever real RPC host it picks for the connected chain — e.g. + * `https://ethereum-rpc.publicnode.com` for Mainnet, the same host `REACT_APP_NETWORK_URL_1` + * configures. Neither `mocks/allowances` (which owns that configured host) nor + * `mocks/multicall3.ts` (which deliberately defers on any host `mocks/allowances` owns) has any + * notion of these selectors, so without this mock the call forwards untouched to the real host — + * a real, rate-limited dependency, same class of gap `installMulticall3`'s own doc comment + * describes for unrecognized Multicall3 traffic in general. See `AGENTS.md`'s cross-chain + * bridging section for this check's history — it also used to reach the network through the + * connected wallet's own provider, a case this mock's page-network-layer `context.route()` can't + * see at all, stubbed separately at the time; that stub was later deleted once this mock alone + * proved sufficient. + * + * Host-agnostic and registered ahead of `installMulticall3`/`installAllowances` in the `mocks` + * fixture (last registered wins in Playwright's LIFO route order), so it always gets first look: + * it resolves any matching call locally — never touching the network — and falls back untouched + * otherwise, the same shape as `ethBlockNumber.ts`/`ethGetCode.ts`. + */ +export function installSocketVerifier(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 = 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 classifyCall(call.to, call.data) + }) + + if (classified.every((call) => call.kind === 'opaque')) return route.fallback() + + if (classified.every(isFullyStubbed)) { + const payload = entries.map((entry, index) => ({ + jsonrpc: '2.0', + id: entry.id, + result: encodeResult(classified[index]), + })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries, classified) + }) +} + +export function isFullyStubbed(call: ClassifiedCall): boolean { + if (call.kind === 'stubbed') return true + if (call.kind === 'opaque') return false + return call.calls.every(isFullyStubbed) +} + +function decodeBatch(data: string): 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) => classifyCall(c.target, c.callData)), + } + } catch { + return OPAQUE + } +} + +function decodeResultSlots(blob: Hex): ResultSlot[] { + try { + return [...(decodeAbiParameters(RESULT_TUPLE, blob)[0] as ReadonlyArray)] + } catch { + // An upstream error body or a truncated blob must not lose the stubbed slots. + return [] + } +} + +/** + * A mixed batch alongside something this mock doesn't own (rare — the real capture this mock is + * modeled on arrived as a single, unbatched SocketVerifier call) — same defensive try/catch as + * every other host-agnostic mock in this suite (`installMulticall3`, `installEthBlockNumber`), + * patching only the recognized slots and forwarding the rest of the real response untouched. + */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[], classified: ClassifiedCall[]): 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 id = (entry as JsonRpcEntry).id + const call = classifiedById.get(id) + if (!call || call.kind === 'opaque') return entry + + const upstreamResult = typeof entry.result === 'string' ? (entry.result as Hex) : undefined + return { jsonrpc: '2.0', id, result: patchResult(call, upstreamResult) } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} + +/** Like `encodeResult`, but for a call that may be only partially stubbed: an unmocked slot keeps + * whatever the real upstream returned for that position instead of a safe empty success — this + * mock only ever answers for the exact calls it recognizes. */ +function patchResult(call: ClassifiedCall, upstream?: Hex): Hex { + if (call.kind !== 'batch') return '0x' + + 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 === 'opaque') return fallback + const nestedUpstream = inner.kind === 'batch' && fallback.success ? fallback.returnData : undefined + return { success: true, returnData: patchResult(inner, nestedUpstream) } + }) + + return encodeAbiParameters(RESULT_TUPLE, [slots]) +} diff --git a/apps/cowswap-e2e-tests/src/mocks/tokenLists.ts b/apps/cowswap-e2e-tests/src/mocks/tokenLists.ts deleted file mode 100644 index 4c0e7c010fe..00000000000 --- a/apps/cowswap-e2e-tests/src/mocks/tokenLists.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { BrowserContext, Route } from '@playwright/test' - -export interface TokenListsMock { - setListForChain( - chainId: number, - list: { - tokens: Array<{ - address: string - symbol: string - name: string - decimals: number - chainId: number - logoURI?: string - }> - }, - ): void - reset(): void -} - -const EMPTY_LIST = { - name: 'e2e-pw stub', - timestamp: new Date().toISOString(), - version: { major: 1, minor: 0, patch: 0 }, - tokens: [], -} - -export function installTokenLists(context: BrowserContext): TokenListsMock { - const byChain = new Map() - - void context.route(/tokens.*\.json$/i, async (route: Route) => { - const url = new URL(route.request().url()) - const chainMatch = url.pathname.match(/(\d+)/) - const chainId = chainMatch ? Number.parseInt(chainMatch[1], 10) : 0 - const list = byChain.get(chainId) ?? EMPTY_LIST - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(list) }) - }) - - return { - setListForChain(chainId, list) { - byChain.set(chainId, { - name: `e2e-pw chain ${chainId}`, - timestamp: new Date().toISOString(), - version: { major: 1, minor: 0, patch: 0 }, - tokens: list.tokens, - }) - }, - reset() { - byChain.clear() - }, - } -} diff --git a/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts b/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts new file mode 100644 index 00000000000..3271e745a51 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts @@ -0,0 +1,73 @@ +import { encodeAbiParameters } from 'viem' + +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] +} + +/** `nonces(address)` — EIP-2612's permit nonce. */ +const NONCES_SELECTOR = '0x7ecebe00' +// No test in this suite asserts on the real on-chain nonce, only that one is present — a fixed +// value removes the real dependency entirely, same rationale as `installEthBlockNumber`. +const NONCE_RESULT = encodeAbiParameters([{ type: 'uint256' }], [1n]) + +/** + * `eip2612Utils.getTokenNonce` reads a token's EIP-2612 permit nonce via a plain `eth_call` to + * `nonces(address)`, routed through the app's own read-only `publicClient` — a real page network + * request, but to whichever real RPC/Infura host that client picked, not a URL this suite + * controls. Matched by selector alone, host-agnostically, same technique as + * `mockApproveSimulation.ts` uses for `approve()`: the nonce is faked to the same constant + * regardless of which token or owner it's queried for. + */ +export function installTokenNonce(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 { + 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(isNonceCall) + if (!matches.some(Boolean)) return route.fallback() + + if (matches.every(Boolean)) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: NONCE_RESULT })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + return fulfillFromUpstream(route, entries, matches) + }) +} + +/** Same merge-with-upstream technique as `mockApproveSimulation.ts`. */ +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: NONCE_RESULT } : entry, + ) + await route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + } catch { + await route.fallback() + } +} + +/** Matches any `eth_call` whose calldata is a `nonces(address)` invocation, regardless of `to`. */ +function isNonceCall(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(NONCES_SELECTOR) +} diff --git a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts new file mode 100644 index 00000000000..59b307203cb --- /dev/null +++ b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts @@ -0,0 +1,120 @@ +import type { Page, Locator } from '@playwright/test' + +/** + * The "Route" breakdown shown under the swap form once a cross-chain quote loads + * (`SwapRateDetails` → `TradeRateDetails` → `QuoteDetails`, rendering a swap leg then a bridge + * leg). None of it carries `data-testid`/`id` at the row level — only i18n text — and several + * labels repeat once per leg with no container id to key off, so rows here are matched by text + * and disambiguated by DOM order (swap leg renders before bridge leg). + */ +export class BridgeRoutePanel { + private readonly page: Page + /** `TradeDetailsAccordion`'s `SummaryClickable` — the only stable (non-text) hook here. */ + readonly expandToggle: Locator + readonly bridgeQuoteDetails: Locator + readonly swapStopTitle: Locator + /** `ProxyAccountBanner` — "Swap bridged via your Account Proxy: 0x..." (Bungee/Across). */ + readonly accountProxyBanner: Locator + /** Same banner, Near Intents' "recipient overridden to the deposit address" variant (CC-17). */ + readonly modifiedRecipientBanner: Locator + + constructor(page: Page) { + this.page = page + // Scoped by class substring (babel-plugin-styled-components names it after its own export, + // `SummaryClickable`), not just `[aria-expanded]` — the app header's nav dropdown also renders + // `aria-expanded`, and an unscoped `.first()` would resolve to whichever renders first in DOM + // order. + this.expandToggle = page.locator('.trade-details-accordion-toggle').first() + this.bridgeQuoteDetails = page.locator('.collapsible-bridge-route').first() + // Not an exact match: `BridgeRouteTitle` renders "Swap on" and "CoW Protocol" either side of a + // protocol icon, which can add whitespace/alt text into the element's normalized text content. + this.swapStopTitle = page.getByText(/Swap on.*CoW Protocol/) + this.accountProxyBanner = page.getByText(/^Swap bridged via your/) + this.modifiedRecipientBanner = page.getByText(/^Modified recipient address to/) + } + + bridgeStopTitle(providerName: 'Bungee' | 'Near Intents'): Locator { + return this.page.getByText(new RegExp(`Bridge via.*${providerName}`)) + } + + /** + * The toggle click occasionally doesn't register (e.g. a re-render swaps the element under the + * pointer mid-click), leaving the panel collapsed. Retrying the click up to 3 times is more + * reliable than firing it once and hoping it stuck. + */ + async expand(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + if (await this.bridgeQuoteDetails.isVisible()) return + await this.expandToggle.click() + if (await this.bridgeQuoteDetails.isVisible()) return + await this.page.waitForTimeout(500) + } + } + + /** + * The label's sibling `Content` cell (`ConfirmDetailsItem`'s `Row > Label, Content`). + * + * `getByText` resolves to the innermost element whose full text matches — for a label like + * "Expected to receive" that's the `Label` span itself (a tooltip icon after it contributes no + * text), but for one wrapped in an inner tag with nothing else inside (e.g. `Min. to receive`'s + * `` in `ReceiveAmountTitle`) it's that inner tag instead, which has no useful sibling of its + * own. Walling up to the nearest `styled__Label-*` ancestor first — babel-plugin-styled- + * components names every `styled.xxx` export after its variable, so any component's own + * `Label` export produces this same class prefix — lands on `Content`'s actual sibling either way. + */ + private detailContent(label: string | RegExp, occurrence = 0): Locator { + const labelLocator = + typeof label === 'string' ? this.page.getByText(label, { exact: true }) : this.page.getByText(label) + return labelLocator + .nth(occurrence) + .locator('xpath=ancestor-or-self::*[contains(concat(" ", @class, " "), "__Label-")][1]') + .locator('xpath=following-sibling::*[1]') + } + + /** Reads a `TokenAmountDisplay` cell's exact value off its inner `[title]` (`LibTokenAmount`). */ + private amountValue(label: string, occurrence = 0): Locator { + return this.detailContent(label, occurrence).locator('[title]').first() + } + + // Swap leg (stop 1) + /** `ProtocolFeeRow`'s "Protocol fee (X%)" when nonzero, `FreeFeeRow`'s plain "Fee" when free. */ + swapFee(): Locator { + return this.detailContent(/^(Protocol fee|Fee)/) + } + swapNetworkCosts(): Locator { + return this.detailContent('Network costs (est.)') + } + swapExpectedToReceive(): Locator { + return this.amountValue('Expected to receive', 0) + } + swapMinToReceive(): Locator { + return this.amountValue('Min. to receive', 0) + } + swapRecipient(): Locator { + return this.detailContent('Recipient', 0) + } + /** Not an exact match: the label also carries the verification badge/tooltip after the text. */ + swapQuoteId(): Locator { + return this.detailContent(/^Quote ID/) + } + + // Bridge leg (stop 2) + bridgeEstTime(): Locator { + return this.detailContent('Est. bridge time') + } + bridgeCosts(): Locator { + return this.detailContent('Bridge costs') + } + bridgeExpectedToReceive(): Locator { + return this.amountValue('Expected to receive', 1) + } + bridgeMinToDeposit(): Locator { + return this.amountValue('Min. to deposit', 0) + } + bridgeRecipient(): Locator { + return this.detailContent('Recipient', 1) + } + bridgeMinToReceive(): Locator { + return this.amountValue('Min. to receive', 1) + } +} diff --git a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts index fe761ed0db9..44311235df5 100644 --- a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test' +import { BridgeRoutePanel } from './BridgeRoutePanel' import { TokenSelector } from './TokenSelector' import type { TradePage } from './TradePage' @@ -38,6 +39,7 @@ export class SwapPage implements TradePage { readonly receiveAmountLabel: Locator readonly receiveAmountTooltipTrigger: Locator readonly receiveAmountValue: Locator + readonly routePanel: BridgeRoutePanel /** `AddressInputPanel`'s wrapping `ReceiverPanel` — `id="recipient"` set by `SetRecipient`. */ readonly recipientPanel: Locator /** `AddressInputPanel.tsx`'s default className on the `` itself. */ @@ -84,10 +86,17 @@ export class SwapPage implements TradePage { // 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. + // "Receive (incl. fees)" label and the `HelpTooltip` icon next to it are the label's next + // sibling. That sibling is `HelpTooltip`'s outer `HelpTooltipContainer` span, one level above + // the real `HoverTooltip` hitbox div (same quirk as `priceImpactTooltipTrigger` above, but + // nested one div deeper here: `ReferenceElement` div > listener div > icon-wrapper div) — + // `div div` matches both the listener div and the icon-wrapper div nested inside it, so take + // the first (outermost, document-order-first) match to land on the listener div itself. this.receiveAmountLabel = page.getByText('Receive (incl. fees)', { exact: true }) - this.receiveAmountTooltipTrigger = this.receiveAmountLabel.locator('xpath=following-sibling::*[1]') + this.receiveAmountTooltipTrigger = this.receiveAmountLabel + .locator('xpath=following-sibling::*[1]') + .locator('div div') + .first() // 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() @@ -100,6 +109,7 @@ export class SwapPage implements TradePage { this.unlockButton = page.locator('#unlock-cross-chain-swap-btn') this.orderProgressBarModal = page.locator('#order-progress-bar-modal') this.tokens = new TokenSelector(page) + this.routePanel = new BridgeRoutePanel(page) this.recipientPanel = page.locator('#recipient') this.recipientInput = page.locator('input.recipient-address-input') this.recipientPasteButton = this.recipientPanel.getByText('Paste', { exact: true }) diff --git a/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts b/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts index e0580f2454d..489eb4f1de6 100644 --- a/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts +++ b/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts @@ -27,7 +27,7 @@ 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 + * (`mocks/socketVerifier.ts`, `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. diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts index 1f93d65e8d1..d7ef435be69 100644 --- a/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts +++ b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts @@ -15,7 +15,8 @@ interface JsonRpcEntry { * `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 + * URL this suite controls, so it needs the same host-agnostic matching `mocks/socketVerifier.ts` + * uses. 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. diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts index 6284ad073d4..fce24283468 100644 --- a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts +++ b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts @@ -55,7 +55,7 @@ interface ReceiptContext { * 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`) + * parallel load — so it's matched host-agnostically by `to`/`data` (like `mocks/socketVerifier.ts`) * and answered with a successful ABI-encoded `true`, same as the real call would return. */ export async function mockApproveTransaction(opts: MockApproveTransactionOpts): Promise { @@ -162,7 +162,7 @@ function buildReceiptRpcResponse( * 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 + * try/catch → `route.fallback()` guard as `mocks/socketVerifier.ts`'s `fulfillFromUpstream`, so a * transient real-RPC hiccup here can't abort the whole request. */ async function fulfillApproveSimulationFromUpstream( @@ -186,10 +186,10 @@ async function fulfillApproveSimulationFromUpstream( /** * 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. + * `mockApproveTransaction`) with a successful `true`, host-agnostically. Unlike + * `mocks/socketVerifier.ts`'s SocketVerifier check, 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() diff --git a/apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts b/apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts deleted file mode 100644 index 965450f44bf..00000000000 --- a/apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 069427117a9..00000000000 --- a/apps/cowswap-e2e-tests/src/support/mockEthFlowOrderIndexing.ts +++ /dev/null @@ -1,53 +0,0 @@ -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/mockOrderPosting.ts b/apps/cowswap-e2e-tests/src/support/mockOrderPosting.ts deleted file mode 100644 index e33098f3795..00000000000 --- a/apps/cowswap-e2e-tests/src/support/mockOrderPosting.ts +++ /dev/null @@ -1,156 +0,0 @@ -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/tests/cross-chain-swaps.spec.ts b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts new file mode 100644 index 00000000000..1e8b63e330a --- /dev/null +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -0,0 +1,603 @@ +import { parseUnits, type Hex } from 'viem' + +import { areAddressesEqual } from '@cowprotocol/cow-sdk' + +import { test, expect } from '../fixtures' +import { reply } from '../mocks/cowProtocolApi' +import { generateOrderId } from '../mocks/orders' +import { CHAIN_IDS } from '../support/constants' +import { mockEthFlowTransaction } from '../support/mockEthFlowTransaction' +import { mockFixedRateQuote } from '../support/mockFixedRateQuote' +import { seedTrader } from '../support/seedTrader' + +import type { RpcProxyHandle } from '../fixtures/rpcProxy' +import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' +import type { LaunchDarklyMock } from '../mocks/launchDarkly' +import type { SwapPage } from '../pages/SwapPage' + +/** + * Scope notes (see cross-chain-swaps.specs.md for the full scenarios): + * + * - Bungee and Near Intents are mocked against their real APIs (`backend.bungee.exchange`, + * `1click.chaindefuser.com`) — see `mocks/bungee.ts` / `mocks/nearIntents.ts`. Near's quote is + * cryptographically signed by Near's attestor key over the exact quote+timestamp payload + * (`recoverDepositAddress` in `@cowprotocol/sdk-bridging`), so the Near fixture can only be + * replayed byte-for-byte, for the one real route it was captured for (Mainnet USDC → Base USDC) + * — it cannot be edited to match every chain pairing the spec names, and no valid fixture exists + * for a Solana/Bitcoin *destination* quote at all. Bungee's fixture isn't signature-bound, but + * is likewise a single captured route (also Mainnet USDC → Base USDC). + * - Both bridge providers are gated behind LaunchDarkly flags in the real app; `mocks.launchDarkly` + * forces them on (Bungee alone would otherwise win real provider competition for any EVM↔EVM + * pair by being the only one enabled by default, and both are needed here per test). + * - Given the above, CC-02/CC-03/CC-26/CC-27 use Mainnet USDC → Base USDC (the one route with a + * valid signed Near fixture) rather than the exact chains/tokens named in the spec, forcing a + * single provider on per test via `mocks.launchDarkly.setFlag`. CC-26/CC-27 cover the Bungee and + * Near Intents repeats but not the BNB/BTC decimal-precision repeats — no valid fixture exists + * for either. + * - CC-15/CC-17 stop at the recipient-requirement UI states (chain selectability, button-state + * progression, confirmation checkbox) — the settlement-side assertions (tokens received, SOL/BTC + * decimal display) need a real resolved Solana/Bitcoin-destination quote, which no valid fixture + * exists for (see above). + * - Bridge-order tracking after confirmation (Bridge Explorer navigation, CoW Explorer tracking, + * bridge tx hash) needs the separate deposit/status polling machinery + * (`PendingBridgeOrdersUpdater`) on top of everything above; out of scope here. These tests stop + * once the order is posted and confirmed, mirroring how the rest of this suite verifies order + * posting (`mockOrderPosting`) without simulating on-chain settlement of the bridge leg itself. + * - CC-26's spec expects the swap and bridge stops' own "Min. to receive" figures to be equal — + * confirmed (via reading `useBridgeQuoteAmounts`/the bundled bridging SDK) to be two genuinely + * different calculations in the real app, not a mock artifact: the bridge stop's figure is the + * bridge SDK quote's own `afterSlippage.buyAmount`, carrying that provider's real routeFee/ + * slippage rather than being rescaled to the swap leg the way "Expected to receive" is. This test + * checks both are present instead of asserting parity. + */ + +const MAINNET = CHAIN_IDS.MAINNET +const BASE = CHAIN_IDS.BASE + +const USDC_MAINNET = '0xA0b86991c6218b36c1d19D4A2e9Eb0cE3606eB48' +const USDC_BASE = '0x833589fCD6eDb6E08f4C7C32D4f71b54bdA02913' +const NATIVE_ETH = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + +const INITIAL_USDC_BALANCE = parseUnits('1000', 6) +const INITIAL_ETH_BALANCE = parseUnits('1', 18) + +test.describe('Cross-chain swaps', () => { + test.use({ mockWalletKey: process.env.INTEGRATION_TEST_PRIVATE_KEY as Hex | undefined }) + + /** + * Forces exactly one bridge provider on, stubs the on-chain check Bungee's quote needs, and pins + * the swap leg's rate near 1:1. `BridgingSdk.getBestQuote()` first fetches a *regular* CoW quote + * for the swap leg (sell token → intermediate token) and feeds its `buyAmount` in as the amount + * the bridge provider quotes — `mocks.cowApi`'s default quote fixture scales that from a + * WETH/18-decimal:testUSDC/18-decimal ratio (~1:547), which is nonsensical for this suite's real + * USDC(6dec)→USDC(6dec) pair and was silently producing an amount so degenerate the bridge + * provider quote failed outright. + */ + async function configureProviders( + mocks: { launchDarkly: LaunchDarklyMock; cowApi: CowProtocolApiMock }, + rpcProxy: RpcProxyHandle, + active: 'bungee' | 'near-intents', + ): Promise { + await mocks.launchDarkly.setFlag('isBungeeBridgeProviderEnabled', active === 'bungee') + await mocks.launchDarkly.setFlag('isNearIntentsBridgeProviderEnabled', active === 'near-intents') + mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: 999n, denominator: 1000n } }) + } + + /** + * `/#/{chainId}/swap/{sell}/{buy}` alone can't express a cross-chain buy token — the router + * resolves `buy` against the *path's* chain id unless a `targetChainId` query param says + * otherwise (`useSetupTradeStateFromUrl.ts`, `parameterizeTradeSearch.ts`). Presetting + * sell/buy/amount together in one navigation — rather than typing the amount then picking the + * buy token through the UI, or vice versa — sidesteps a real race in the app's own quote + * polling: whichever of the two happens second fires a fresh bridging quote fetch, but the + * *first* one's now-stale in-flight fetch (still carrying the old amount, or no buy token yet) + * can resolve after it and stick as the shown state — a bridge quote error is provider-and-pair + * scoped, not amount-scoped, so nothing about the follow-up fetch retries or clears it. + * + * The app uses a hash router, so `page.goto()` to a new `#/...` route is a same-document + * navigation. No reload is needed to switch providers between calls (`configureProviders` in + * between): `mocks.launchDarkly.setFlag` pushes the new flags straight into the open page and + * fires a `featureFlagsUpdate` event, which `useFeatureFlags` (`libs/common-hooks/src/ + * useFeatureFlags.ts`) picks up, causing `BridgeProvidersUpdater` to recompute `bridgingSdk`'s + * available providers reactively — see both files' doc comments. + */ + async function openCrossChainSwap( + wallet: { openApp(opts: { chainId: number; sell?: string }): Promise }, + swapPage: SwapPage, + opts: { chainId: number; sell: string; buy: string; targetChainId: number; sellAmount: string }, + ): Promise { + await wallet.openApp({ chainId: opts.chainId }) + await swapPage.unlockIfNeeded() + const url = `/#/${opts.chainId}/swap/${opts.sell}/${opts.buy}?targetChainId=${opts.targetChainId}&sellAmount=${opts.sellAmount}` + await swapPage.page.goto(url) + } + + test('[CS-285] Cross-chain swap UI: accessible via Swap form @smoke', async ({ + swapPage, + wallet, + mocks, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + + await configureProviders(mocks, rpcProxy, 'bungee') + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: USDC_MAINNET, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '100', + }) + await swapPage.waitForQuote() + await swapPage.routePanel.expand() + await expect(swapPage.routePanel.swapStopTitle).toBeVisible() + await expect(swapPage.routePanel.bridgeStopTitle('Bungee')).toBeVisible() + + await configureProviders(mocks, rpcProxy, 'near-intents') + await swapPage.page.reload() + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: USDC_MAINNET, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '100', + }) + await swapPage.waitForQuote() + await swapPage.routePanel.expand() + await expect(swapPage.routePanel.swapStopTitle).toBeVisible() + await expect(swapPage.routePanel.bridgeStopTitle('Near Intents')).toBeVisible() + }) + + test('[CS-286] Cross-chain swap: Near provider', async ({ swapPage, wallet, confirmModal, mocks, rpcProxy }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, rpcProxy, 'near-intents') + + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: USDC_MAINNET, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '100', + }) + await swapPage.waitForQuote() + await swapPage.routePanel.expand() + + await expect(swapPage.routePanel.swapStopTitle).toBeVisible() + await expect(swapPage.routePanel.bridgeStopTitle('Near Intents')).toBeVisible() + + // Swap leg line items. `mockFixedRateQuote` zeroes the fee, so this always renders as + // `FreeFeeRow`'s plain "Fee" / "FREE" rather than `ProtocolFeeRow`'s "Protocol fee (X%)" — + // and with no network fee either, `NetworkCostsRow` doesn't render at all in that state. + await expect(swapPage.routePanel.swapFee()).toBeVisible() + await expect(swapPage.routePanel.swapExpectedToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.swapMinToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.swapQuoteId()).toBeVisible() + + // Intermediate recipient differs from the wallet owner. `AddressLink` shows a truncated + // "0x844C...1Cb5" string but links to the explorer with the full address in the URL. + const swapRecipientHref = await swapPage.routePanel.swapRecipient().locator('a').getAttribute('href') + const swapRecipientAddress = swapRecipientHref?.match(/0x[a-fA-F0-9]{40}/)?.[0] + expect(swapRecipientAddress).toBeTruthy() + expect(areAddressesEqual(swapRecipientAddress, wallet.address)).toBe(false) + + // Bridge leg line items. + await expect(swapPage.routePanel.bridgeEstTime()).toBeVisible() + await expect(swapPage.routePanel.bridgeCosts()).toBeVisible() + await expect(swapPage.routePanel.bridgeExpectedToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.bridgeMinToDeposit()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.bridgeMinToReceive()).toHaveAttribute('title', /.+/) + + const orderId = generateOrderId() + await mocks.orders.expectOrderToBePosted({ + orderId, + owner: wallet.address, + trigger: async () => { + await swapPage.clickPrimaryAction() + await confirmModal.confirm() + }, + }) + await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) + + mocks.orders.fulfillOrder(orderId, mocks.balances, MAINNET, INITIAL_USDC_BALANCE, 0n) + // The swap leg settles and the progress modal moves on to bridging — full bridge-order + // tracking (`PendingBridgeOrdersUpdater`'s deposit/status polling) is out of scope here (see + // the module doc comment), so this is as far as the mocked flow goes. + await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) + }) + + test('[CS-287] Cross-chain swap: Bungee provider @smoke', async ({ + swapPage, + wallet, + confirmModal, + mocks, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, rpcProxy, 'bungee') + + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: USDC_MAINNET, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '100', + }) + await swapPage.waitForQuote() + await swapPage.routePanel.expand() + + await expect(swapPage.routePanel.swapStopTitle).toBeVisible() + await expect(swapPage.routePanel.bridgeStopTitle('Bungee')).toBeVisible() + + // Swap leg line items. `mockFixedRateQuote` zeroes the fee, so this always renders as + // `FreeFeeRow`'s plain "Fee" / "FREE" rather than `ProtocolFeeRow`'s "Protocol fee (X%)" — + // and with no network fee either, `NetworkCostsRow` doesn't render at all in that state. + await expect(swapPage.routePanel.swapFee()).toBeVisible() + await expect(swapPage.routePanel.swapExpectedToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.swapMinToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.swapQuoteId()).toBeVisible() + + // Bungee settles via a CoW-Shed hook on the user's Account Proxy — banner shows that address. + await expect(swapPage.routePanel.accountProxyBanner).toBeVisible() + await expect(swapPage.routePanel.accountProxyBanner.locator('a')).toHaveAttribute('href', /.+/) + + // Bridge leg line items. + await expect(swapPage.routePanel.bridgeEstTime()).toBeVisible() + await expect(swapPage.routePanel.bridgeCosts()).toBeVisible() + await expect(swapPage.routePanel.bridgeExpectedToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.bridgeMinToDeposit()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.bridgeMinToReceive()).toHaveAttribute('title', /.+/) + + const orderId = generateOrderId() + await mocks.orders.expectOrderToBePosted({ + orderId, + owner: wallet.address, + trigger: async () => { + await swapPage.clickPrimaryAction() + await confirmModal.confirm() + }, + }) + await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) + + mocks.orders.fulfillOrder(orderId, mocks.balances, MAINNET, INITIAL_USDC_BALANCE, 0n) + // The swap leg settles and the progress modal moves on to bridging — full bridge-order + // tracking (`PendingBridgeOrdersUpdater`'s deposit/status polling) is out of scope here (see + // the module doc comment), so this is as far as the mocked flow goes. + await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) + }) + + test('[CS-297] Cross-chain: ETH-flow source — native ETH sent cross-chain @smoke', async ({ + swapPage, + wallet, + confirmModal, + mocks, + context, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { balances: { [NATIVE_ETH]: INITIAL_ETH_BALANCE } }) + await configureProviders(mocks, rpcProxy, 'bungee') + + // `configureProviders`'s `mockFixedRateQuote({ rate: { numerator: 999n, denominator: 1000n } })` + // computes `buyAmount = sellAmount * 999n / 1000n` — correct for every other test here, where + // sell and (intermediate) buy token are both 6-decimal USDC, but wrong for this one: selling + // 18-decimal native ETH into a 6-decimal USDC intermediate needs the ratio scaled down by + // 10^12, or the naive multiply leaves `buyAmount` twelve orders of magnitude too large (surfaced + // as Bungee's mocked `inputAmount` request param, then as an absurd "for at least 99.339B USDC" + // in the confirm modal). Re-overriding the same `quote` endpoint here fixes it without touching + // `mockFixedRateQuote`'s shared, decimals-agnostic default behaviour. + mocks.cowApi.set('quote', (req) => { + const defaults = req.defaults as { quote: Record } + const sellAmount = BigInt(defaults.quote.sellAmount as string) + const buyAmount = (sellAmount * 999n) / (1000n * 10n ** 12n) + return { + ...defaults, + protocolFeeBps: '0', + quote: { ...defaults.quote, buyAmount: buyAmount.toString(), feeAmount: '0' }, + } + }) + + // 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 to a dedicated EthFlow contract instead + // (with the sell amount as `tx.value`), so this needs `mockEthFlowTransaction` rather than + // `tradePage.mockOrderPosting`. Without it, confirming sends a real, un-stubbed + // `eth_sendTransaction` to the real RPC, which is what was surfacing as "Missing or invalid + // parameters" — see `mockEthFlowTransaction` and [MO-11] for the non-bridging version of this + // same distinction. + const ethFlow = await mockEthFlowTransaction({ + context, + wallet, + chainId: MAINNET, + initialEthBalance: INITIAL_ETH_BALANCE, + }) + + // An eth-flow `createOrder()` tx only carries the app-data *hash* on-chain (a `bytes32`, no + // room for the full JSON document) — the app uploads the full document separately via + // `PUT /api/v1/app_data/{hash}` beforehand, same as every other order type here, so it's still + // capturable that way (see [MO-30] for the same capture pattern used to assert on it instead). + // Capturing and echoing it back matters for more than fidelity: `useSwapAndBridgeContext` + // resolves the bridge provider from `order.apiAdditionalInfo.fullAppData` + // (`bridgingSdk.getProviderFromAppData`) to decide whether this is a bridging order at all — + // without it, `bridgingStatus` never resolves and the progress modal sticks on "Executing" + // forever, regardless of what `order`/`orderStatus` themselves report. + let uploadedAppData: string | undefined + mocks.cowApi.set('putAppData', (req) => { + uploadedAppData = (req.body as { fullAppData: string }).fullAppData + return req.params.hash + }) + + // Mirrors [MO-11]'s inlined `order`-endpoint override: an ETH-flow order's uid is computed + // client-side before anything is sent on-chain, so there's no `postOrder` call to hook the way + // `mockOrderPosting` does for every other order type here. + let orderIndexed = false + mocks.cowApi.set('order', (req) => { + if (!orderIndexed) 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, + fullAppData: uploadedAppData, + } + }) + + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: NATIVE_ETH, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '0.1', + }) + await swapPage.waitForQuote() + + // Native ETH accepted directly for a cross-chain sell — no separate wrap step is offered. + await expect(swapPage.approveButton).toBeHidden() + await expect(swapPage.swapButton).toContainText(/swap.*bridge/i) + + 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(parseUnits('0.1', 18)) + ethFlow.confirmMined() + orderIndexed = true + + await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) + + const orderParams = ethFlow.getOrderParams() + if (!orderParams) throw new Error('mockEthFlowTransaction: fulfill attempted before an order was sent') + seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_BASE]: orderParams.buyAmount } }) + ethFlow.confirmFilled() + + // Mirrors `mockOrderPosting.fulfill()`'s other half: the order-progress modal's competition + // stages (shared with the regular, non-eth-flow bridging tests) advance past "Executing" only + // once `orderStatus` itself reports `traded` — setting `order`'s own `status` above isn't + // enough on its own. + mocks.cowApi.set('orderStatus', () => ({ + type: 'traded', + value: [ + { + solver: '0x99b4136666ca1d13020830350ca8d01a0e5e466b', + executedAmounts: { sell: orderParams.sellAmount.toString(), buy: orderParams.buyAmount.toString() }, + }, + ], + })) + + // The swap leg settles and the progress modal moves on to bridging — full bridge-order + // tracking (`PendingBridgeOrdersUpdater`'s deposit/status polling) is out of scope here (see + // the module doc comment), so this is as far as the mocked flow goes. + await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) + }) + + test('[CS-299] Cross-chain: swap to Solana — SOL or SPL token as destination @smoke', async ({ + swapPage, + wallet, + mocks, + context, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, rpcProxy, 'near-intents') + await mocks.launchDarkly.setFlag('isSolBridgeEnabled', true) + // Distinct from the LaunchDarkly-style flag above: `IS_SOLANA_ENABLED` is a plain localStorage + // switch (`libs/common-const/src/featureFlags.ts`) gating whether Solana even has a + // `CHAIN_INFO` entry to begin with — without it, `useSupportedTargetChains` has the flag on but + // nothing to look up, and Solana still can't appear as a destination chain. + await context.addInitScript(() => localStorage.setItem('IS_SOLANA_ENABLED', '1')) + + await wallet.openApp({ chainId: MAINNET, sell: USDC_MAINNET }) + await swapPage.unlockIfNeeded() + await swapPage.enterSellAmount('100') + + await swapPage.tokens.openOutput() + await expect(swapPage.page.getByText('Solana', { exact: true })).toBeVisible() + await swapPage.tokens.selectChain('Solana') + await swapPage.tokens.searchAndPick('SOL') + + // No default recipient — the button is disabled and names Solana specifically. No `id` in + // this validation state (`RecipientNotSet` in `tradeButtonsMap.tsx` renders a plain + // `TradeFormBlankButton` with no `id` prop — only the "no validation errors" state gets + // `#do-trade-button`), so matched by role/text instead of `swapButton`. + const recipientRequiredButton = swapPage.page.getByRole('button', { name: /recipient is required for solana/i }) + await expect(recipientRequiredButton).toBeVisible() + await expect(recipientRequiredButton).toBeDisabled() + await expect(swapPage.page.getByText('Send to Solana wallet', { exact: true })).toBeVisible() + await expect(swapPage.recipientPasteButton).toBeVisible() + + const SOLANA_ADDRESS = '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1' + await swapPage.recipientInput.fill(SOLANA_ADDRESS) + + // Valid address, not yet confirmed. Same no-`id` situation as above (`RecipientNotConfirmed`). + const confirmRecipientButton = swapPage.page.getByRole('button', { name: /confirm recipient to swap/i }) + await expect(confirmRecipientButton).toBeVisible() + await expect(confirmRecipientButton).toBeDisabled() + + // Under load, a still-settling recipient-validation debounce can reset `confirmed` back to + // false right after this click lands (the checkbox is a controlled input driven by that + // validation state) — Playwright's own `.check()` sees the click "not change its state" when + // that happens. Retrying the click until it actually sticks rides out the race instead of + // asserting on a single attempt. + await expect + .poll(async () => { + await swapPage.recipientConfirmationCheckbox.check() + return swapPage.recipientConfirmationCheckbox.isChecked() + }) + .toBe(true) + + // Once validation passes, the primary CTA becomes `TradeApproveButton` (an ERC-20 allowance + // decision applies to every cross-chain sell here) rather than the plain `swapButton`. + await expect(swapPage.primaryActionButton).toContainText(/swap and bridge/i) + await expect(swapPage.primaryActionButton).toBeEnabled() + }) + + test('[CS-301] Cross-chain: swap to Bitcoin — BTC as destination @smoke', async ({ + swapPage, + wallet, + mocks, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, rpcProxy, 'near-intents') + await mocks.launchDarkly.setFlag('isBtcBridgeEnabled', true) + + await wallet.openApp({ chainId: MAINNET, sell: USDC_MAINNET }) + await swapPage.unlockIfNeeded() + await swapPage.enterSellAmount('100') + + await swapPage.tokens.openOutput() + await expect(swapPage.page.getByText('Bitcoin', { exact: true })).toBeVisible() + await swapPage.tokens.selectChain('Bitcoin') + await swapPage.tokens.searchAndPick('BTC(OMNI)') + + // No `id` in this validation state — see the matching comment in [CC-15]. + const recipientRequiredButton = swapPage.page.getByRole('button', { name: /recipient is required for bitcoin/i }) + await expect(recipientRequiredButton).toBeVisible() + await expect(recipientRequiredButton).toBeDisabled() + + const BITCOIN_ADDRESS = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + await swapPage.recipientInput.fill(BITCOIN_ADDRESS) + + const confirmRecipientButton = swapPage.page.getByRole('button', { name: /confirm recipient to swap/i }) + await expect(confirmRecipientButton).toBeVisible() + await expect(confirmRecipientButton).toBeDisabled() + await expect( + swapPage.page.getByText(/Recipient is on Bitcoin network\. Confirm this is the correct address/i), + ).toBeVisible() + + // Under load, a still-settling recipient-validation debounce can reset `confirmed` back to + // false right after this click lands (the checkbox is a controlled input driven by that + // validation state) — Playwright's own `.check()` sees the click "not change its state" when + // that happens. Retrying the click until it actually sticks rides out the race instead of + // asserting on a single attempt. + await expect + .poll(async () => { + await swapPage.recipientConfirmationCheckbox.check() + return swapPage.recipientConfirmationCheckbox.isChecked() + }) + .toBe(true) + + // Once validation passes, the primary CTA becomes `TradeApproveButton` (an ERC-20 allowance + // decision applies to every cross-chain sell here) rather than the plain `swapButton`. + await expect(swapPage.primaryActionButton).toContainText(/swap and bridge/i) + await expect(swapPage.primaryActionButton).toBeEnabled() + }) + + test('[CS-310] Cross-chain: calculation parity — form Receive equals bridge Expected to receive @smoke', async ({ + swapPage, + wallet, + mocks, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + + for (const provider of ['bungee', 'near-intents'] as const) { + await configureProviders(mocks, rpcProxy, provider) + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: USDC_MAINNET, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '100', + }) + await swapPage.waitForQuote() + await swapPage.routePanel.expand() + + // Per spec: form `Receive (incl. fees)` equals the *bridge* stop's `Expected to receive` + // (the final, post-bridge amount) — not the swap stop's own row, which shows the swap + // leg's unscaled output (`QuoteObserverUpdater` overwrites the form's own + // `outputCurrencyAmount` with `useEstimatedBridgeBuyAmount`'s bridge-rescaled figure, but the + // swap stop's row in the panel reads the swap quote directly, un-rescaled). + const formReceive = await swapPage.receiveAmountValue.getAttribute('title') + await expect(swapPage.routePanel.bridgeExpectedToReceive()).toHaveAttribute('title', formReceive ?? '') + await expect(swapPage.routePanel.swapExpectedToReceive()).toHaveAttribute('title', /.+/) + + // Unlike "Expected to receive" (rescaled through the same ratio for both stops, hence the + // equality above), "Min. to receive" is genuinely two different calculations in the real + // app, not a mock artifact: the swap stop's own figure comes from the swap quote's + // `amountsToSign` tier, while the bridge stop's is read straight off the bridge SDK quote's + // `amountsAndCosts.afterSlippage.buyAmount` — for Bungee that's `route.output.amount` with + // its own real routeFee baked in (a genuine, small, provider-specific bridging cost, not + // rescaled to match the swap leg), and for Near Intents it's an absolute number lifted + // verbatim from the signed fixture (`near-quote.json`), unrelated to this test's actual sell + // amount since that fixture can't be rescaled (see the module doc comment). Asserting only + // presence here, not parity with the swap leg's own Min. to receive. + await expect(swapPage.routePanel.swapMinToReceive()).toHaveAttribute('title', /.+/) + await expect(swapPage.routePanel.bridgeMinToReceive()).toHaveAttribute('title', /.+/) + } + }) + + test('[CS-311] Cross-chain: calculation parity — bridge Min. to deposit equals swap Min. to receive @smoke', async ({ + swapPage, + wallet, + mocks, + rpcProxy, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + + for (const provider of ['bungee', 'near-intents'] as const) { + await configureProviders(mocks, rpcProxy, provider) + await openCrossChainSwap(wallet, swapPage, { + chainId: MAINNET, + sell: USDC_MAINNET, + buy: USDC_BASE, + targetChainId: BASE, + sellAmount: '100', + }) + await swapPage.waitForQuote() + await swapPage.routePanel.expand() + + const swapMinToReceive = await swapPage.routePanel.swapMinToReceive().getAttribute('title') + await expect(swapPage.routePanel.bridgeMinToDeposit()).toHaveAttribute('title', swapMinToReceive ?? '') + } + }) +}) 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 9025538da9e..7388138d466 100644 --- a/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts @@ -1,6 +1,7 @@ import { parseUnits, type Hex } from 'viem' import { test, expect } from '../fixtures' +import { generateOrderId } from '../mocks/orders' import { CHAIN_IDS } from '../support/constants' const CHAIN_ID = CHAIN_IDS.SEPOLIA @@ -32,7 +33,6 @@ test.describe('Limit Orders', () => { test('[LO-02] Place sell limit order: USDC → COW, order shows up in the orders table', async ({ limitPage, - tradePage, wallet, confirmModal, mocks, @@ -48,11 +48,7 @@ test.describe('Limit Orders', () => { 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) + const orderId = generateOrderId() await limitPage.goto({ chainId: CHAIN_ID, sell: USDC, buy: COW }) await limitPage.enterSellAmount('120') @@ -66,7 +62,15 @@ test.describe('Limit Orders', () => { await limitPage.placeOrder() await expect(confirmModal.confirmButton).toContainText('Place limit order') - await confirmModal.confirm() + + // Real trade flow tags the pending order `class: LIMIT` locally before dispatch, and that + // local class always wins over a fetched order's — so `expectOrderToBePosted`'s hardcoded + // `class: 'market'` on the fabricated order doesn't filter it out of the Limit tab. + await mocks.orders.expectOrderToBePosted({ + orderId, + owner: wallet.address, + trigger: () => 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. 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 856458812c8..eda8ace2c16 100644 --- a/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts @@ -4,11 +4,10 @@ import { areAddressesEqual, bpsToPercentage } from '@cowprotocol/cow-sdk' import { test, expect } from '../fixtures' import { reply } from '../mocks/cowProtocolApi' +import { generateOrderId } from '../mocks/orders' 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' @@ -38,7 +37,6 @@ test.describe('Market Orders', () => { test('[CS-59] Sell order: ERC-20 → ERC-20 @smoke', async ({ swapPage, - tradePage, wallet, confirmModal, accountModal, @@ -58,7 +56,7 @@ test.describe('Market Orders', () => { // 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) + const orderId = generateOrderId() // `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 @@ -88,8 +86,14 @@ test.describe('Market Orders', () => { await swapPage.waitForQuote() - await swapPage.clickSwap() - await confirmModal.confirm() + await mocks.orders.expectOrderToBePosted({ + orderId, + owner: wallet.address, + trigger: async () => { + 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') @@ -108,13 +112,13 @@ test.describe('Market Orders', () => { // `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() + mocks.orders.markExecuting(orderId) 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) + mocks.orders.fulfillOrder(orderId, 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 }) @@ -124,15 +128,16 @@ test.describe('Market Orders', () => { // 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())) + const postedOrder = mocks.orders.getOrder(orderId) + expect(await readTitledAmount(soldAmountRow)).toBe(BigInt(postedOrder?.sellAmount ?? 0)) + expect(await readTitledAmount(receivedAmountRow)).toBe(BigInt(postedOrder?.buyAmount ?? 0)) 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`, + `${formatUnits(BigInt(mocks.orders.getOrder(orderId)?.buyAmount ?? 0), 18)} WETH`, { timeout: 15_000 }, ) @@ -141,7 +146,6 @@ test.describe('Market Orders', () => { test('[CS-60] Buy order: specify exact buy amount (ERC-20) @smoke', async ({ swapPage, - tradePage, wallet, confirmModal, accountModal, @@ -157,7 +161,7 @@ test.describe('Market Orders', () => { // 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) + const orderId = generateOrderId() // `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]. @@ -177,8 +181,14 @@ test.describe('Market Orders', () => { await swapPage.enterBuyAmount('1') await swapPage.waitForQuote() - await swapPage.clickSwap() - await confirmModal.confirm() + await mocks.orders.expectOrderToBePosted({ + orderId, + owner: wallet.address, + trigger: async () => { + await swapPage.clickSwap() + await confirmModal.confirm() + }, + }) await expect(swapPage.orderProgressBarModal).toContainText('Batching orders') await swapPage.page.keyboard.press('Escape') @@ -187,7 +197,7 @@ test.describe('Market Orders', () => { 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) + mocks.orders.fulfillOrder(orderId, 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, @@ -200,7 +210,7 @@ test.describe('Market Orders', () => { 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`, + `${formatUnits(INITIAL_USDC_BALANCE - BigInt(mocks.orders.getOrder(orderId)?.sellAmount ?? 0), 18)} USDC`, { timeout: 15_000 }, ) @@ -477,7 +487,7 @@ test.describe('Market Orders', () => { // 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) + const orderIndexing = mocks.orders.trackEthFlowOrder(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 @@ -595,7 +605,7 @@ test.describe('Market Orders', () => { initialEthBalance: INITIAL_ETH_BALANCE, }) - const orderIndexing = mockEthFlowOrderIndexing(mocks.cowApi, ethFlow) + const orderIndexing = mocks.orders.trackEthFlowOrder(ethFlow) mockFixedRateQuote({ cowApi: mocks.cowApi }) @@ -1028,8 +1038,9 @@ test.describe('Market Orders', () => { // 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, + const orderId = generateOrderId() + mocks.orders.seedOpenOrder({ + orderId, owner: wallet.address, sellToken: WETH, buyToken: USDC, @@ -1060,12 +1071,12 @@ test.describe('Market Orders', () => { // 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) + await expect.poll(() => mocks.orders.wasCancelRequested(orderId)).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) + expect(cancellationSignRequest?.message?.orderUids).toContain(orderId) // No gas transaction is ever sent for a soft cancellation. expect(wallet.rpcCalls('eth_sendTransaction')).toHaveLength(0) @@ -1073,7 +1084,7 @@ test.describe('Market Orders', () => { // 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() + mocks.orders.markCancelled(orderId) await expect(accountModal.activitiesList).toContainText('Cancelling...', { timeout: 45_000 }) // Once enough real time has passed since `creationDate`, `isOrderCancelled` flips true and the @@ -1084,7 +1095,6 @@ test.describe('Market Orders', () => { test('[CS-118] Progress bar: regular order happy path — steps 1 → 2 → 3 → 4', async ({ swapPage, - tradePage, wallet, confirmModal, mocks, @@ -1096,7 +1106,7 @@ test.describe('Market Orders', () => { mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: BUY_RATE_NUM, denominator: BUY_RATE_DEN } }) - const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + const orderId = generateOrderId() // 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 @@ -1115,8 +1125,14 @@ test.describe('Market Orders', () => { await selectTokens(swapPage, 'USDC', 'WETH') await swapPage.waitForQuote() - await swapPage.clickSwap() - await confirmModal.confirm() + await mocks.orders.expectOrderToBePosted({ + orderId, + owner: wallet.address, + trigger: async () => { + await swapPage.clickSwap() + await confirmModal.confirm() + }, + }) // Step 1 (INITIAL, backend OPEN/SCHEDULED) — order just signed and posted, competition hasn't // started yet. @@ -1131,11 +1147,11 @@ test.describe('Market Orders', () => { // 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() + mocks.orders.markExecuting(orderId) 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) + mocks.orders.fulfillOrder(orderId, 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 }) @@ -1145,8 +1161,9 @@ test.describe('Market Orders', () => { // 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())) + const postedOrder = mocks.orders.getOrder(orderId) + expect(await readTitledAmount(soldAmountRow)).toBe(BigInt(postedOrder?.sellAmount ?? 0)) + expect(await readTitledAmount(receivedAmountRow)).toBe(BigInt(postedOrder?.buyAmount ?? 0)) }) test('[CS-127] Swap form: protocol fee applied at 0.02% (2 bps) for standard token pair @smoke', async ({ diff --git a/apps/cowswap-frontend/src/common/pure/TradeDetailsAccordion/index.tsx b/apps/cowswap-frontend/src/common/pure/TradeDetailsAccordion/index.tsx index 1cf129d8960..fbec8ad693e 100644 --- a/apps/cowswap-frontend/src/common/pure/TradeDetailsAccordion/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/TradeDetailsAccordion/index.tsx @@ -47,10 +47,17 @@ export function TradeDetailsAccordion({ const defaultFeeContent = return ( - + {rateInfo} - + {feeWrapper ? feeWrapper(defaultFeeContent, open) : defaultFeeContent} diff --git a/apps/cowswap-frontend/src/modules/bridge/pure/CollapsibleBridgeRoute/index.tsx b/apps/cowswap-frontend/src/modules/bridge/pure/CollapsibleBridgeRoute/index.tsx index 03d2e80d09a..053a950e8aa 100644 --- a/apps/cowswap-frontend/src/modules/bridge/pure/CollapsibleBridgeRoute/index.tsx +++ b/apps/cowswap-frontend/src/modules/bridge/pure/CollapsibleBridgeRoute/index.tsx @@ -39,7 +39,7 @@ export function CollapsibleBridgeRoute(props: CollapsibleBridgeRouteProps): Reac const toggleExpanded = (): void => setIsExpanded((state) => !state) return ( - + {isCollapsible && ( ({ + address: NEAR_INTENTS_E2E_ATTESTATOR_ADDRESS, + quoteHash: quote.depositAddress ?? '0x0', + stringifiedQuote: '', + attestationSignature: '0x', + }) +} + export const bridgingSdk = new BridgingSdk({ providers: [bungeeBridgeProvider, acrossBridgeProvider, nearIntentsBridgeProvider], enableLogging: !!localStorage.getItem('enableBridgingSdkLogs'), diff --git a/libs/common-hooks/src/useFeatureFlags.ts b/libs/common-hooks/src/useFeatureFlags.ts index 457b0c41d9a..ae7b0fb43f7 100644 --- a/libs/common-hooks/src/useFeatureFlags.ts +++ b/libs/common-hooks/src/useFeatureFlags.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { useFlags } from 'launchdarkly-react-client-sdk' export interface FeatureFlags { @@ -7,11 +9,30 @@ export interface FeatureFlags { [key: string]: any } +declare global { + interface Window { + __COWSWAP_E2E_FEATURE_FLAGS__?: FeatureFlags + } +} + // const defaults: Partial = { // } export function useFeatureFlags(): FeatureFlags { const flags = useFlags() + + // e2e tests can't get LaunchDarkly to resolve real flag values (no client-side ID is configured + // for that environment, so the SDK never even attempts the flag-evaluation request) — they set + // this directly on `window` instead, bypassing LaunchDarkly entirely. See + // `apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts`. Memoized so the e2e override doesn't hand + // consumers a new object identity on every render. + const e2eOverrideFlags = useMemo(() => { + if (typeof window === 'undefined' || !window.__COWSWAP_E2E_FEATURE_FLAGS__) return undefined + return { ...flags, ...window.__COWSWAP_E2E_FEATURE_FLAGS__ } + }, [flags]) + + if (e2eOverrideFlags) return e2eOverrideFlags + return flags // return { ...defaults, ...flags } }