From 4af9613f00c10867627c102bdc953416e83d8e0b Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Tue, 11 Aug 2026 16:12:51 +0200 Subject: [PATCH 01/35] test: e2e cross-chain swaps --- .github/workflows/e2e-pw-nightly.yml | 1 + .github/workflows/e2e-pw-smoke.yml | 1 + apps/cowswap-e2e-tests/.env.example | 1 + apps/cowswap-e2e-tests/src/fixtures/shared.ts | 6 +- .../bridge/fixtures/bungee-dest-tokens.json | 79 + .../fixtures/bungee-intermediate-tokens.json | 25 + .../mocks/bridge/fixtures/bungee-quote.json | 232 +++ .../bridge/fixtures/near-attestation.json | 4 + .../bridge/fixtures/near-dest-tokens.json | 1820 +++++++++++++++++ .../src/mocks/bridge/fixtures/near-quote.json | 40 + apps/cowswap-e2e-tests/src/mocks/bungee.ts | 185 +- .../src/mocks/launchDarkly.ts | 50 + .../src/mocks/nearIntents.ts | 68 +- .../src/pages/BridgeRoutePanel.ts | 107 + apps/cowswap-e2e-tests/src/pages/SwapPage.ts | 33 +- .../src/pages/TokenSelector.ts | 10 + .../src/support/mockEthFlowTransaction.ts | 40 +- .../src/support/mockSocketVerifier.ts | 191 ++ .../src/tests/cross-chain-swaps.spec.ts | 593 ++++++ .../src/tradingSdk/bridgingSdk.ts | 20 + libs/common-hooks/src/useFeatureFlags.ts | 15 + 21 files changed, 3479 insertions(+), 42 deletions(-) create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-dest-tokens.json create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-intermediate-tokens.json create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/bungee-quote.json create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-attestation.json create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-dest-tokens.json create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/fixtures/near-quote.json create mode 100644 apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts create mode 100644 apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts create mode 100644 apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts create mode 100644 apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts diff --git a/.github/workflows/e2e-pw-nightly.yml b/.github/workflows/e2e-pw-nightly.yml index cfc730a0114..d7eeef73517 100644 --- a/.github/workflows/e2e-pw-nightly.yml +++ b/.github/workflows/e2e-pw-nightly.yml @@ -16,6 +16,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 8f189b2620d..0cae6f9dcb6 100644 --- a/.github/workflows/e2e-pw-smoke.yml +++ b/.github/workflows/e2e-pw-smoke.yml @@ -20,6 +20,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/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/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index a0951f0d6eb..57a98d2acdd 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -4,6 +4,7 @@ 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 { installLaunchDarkly, type LaunchDarklyMock } from '../mocks/launchDarkly' import { installNearIntents, type NearIntentsMock } from '../mocks/nearIntents' import { installSafeSdk, type SafeSdkMock } from '../mocks/safeSdk' import { installTokenLists, type TokenListsMock } from '../mocks/tokenLists' @@ -40,6 +41,7 @@ export interface SharedFixtures { safeSdk: SafeSdkMock bungee: BungeeMock nearIntents: NearIntentsMock + launchDarkly: LaunchDarklyMock usdPrices: UsdPricesMock } } @@ -107,13 +109,15 @@ export const sharedFixtures: Fixtures< const safeSdk = installSafeSdk(context) const bungee = installBungee(context) const nearIntents = installNearIntents(context) + const launchDarkly = installLaunchDarkly(context) const usdPrices = installUsdPrices(context) - await use({ allowances, balances, cowApi, tokenLists, safeSdk, bungee, nearIntents, usdPrices }) + await use({ allowances, balances, cowApi, tokenLists, safeSdk, bungee, nearIntents, launchDarkly, usdPrices }) tokenLists.reset() bungee.reset() nearIntents.reset() + await launchDarkly.reset() usdPrices.reset() await safeSdk.disable() // Non-fatal, so it must run before the throwing assert below. diff --git a/apps/cowswap-e2e-tests/src/mocks/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/bungee.ts b/apps/cowswap-e2e-tests/src/mocks/bungee.ts index c4e6d878664..0c680b0184e 100644 --- a/apps/cowswap-e2e-tests/src/mocks/bungee.ts +++ b/apps/cowswap-e2e-tests/src/mocks/bungee.ts @@ -1,31 +1,182 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + import type { BrowserContext, Route } from '@playwright/test' +const FIXTURES_DIR = path.join(__dirname, 'bridge', 'fixtures') + +function loadFixture(name: string): unknown { + return JSON.parse(readFileSync(path.join(FIXTURES_DIR, name), 'utf8')) as unknown +} + +// 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 { - stubRoute(opts: { sellAmount: string; buyAmount: string; estTimeSec: number }): void 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 { - let next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 180 } - - void context.route(/(?:api\.bungee|api\.socket)\..*/i, async (route: Route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - result: { - routes: [{ sellAmount: next.sellAmount, buyAmount: next.buyAmount, estimatedTimeSeconds: next.estTimeSec }], - }, - }), - }) + 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 { - stubRoute(opts) { - next = opts - }, reset() { - next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 180 } + // 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 `mockSocketVerifier`. + 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 } : {}), + }, + } + }), }, } } + +function toUsd(amount: string, decimals: number): number { + return Number(amount) / 10 ** decimals +} diff --git a/apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts b/apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts new file mode 100644 index 00000000000..40e7828e6da --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/launchDarkly.ts @@ -0,0 +1,50 @@ +import type { BrowserContext } from '@playwright/test' + +/** + * `BridgeProvidersUpdater` keeps every bridge provider except Bungee disabled until all three + * `is*BridgeProviderEnabled` flags resolve to an actual boolean (see + * `entities/bridgeProvider/BridgeProvidersUpdater.ts`) — with no LaunchDarkly client-side ID + * configured in this suite's env, the real SDK never even attempts the flag-evaluation request + * (confirmed by tracing network traffic: only a `/sdk/goals/` call fires, never `/sdk/evalx/...`), + * so those flags never resolve and Near Intents/Across can never turn on. Rather than mock + * LaunchDarkly's network calls, `useFeatureFlags` (`libs/common-hooks/src/useFeatureFlags.ts`) + * reads `window.__COWSWAP_E2E_FEATURE_FLAGS__` directly and merges it over the (permanently + * unresolved) real flags — set here via `context.addInitScript`, so it's in place before the + * app's first render, no network round-trip or race to win. + * + * Bungee itself doesn't need any of this: it's added to the provider set synchronously at module + * load (`tradingSdk/bridgingSdk.ts`), before flags ever matter. + */ +export interface LaunchDarklyMock { + setFlag(key: string, value: boolean): Promise + reset(): Promise +} + +const DEFAULT_FLAGS: Readonly> = { + isBungeeBridgeProviderEnabled: true, + isNearIntentsBridgeProviderEnabled: true, + isAcrossBridgeProviderEnabled: false, +} + +export function installLaunchDarkly(context: BrowserContext): LaunchDarklyMock { + let flags: Record = { ...DEFAULT_FLAGS } + + async function applyInitScript(): Promise { + await context.addInitScript((flagsToApply: Record) => { + ;( + window as unknown as { __COWSWAP_E2E_FEATURE_FLAGS__?: Record } + ).__COWSWAP_E2E_FEATURE_FLAGS__ = flagsToApply + }, flags) + } + + return { + async setFlag(key, value) { + flags = { ...flags, [key]: value } + await applyInitScript() + }, + async reset() { + flags = { ...DEFAULT_FLAGS } + await applyInitScript() + }, + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts index 7778c7ecaa0..01a54f1875a 100644 --- a/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts +++ b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts @@ -1,34 +1,62 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + import type { BrowserContext, Route } from '@playwright/test' +const FIXTURES_DIR = path.join(__dirname, 'bridge', 'fixtures') + +function loadFixture(name: string): unknown { + return JSON.parse(readFileSync(path.join(FIXTURES_DIR, name), 'utf8')) as unknown +} + +// 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 { - stubRoute(opts: { sellAmount: string; buyAmount: string; estTimeSec: number }): void reset(): void } export function installNearIntents(context: BrowserContext): NearIntentsMock { - let next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 240 } - - void context.route(/(?:api\.near-intents|near-intents\.org)/i, async (route: Route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - intent: { - sellAmount: next.sellAmount, - buyAmount: next.buyAmount, - estimatedTimeSeconds: next.estTimeSec, - provider: 'near', - }, - }), - }) + 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 { - stubRoute(opts) { - next = opts - }, reset() { - next = { sellAmount: '1000000', buyAmount: '999000', estTimeSec: 240 } + // Fixtures are served as-is for every test — nothing mutable to reset yet. }, } } 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..04bfcae4733 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts @@ -0,0 +1,107 @@ +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 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 + this.expandToggle = page.locator('[aria-expanded]').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}`)) + } + + async expand(): Promise { + if ((await this.expandToggle.getAttribute('aria-expanded')) !== 'true') { + await this.expandToggle.click() + } + await this.swapStopTitle.waitFor({ state: 'visible' }) + } + + /** + * 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 1720c699113..6465418e769 100644 --- a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts @@ -1,3 +1,4 @@ +import { BridgeRoutePanel } from './BridgeRoutePanel' import { TokenSelector } from './TokenSelector' import type { TradePage } from './TradePage' @@ -11,6 +12,16 @@ export class SwapPage implements TradePage { readonly buyBalance: Locator readonly swapButton: Locator readonly approveButton: Locator + /** + * The actual primary CTA once form validation passes: `#do-trade-button` for a plain swap, but + * `TradeApproveButton`'s `#approve-trade-button` instead whenever an ERC-20 allowance decision + * applies (e.g. every cross-chain swap here) — regardless of whether its own label says + * "Approve..." or, once the mocked allowance already covers the trade, "Swap and Bridge". + * `swapButton`/`#do-trade-button` alone still covers every *disabled*, validation-blocking state + * (`ButtonError` also renders under that same id), so this is only for the final ready-to-submit + * click and its enabled/text assertions. + */ + readonly primaryActionButton: Locator readonly arrowSeparator: Locator readonly maxButton: Locator readonly openOrders: Locator @@ -26,6 +37,14 @@ 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. */ + readonly recipientInput: Locator + readonly recipientPasteButton: Locator + /** Hardcoded id on `ReceiverConfirmationRow.pure.tsx`'s "confirm this is the right chain" checkbox. */ + readonly recipientConfirmationCheckbox: Locator constructor(page: Page) { this.page = page @@ -59,12 +78,18 @@ export class SwapPage implements TradePage { this.receiveAmountValue = this.receiveAmountLabel.locator('xpath=../..').locator('[title]').first() this.swapButton = page.locator('#do-trade-button') this.approveButton = page.locator('#approve-trade-button') + this.primaryActionButton = page.locator('#do-trade-button, #approve-trade-button') this.arrowSeparator = page.locator('#currency-arrow-separator') this.maxButton = page.getByRole('button', { name: /^max$/i }) this.openOrders = page.locator('[data-testid="open-orders-list"]') this.unlockButton = page.locator('#unlock-cross-chain-swap-btn') this.orderProgressBarModal = page.locator('#order-progress-bar-modal') this.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 }) + this.recipientConfirmationCheckbox = page.locator('#receiver-confirmation') } async goto(opts: { chainId: number; sell?: string; buy?: string }): Promise { @@ -75,7 +100,9 @@ export class SwapPage implements TradePage { } // The first visit shows an "unlock" intro screen instead of the order form — dismiss it. - private async unlockIfNeeded(): Promise { + // Public: `MockWalletApi.openApp()` navigates directly (bypassing `goto()`), so callers using + // it need to dismiss this screen themselves the same way. + async unlockIfNeeded(): Promise { await this.unlockButton.or(this.inputAmount).first().waitFor({ state: 'visible' }) if (await this.unlockButton.isVisible()) { await this.unlockButton.click() @@ -106,4 +133,8 @@ export class SwapPage implements TradePage { async clickSwap(): Promise { await this.swapButton.click() } + + async clickPrimaryAction(): Promise { + await this.primaryActionButton.click() + } } diff --git a/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts b/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts index 57e23fc3a31..6b084479fe3 100644 --- a/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts +++ b/apps/cowswap-e2e-tests/src/pages/TokenSelector.ts @@ -11,6 +11,16 @@ export class TokenSelector { await this.page.locator('#output-currency-input .open-currency-select-button').click() } + /** + * Picks a destination network in the token picker's chain panel (only rendered when the field + * being picked for is bridging-eligible — see `useChainPanelState`). Chain rows have no + * `data-testid`; `ChainButton` renders only the chain's `label` text (e.g. "Arbitrum", "Base", + * "BNB", "Solana", "Bitcoin" — see `@cowprotocol/sdk-config`'s chain definitions). + */ + async selectChain(chainLabel: string): Promise { + await this.page.getByText(chainLabel, { exact: true }).click() + } + async searchAndPick(symbolOrAddress: string): Promise { const input = this.page.locator('#token-search-input') await input.fill(symbolOrAddress) diff --git a/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts index 39c81c8d2d2..8e1b706a635 100644 --- a/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts +++ b/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts @@ -186,6 +186,9 @@ export interface MockEthFlowTransactionOpts { type ClassifiedEntry = { kind: 'receipt' } | { kind: 'call'; call: ClassifiedEthCall } | { kind: 'opaque' } +/** A generous flat estimate for the `createOrder()` call — never actually spent, since the send itself is stubbed. */ +const FAKE_GAS_ESTIMATE = '0x7a120' as const + interface JsonRpcEntry { id: number | string method: string @@ -227,13 +230,12 @@ export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): let mined = false let filled = false - const stub: RpcStub = ({ params }) => { + wallet.stubRpc('eth_sendTransaction', (({ params }) => { const tx = params[0] as { value?: string; data?: Hex } sentValue = BigInt(tx.value ?? '0x0') orderParams = decodeEthFlowOrderParams(tx.data) return FAKE_ETH_FLOW_TX_HASH - } - wallet.stubRpc('eth_sendTransaction', stub) + }) as RpcStub) const classify = (entry: JsonRpcEntry): ClassifiedEntry => { if (entry.method === 'eth_getTransactionReceipt' && entry.params[0] === FAKE_ETH_FLOW_TX_HASH) { @@ -262,6 +264,8 @@ export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): return undefined } + await mockEthEstimateGas(context) + await context.route(rpcUrl, async (route) => { const body = route.request().postDataJSON() as JsonRpcEntry | JsonRpcEntry[] const entries = Array.isArray(body) ? body : [body] @@ -341,3 +345,33 @@ function decodeEthFlowOrderParams(data: Hex | undefined): EthFlowOrderParams | u return undefined } } + +/** + * Before ever calling `eth_sendTransaction` (stubbed by the caller), the app estimates gas for the + * real `createOrder()` call via its own default public RPC — which, traced live, is *not* + * `REACT_APP_NETWORK_URL_{chainId}` at all (that only backs this suite's own wallet-side + * dispatch/proxy) but whichever of the app's own hardcoded providers (Infura, the WalletConnect RPC + * relay, ...) it happens to pick, unpredictable and outside this test's control. Left unmocked, + * that estimate is a REAL simulation against the wallet's REAL on-chain balance (zero, since this + * is a shared test key with no real funds) and fails with a genuine "exceeds the balance of the + * account" error — well before the stubbed send is ever reached. Matched host-agnostically by + * JSON-RPC method (like `mockSocketVerifier`) rather than by URL, since there's no fixed host to + * route on. + */ +async function mockEthEstimateGas(context: BrowserContext): Promise { + await context.route('**/*', async (route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + const entries = Array.isArray(body) ? body : [body] + if (!entries.length || !entries.every((e) => e?.method === 'eth_estimateGas')) return route.fallback() + + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: FAKE_GAS_ESTIMATE })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + }) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts new file mode 100644 index 00000000000..9fba8b78de7 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts @@ -0,0 +1,191 @@ +import { decodeAbiParameters, encodeAbiParameters, type Hex } from 'viem' + +import { AGGREGATE3_SELECTOR } from '../mocks/allowances/codec' + +import type { BrowserContext, Route } from '@playwright/test' + +const SOCKET_VERIFIER_ADDRESS = '0xa27a3f5a96df7d8be26ee2790999860c00eb688d' +// `validateRotueId(bytes,uint32)` / `validateSocketRequest(bytes,(uint32,(uint256,address,uint256,address,bytes4)))` +// — both `nonpayable` with no outputs, called via `eth_call`; the SDK only checks the call +// doesn't revert (see `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`). +const STUBBED_SELECTORS = new Set(['0xeee54b0d', '0xf75d4a35']) + +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 + +interface BatchCall { + kind: 'batch' + calls: ClassifiedCall[] +} +interface BatchResultSlot { + success: boolean + returnData: Hex +} +type ClassifiedCall = StubbedCall | BatchCall | OpaqueCall +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] + result?: unknown +} + +interface OpaqueCall { + kind: 'opaque' +} + +interface StubbedCall { + kind: 'stubbed' +} + +const OPAQUE: OpaqueCall = { kind: 'opaque' } + +/** + * `BungeeBridgeProvider.getQuote()` verifies the build-tx it gets from Bungee's API by reading + * two functions on the on-chain SocketVerifier contract, on the origin chain — Near Intents never + * does this. This suite's own RPC proxy (`fixtures/rpcProxy.ts`) only sits in front of the + * *wallet's* provider requests; this contract read instead goes through the app's independent + * read-only RPC client (`RPC_URLS` in `libs/common-const/src/networks.ts`), which for any chain + * without a `REACT_APP_NETWORK_URL_` override (every chain here except Sepolia) falls + * back to a real public endpoint (a baked-in Infura key, confirmed by tracing real traffic) — so + * this needs a host-agnostic route rather than `rpcProxy.stubCall`. Without it, the real call + * reverts with `RouteIdNotFound()` and every Bungee quote fetch fails with `TX_BUILD_ERROR`. + */ +export function mockSocketVerifier(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((c) => c.kind === 'opaque')) return route.fallback() + + if (classified.every(isFullyMocked)) { + const payload = entries.map((entry, i) => ({ jsonrpc: '2.0', id: entry.id, result: buildResult(classified[i]) })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + // Some entries need real data (fully opaque, or a batch only partially recognized) — fetch + // upstream and patch in only what's actually mocked, same merge technique as the allowances mock. + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + + const classifiedById = new Map() + entries.forEach((entry, i) => classifiedById.set(entry.id, classified[i])) + + const payload = upstreamEntries.map((entry) => { + const classifiedEntry = classifiedById.get(entry.id) + if (!classifiedEntry || classifiedEntry.kind === 'opaque') return entry + const upstreamResult = typeof entry.result === 'string' ? (entry.result as Hex) : undefined + return { jsonrpc: '2.0', id: entry.id, result: buildResult(classifiedEntry, upstreamResult) } + }) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + }) +} + +function buildResult(call: ClassifiedCall, upstream?: Hex): unknown { + if (call.kind === 'stubbed') return '0x' + if (call.kind === 'opaque') return undefined + return resolveBatchResult(call, upstream) +} + +/** + * Classifies one `eth_call` payload, recursively — mirrors `mocks/allowances/codec.ts`'s + * `classifyCall`, since Multicall3 batches nest the same way regardless of what's inside them. + * The app never calls the SocketVerifier directly: tracing real RPC traffic shows it's always + * bundled into a Multicall3 `aggregate3` batch alongside unrelated ERC20/allowance reads, so + * recognizing the target wherever it appears inside a batch (rather than requiring the *whole* + * batch to be nothing else) is what keeps this from having to understand every other call in it. + */ +function classifyCall(to: string, data: string): ClassifiedCall { + const selector = data.slice(0, 10).toLowerCase() + + if (to.toLowerCase() === SOCKET_VERIFIER_ADDRESS && STUBBED_SELECTORS.has(selector)) { + return { kind: 'stubbed' } + } + if (selector === AGGREGATE3_SELECTOR) { + 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 + } + } + return OPAQUE +} + +function decodeResultSlots(blob: Hex): BatchResultSlot[] { + try { + return [...(decodeAbiParameters(RESULT_TUPLE, blob)[0] as ReadonlyArray)] + } catch { + return [] + } +} + +function isFullyMocked(call: ClassifiedCall): boolean { + if (call.kind === 'stubbed') return true + if (call.kind === 'opaque') return false + return call.calls.every(isFullyMocked) +} + +/** Same upstream-as-base patch technique as `codec.ts`'s `resolveBatchResult`. */ +function resolveBatchResult(call: BatchCall, upstream?: Hex): Hex { + const base = upstream ? decodeResultSlots(upstream) : [] + + const slots = call.calls.map((inner, index) => { + const fallback = base[index] ?? { success: false, returnData: '0x' as Hex } + + if (inner.kind === 'stubbed') return { success: true, returnData: '0x' as Hex } + if (inner.kind === 'batch') { + const nestedUpstream = fallback.success ? fallback.returnData : undefined + return { success: true, returnData: resolveBatchResult(inner, nestedUpstream) } + } + return fallback + }) + + return encodeAbiParameters(RESULT_TUPLE, [slots]) +} 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..32ae355d5ec --- /dev/null +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -0,0 +1,593 @@ +import { parseUnits, type Hex } from 'viem' + +import { test, expect } from '../fixtures' +import { reply } from '../mocks/cowProtocolApi' +import { CHAIN_IDS } from '../support/constants' +import { mockEthFlowTransaction } from '../support/mockEthFlowTransaction' +import { mockFixedRateQuote } from '../support/mockFixedRateQuote' +import { mockSocketVerifier } from '../support/mockSocketVerifier' +import { seedTrader } from '../support/seedTrader' + +import type { CowProtocolApiMock } from '../mocks/cowProtocolApi' +import type { LaunchDarklyMock } from '../mocks/launchDarkly' +import type { SwapPage } from '../pages/SwapPage' +import type { BrowserContext } from '@playwright/test' + +/** + * 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 }, + context: BrowserContext, + 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 } }) + if (active === 'bungee') { + mockSocketVerifier(context) + } + } + + /** + * `/#/{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 — `bridgingSdk`'s available-provider set is a page-lifetime singleton + * (`tradingSdk/bridgingSdk.ts` seeds it once at module load), so a test that calls this twice to + * switch providers (`configureProviders` in between) needs a genuine reload for the switch to + * take effect; `page.reload()` re-reads whatever hash is already in the address bar, so it must + * run after the hash is set, not before. + */ + 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) + await swapPage.page.reload() + await swapPage.unlockIfNeeded() + } + + test('[CC-01] Cross-chain swap UI: accessible via Swap form', async ({ swapPage, wallet, mocks, context }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + + await configureProviders(mocks, context, '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, context, '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() + }) + + test('[CC-02] Cross-chain swap: Near provider', async ({ + swapPage, + tradePage, + wallet, + confirmModal, + mocks, + context, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, context, '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(swapRecipientAddress?.toLowerCase()).not.toBe(wallet.address.toLowerCase()) + + // 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 posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + + await swapPage.clickPrimaryAction() + await confirmModal.confirmButton.click() + await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) + + posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE) + // 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('[CC-03] Cross-chain swap: Bungee provider', async ({ + swapPage, + tradePage, + wallet, + confirmModal, + mocks, + context, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, context, '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 posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) + + await swapPage.clickPrimaryAction() + await confirmModal.confirmButton.click() + await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) + + posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE) + // 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('[CC-13] Cross-chain: ETH-flow source — native ETH sent cross-chain', async ({ + swapPage, + wallet, + confirmModal, + mocks, + context, + }) => { + seedTrader(mocks, wallet, MAINNET, { balances: { [NATIVE_ETH]: INITIAL_ETH_BALANCE } }) + await configureProviders(mocks, context, '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.confirmButton.click() + + // 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('[CC-15] Cross-chain: swap to Solana — SOL or SPL token as destination', async ({ + swapPage, + wallet, + mocks, + context, + }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, context, '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('[CC-17] Cross-chain: swap to Bitcoin — BTC as destination', async ({ swapPage, wallet, mocks, context }) => { + seedTrader(mocks, wallet, MAINNET, { + balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, + }) + await configureProviders(mocks, context, '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('[CC-26] Cross-chain: calculation parity — form Receive equals bridge Expected to receive', async ({ + swapPage, + wallet, + mocks, + context, + }) => { + 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, context, 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') + const bridgeExpectedToReceive = await swapPage.routePanel.bridgeExpectedToReceive().getAttribute('title') + expect(formReceive).toBe(bridgeExpectedToReceive) + 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('[CC-27] Cross-chain: calculation parity — bridge Min. to deposit equals swap Min. to receive', async ({ + swapPage, + wallet, + mocks, + context, + }) => { + 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, context, 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') + const bridgeMinToDeposit = await swapPage.routePanel.bridgeMinToDeposit().getAttribute('title') + expect(bridgeMinToDeposit).toBe(swapMinToReceive) + } + }) +}) diff --git a/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts b/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts index e3dcedca071..3aabad6fa68 100644 --- a/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts +++ b/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts @@ -1,3 +1,5 @@ +import type { Address } from 'viem' + import { bungeeAffiliateCode } from '@cowprotocol/common-const' import { isDev, isProd, isStaging } from '@cowprotocol/common-utils' import { @@ -30,6 +32,24 @@ export const acrossBridgeProvider = new AcrossBridgeProvider() export const nearIntentsBridgeProvider = new NearIntentsBridgeProvider({ apiKey: process.env.REACT_APP_NEAR_API_KEY }) +// `ATTESTATOR_ADDRESS` in `@cowprotocol/sdk-bridging`, not exported — duplicated here since the +// e2e bypass below needs to match it exactly. +const NEAR_INTENTS_E2E_ATTESTATOR_ADDRESS: Address = '0x0073DD100b51C555E41B2a452E5933ef76F42790' + +// e2e tests mock Near Intents' quote/attestation endpoints with a captured response pair that +// isn't (and can't practically be) signed by Near's real attestor key — `recoverDepositAddress` +// is a live digital-signature check, not something a mocked pair can satisfy, so it's patched out +// entirely for e2e rather than trying to forge a valid signature. See +// `apps/cowswap-e2e-tests/src/mocks/nearIntents.ts`. +if (typeof window !== 'undefined' && window.__COWSWAP_E2E__) { + nearIntentsBridgeProvider.recoverDepositAddress = async ({ quote }) => ({ + 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..148e2b4ace7 100644 --- a/libs/common-hooks/src/useFeatureFlags.ts +++ b/libs/common-hooks/src/useFeatureFlags.ts @@ -7,11 +7,26 @@ 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`. + if (typeof window !== 'undefined' && window.__COWSWAP_E2E_FEATURE_FLAGS__) { + return { ...flags, ...window.__COWSWAP_E2E_FEATURE_FLAGS__ } + } + return flags // return { ...defaults, ...flags } } From 388e897338e628c297ed409d2d9b09171e0f9a36 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Tue, 11 Aug 2026 16:33:41 +0200 Subject: [PATCH 02/35] docs: update e2e AGENTS.md --- apps/cowswap-e2e-tests/AGENTS.md | 96 +++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/AGENTS.md b/apps/cowswap-e2e-tests/AGENTS.md index 821994e957f..98adbcf7f93 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-11 source_of_truth_scope: cowswap-e2e-tests app-specific conventions, mocks, and debugging notes --- @@ -125,6 +125,100 @@ 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. +## 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`/ + `mockSocketVerifier` intercept by that exact URL (tx receipts, native-balance multicalls). Plenty of + other calls the *app itself* makes — Bungee's on-chain SocketVerifier check, `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 these is host-agnostic: `context.route('**/*', ...)`, + decode the JSON-RPC body, and match by `method` (see `mockSocketVerifier.ts` and + `mockEthEstimateGas` in `mockEthFlowTransaction.ts`), never by URL. +- **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 From 24d23cef03b10fa8498bd0b066f0000b709080a8 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 12 Aug 2026 13:38:44 +0200 Subject: [PATCH 03/35] test(e2e): mock all rpc node requests --- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 36 ++- .../src/mocks/allowances/index.ts | 12 + .../src/mocks/ethBlockNumber.ts | 72 +++++ .../src/mocks/ethEstimateGas.ts | 47 +++ .../cowswap-e2e-tests/src/mocks/ethGetCode.ts | 107 +++++++ .../src/mocks/ethGetTransactionCount.ts | 73 +++++ .../cowswap-e2e-tests/src/mocks/multicall3.ts | 290 ++++++++++++++++++ apps/cowswap-e2e-tests/src/pages/SwapPage.ts | 1 + .../src/support/logUnmockedRpcRequests.ts | 115 +++++++ .../src/support/mockApproveTransaction.ts | 88 +++++- .../src/support/mockEthFlowTransaction.ts | 113 +++++-- .../src/support/mockSocketVerifier.ts | 58 ++-- .../src/support/setupTestConditions.test.ts | 1 + 13 files changed, 964 insertions(+), 49 deletions(-) create mode 100644 apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts create mode 100644 apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts create mode 100644 apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts create mode 100644 apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts create mode 100644 apps/cowswap-e2e-tests/src/mocks/multicall3.ts create mode 100644 apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 8dbcde520e7..46a78b9d37d 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -4,7 +4,12 @@ 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' +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 { installSafeSdk, type SafeSdkMock } from '../mocks/safeSdk' import { installTokenLists, type TokenListsMock } from '../mocks/tokenLists' @@ -16,6 +21,7 @@ import { HeaderPage } from '../pages/HeaderPage' import { LimitPage } from '../pages/LimitPage' import { SwapPage } from '../pages/SwapPage' import { TwapPage } from '../pages/TwapPage' +import { logUnmockedRpcRequests } from '../support/logUnmockedRpcRequests' import { mockOrderPosting } from '../support/mockOrderPosting' import { createSetupTestConditions, type SetupTestConditions } from '../support/setupTestConditions' @@ -37,6 +43,7 @@ export interface SharedFixtures { allowances: AllowancesMock balances: BalancesMock cowApi: CowProtocolApiMock + ethGetCode: EthGetCodeMock tokenLists: TokenListsMock safeSdk: SafeSdkMock bungee: BungeeMock @@ -95,7 +102,15 @@ export const sharedFixtures: Fixtures< // teardown. A plain (non-auto) fixture is only set up when requested, so without this the // whole mock stack — including `assertNoUnmatched()` — would silently never run. mocks: [ - async ({ context }, use) => { + async ({ context }, use, testInfo) => { + // Diagnostic-only, opt-in via `LOG_UNMOCKED_RPC=1` — see `logUnmockedRpcRequests`'s own doc + // comment. Registered before every other mock below (and therefore before any manually + // installed one too, e.g. `mockSocketVerifier`, since those only get added once the test body + // starts running) so it only ever sees requests nothing else claimed. + if (process.env.LOG_UNMOCKED_RPC) { + logUnmockedRpcRequests({ context, worker: testInfo.workerIndex, test: testInfo.title }) + } + // The order book API is mocked, so updaters can poll much faster without adding real load. // See `getUpdaterInterval` in `libs/common-const/src/common.ts`. await context.addInitScript(() => { @@ -105,6 +120,11 @@ export const sharedFixtures: Fixtures< const allowances = installAllowances(context) const balances = installBalances(context) const cowApi = await installCowProtocolApi(context) + const ethGetCode = installEthGetCode(context) + installEthBlockNumber(context) + installEthEstimateGas(context) + installEthGetTransactionCount(context) + installMulticall3(context, { allowances }) const tokenLists = installTokenLists(context) const safeSdk = installSafeSdk(context) const bungee = installBungee(context) @@ -112,8 +132,20 @@ export const sharedFixtures: Fixtures< const launchDarkly = installLaunchDarkly(context) const usdPrices = installUsdPrices(context) - await use({ allowances, balances, cowApi, tokenLists, safeSdk, bungee, nearIntents, launchDarkly, usdPrices }) + await use({ + allowances, + balances, + cowApi, + ethGetCode, + tokenLists, + safeSdk, + bungee, + nearIntents, + launchDarkly, + usdPrices, + }) + ethGetCode.reset() tokenLists.reset() bungee.reset() nearIntents.reset() diff --git a/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts b/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts index 0ece5fde859..c982afa2b44 100644 --- a/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts +++ b/apps/cowswap-e2e-tests/src/mocks/allowances/index.ts @@ -33,6 +33,15 @@ export interface AllowancesMock { /** Non-fatal warning about queried-but-unconfigured owners and decode failures. */ reportUnknownOwners(): void reset(): void + /** + * Resolve one already-decoded allowance read against the live fixture+override state, bypassing + * the URL-scoped route handler below entirely. Used by `mocks/multicall3.ts`'s host-agnostic + * `aggregate3` handler, which needs the exact same "override wins, else fixture, else 0" answer + * regardless of which real RPC host the app's independent read-only client happened to pick for a + * given batch — going through the same `resolveFor` the route handler itself uses keeps + * `reads()`/`reportUnknownOwners()` bookkeeping accurate no matter which handler answered. + */ + resolve(chainId: number, call: AllowanceCall): bigint } interface JsonRpcEntry { @@ -156,6 +165,9 @@ export function installAllowances(context: BrowserContext): AllowancesMock { unknownOwners.clear() problems.length = 0 }, + resolve(chainId, call) { + return resolveFor(chainId, call) + }, } } diff --git a/apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts b/apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts new file mode 100644 index 00000000000..a2c2f8eef9e --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts @@ -0,0 +1,72 @@ +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string +} + +// An arbitrary-but-real mainnet block number, captured once — nothing in this suite asserts on the +// actual value, so a fixed one is enough to remove the real dependency entirely. +const HARDCODED_BLOCK_NUMBER = '0x188bc6f' + +/** + * `eth_blockNumber` goes out as a single, standalone JSON-RPC call (no Multicall3 batching, same + * as `eth_getCode`) to whichever real RPC/Infura endpoint the app's own independent client picked. + * Traced with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: same class of real, rate-limited + * dependency as `eth_getCode` (`installEthGetCode`) that 429s under `pnpm e2e`'s full parallel + * load. + */ +export function installEthBlockNumber(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.some((entry) => entry?.method === 'eth_blockNumber')) return route.fallback() + + if (entries.every((entry) => entry?.method === 'eth_blockNumber')) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: HARDCODED_BLOCK_NUMBER })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries) + }) +} + +/** + * A mixed batch alongside something else this mock doesn't own — patch only the `eth_blockNumber` + * slots and merge with the real response for the rest, with the same defensive try/catch as the + * allowances/SocketVerifier mocks so a flaky real upstream can't take the whole batch down. + */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[]): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + const payload = upstreamEntries.map((entry) => { + const original = byId.get((entry as JsonRpcEntry).id) + if (!original || original.method !== 'eth_blockNumber') return entry + return { jsonrpc: '2.0', id: original.id, result: HARDCODED_BLOCK_NUMBER } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts b/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts new file mode 100644 index 00000000000..6315984b754 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethEstimateGas.ts @@ -0,0 +1,47 @@ +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string +} + +/** A generous flat estimate — never actually spent, since whatever gets estimated (a `createOrder()` + * call, a `permit()`-based approval, ...) is either stubbed itself or never sent for real. */ +const FAKE_GAS_ESTIMATE = '0x7a120' as const + +/** + * Before sending almost any on-chain tx, the app estimates gas for it via its own default public + * RPC — which, traced live, is *not* `REACT_APP_NETWORK_URL_{chainId}` at all (that only backs this + * suite's own wallet-side dispatch/proxy) but whichever of the app's own hardcoded providers + * (Infura, the WalletConnect RPC relay, ...) it happens to pick, unpredictable and outside this + * test's control. Left unmocked, that's a REAL simulation against the wallet's REAL on-chain state + * (e.g. zero balance, since this is a shared test key with no real funds) and either fails outright + * or, under `pnpm e2e`'s full parallel load, 429s from the real, rate-limited host. + * + * Originally lived only inside `mockEthFlowTransaction` (for the ETH-flow `createOrder()` call + * specifically), but tracing with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1` found the exact + * same `eth_estimateGas` calls, for an EIP-2612 `permit()` approval (`0xd505accf`), in tests that + * never touch `mockEthFlowTransaction` at all — e.g. the cross-chain-to-Solana/Bitcoin tests. Since + * every gas estimate this suite ever needs is fake regardless of what it's for, this is installed + * unconditionally rather than only for ETH-flow tests. Matched host-agnostically by JSON-RPC method + * (like `mockSocketVerifier`) rather than by URL, since there's no fixed host to route on. + */ +export function installEthEstimateGas(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.length || !entries.every((entry) => entry?.method === 'eth_estimateGas')) return route.fallback() + + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: FAKE_GAS_ESTIMATE })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + }) +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts b/apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts new file mode 100644 index 00000000000..105a3fee22d --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethGetCode.ts @@ -0,0 +1,107 @@ +import type { BrowserContext, Route } from '@playwright/test' + +export interface EthGetCodeMock { + /** Override the bytecode reported for `address` — e.g. a non-`'0x'` value to simulate a + * smart-contract wallet instead of the default plain EOA. */ + set(address: string, code: string): void + /** Drop every override, back to `'0x'` (plain EOA) for every address. */ + reset(): void +} + +interface JsonRpcEntry { + id: number | string + method: string + params?: [address?: string, ...unknown[]] +} + +/** + * `eth_getCode` (wallet-type detection, e.g. `useIsSmartContractWallet`-style checks run for the + * connected wallet on most page loads) goes out as a single, standalone JSON-RPC call to whichever + * real RPC/Infura endpoint the app's own independent client picked — not the wallet's own + * `REACT_APP_NETWORK_URL_`-overridden channel, and not batched via Multicall3 either (it's + * its own top-level RPC method, not a contract `eth_call`), so none of the other mocks ever see it. + * Traced with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: it accounted for the large majority of + * 429s from a real, rate-limited Infura key once enough parallel workers hit it at once under + * `pnpm e2e`. This suite's mock wallet is always a plain EOA, so reporting no code (`'0x'`) for + * every address by default removes that real dependency entirely. `set()` is there for a future + * test that needs to simulate a smart-contract wallet instead. + */ +export function installEthGetCode(context: BrowserContext): EthGetCodeMock { + const overrides = new Map() + + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.some((entry) => entry?.method === 'eth_getCode')) return route.fallback() + + if (entries.every((entry) => entry?.method === 'eth_getCode')) { + const payload = entries.map((entry) => ({ + jsonrpc: '2.0', + id: entry.id, + result: resolveCode(entry, overrides), + })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries, overrides) + }) + + return { + set(address, code) { + overrides.set(address.toLowerCase(), code) + }, + reset() { + overrides.clear() + }, + } +} + +/** + * A mixed batch alongside something else this mock doesn't own — patch only the `eth_getCode` + * slots and merge with the real response for the rest, with the same defensive try/catch as the + * allowances/SocketVerifier mocks so a flaky real upstream can't take the whole batch down. + */ +async function fulfillFromUpstream( + route: Route, + entries: JsonRpcEntry[], + overrides: Map, +): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + const payload = upstreamEntries.map((entry) => { + const original = byId.get((entry as JsonRpcEntry).id) + if (!original || original.method !== 'eth_getCode') return entry + return { jsonrpc: '2.0', id: original.id, result: resolveCode(original, overrides) } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} + +function resolveCode(entry: JsonRpcEntry, overrides: Map): string { + const address = entry.params?.[0] + const override = address ? overrides.get(address.toLowerCase()) : undefined + return override ?? '0x' +} diff --git a/apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts b/apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts new file mode 100644 index 00000000000..90bc88d5e2f --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/ethGetTransactionCount.ts @@ -0,0 +1,73 @@ +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string +} + +// This suite's test wallets are always fresh (a nonce of 0 is genuinely accurate, not just +// convenient), so a single hardcoded value covers every address/block-tag combination. +const HARDCODED_TRANSACTION_COUNT = '0x0' + +/** + * `eth_getTransactionCount` (the wallet's own nonce) goes out as a single, standalone JSON-RPC call + * (no Multicall3 batching, same as `eth_blockNumber`/`eth_getCode`) to whichever real RPC/Infura + * endpoint the app's own independent client picked. Traced with + * `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: same class of real, rate-limited dependency as + * `eth_blockNumber` (`installEthBlockNumber`) that 429s under `pnpm e2e`'s full parallel load. + */ +export function installEthGetTransactionCount(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + if (!entries.some((entry) => entry?.method === 'eth_getTransactionCount')) return route.fallback() + + if (entries.every((entry) => entry?.method === 'eth_getTransactionCount')) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: HARDCODED_TRANSACTION_COUNT })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries) + }) +} + +/** + * A mixed batch alongside something else this mock doesn't own — patch only the + * `eth_getTransactionCount` slots and merge with the real response for the rest, with the same + * defensive try/catch as the allowances/SocketVerifier mocks so a flaky real upstream can't take + * the whole batch down. + */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[]): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + const payload = upstreamEntries.map((entry) => { + const original = byId.get((entry as JsonRpcEntry).id) + if (!original || original.method !== 'eth_getTransactionCount') return entry + return { jsonrpc: '2.0', id: original.id, result: HARDCODED_TRANSACTION_COUNT } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} diff --git a/apps/cowswap-e2e-tests/src/mocks/multicall3.ts b/apps/cowswap-e2e-tests/src/mocks/multicall3.ts new file mode 100644 index 00000000000..109f1243e01 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/multicall3.ts @@ -0,0 +1,290 @@ +import { decodeAbiParameters, encodeAbiParameters, type Hex } from 'viem' + +import { areAddressesEqual, getAddressKey } from '@cowprotocol/cow-sdk' + +import { AGGREGATE3_SELECTOR, ALLOWANCE_SELECTOR, encodeAllowanceResult, type AllowanceCall } from './allowances/codec' +import { normalizeRpcUrl, resolveRpcChainIds } from './allowances/rpcUrls' + +import { CHAIN_IDS } from '../support/constants' + +import type { AllowancesMock } from './allowances' +import type { BrowserContext, Route } from '@playwright/test' + +/** Canonical Multicall3 deployment address — identical on every EVM chain. */ +const MULTICALL3_ADDRESS = '0xca11bde05977b3631167028862be2a173976ca11' +/** `getEthBalance(address)` on Multicall3 itself. */ +const GET_ETH_BALANCE_SELECTOR = '0x4d2301cc' +/** ERC20 `balanceOf(address)`. */ +const BALANCE_OF_SELECTOR = '0x70a08231' + +const CALL3_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, +] as const + +const RESULT_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, +] as const + +const ADDRESS_PAIR = [{ type: 'address' }, { type: 'address' }] as const +const UINT256 = [{ type: 'uint256' }] as const +const ZERO_UINT256 = encodeAbiParameters(UINT256, [0n]) + +interface BatchCall { + kind: 'batch' + calls: ClassifiedCall[] +} +type ClassifiedCall = AllowanceCall | BatchCall | OpaqueCall | UnknownCall | ZeroCall +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] +} +interface OpaqueCall { + kind: 'opaque' +} +interface ResultSlot { + success: boolean + returnData: Hex +} +interface UnknownCall { + kind: 'unknown' +} + +interface ZeroCall { + kind: 'zero' +} + +const OPAQUE: OpaqueCall = { kind: 'opaque' } +const UNKNOWN: UnknownCall = { kind: 'unknown' } +const ZERO: ZeroCall = { kind: 'zero' } + +/** + * Host-agnostic fallback for Multicall3's `aggregate3` — the single biggest source of real, + * rate-limited RPC traffic seen in `logUnmockedRpcRequests`' output (`LOG_UNMOCKED_RPC=1`): 87 of + * ~143 unmocked lines in one traced run, 22 of them real `429`s. The app's independent read-only + * RPC client (see `mockSocketVerifier.ts`'s doc comment, and the cross-chain-swaps `AGENTS.md` + * note on it) doesn't reliably use the wallet's own `REACT_APP_NETWORK_URL_` endpoint, so + * `mocks/allowances`'s URL-scoped handler misses any batch that lands on a different real host + * (Infura, the WalletConnect RPC relay, publicnode, ...). `mockSocketVerifier` is host-agnostic but + * only installed for Bungee-provider cross-chain tests, and only resolves its own SocketVerifier + * selectors — everything else inside the batch still falls through to a real (if now safely + * try/caught) `route.fetch()`. + * + * This mock closes that gap generally: it engages for *any* `eth_call` whose decoded body is (or + * contains, once batches are unwrapped) an `aggregate3` call to the canonical Multicall3 address, + * *except* on a host `mocks/allowances` already owns (any `REACT_APP_NETWORK_URL_` + * override) — those defer immediately via `route.fallback()`, since `mocks/allowances`'s + * URL-scoped handler already knows the exact chain id for that host and resolves allowances + * correctly; this mock's own `chainIdFromUrl` is a heuristic (see its doc comment) that guesses + * mainnet absent better information, and guessing wrong for a *configured* host — e.g. Sepolia's + * `ethereum-sepolia-rpc.publicnode.com` — silently resolved a seeded allowance against the wrong + * chain key and made it read back as unconfigured (0), breaking `[LO-01]` and any other + * Sepolia-based test relying on `mocks.allowances.set(...)`. So this mock only ever engages for + * hosts *not* in that map — the genuinely unpredictable ones (Infura, the WalletConnect RPC relay, + * publicnode-for-a-different-chain, ...) `mocks/allowances` was never scoped to reach — and fully + * resolves those locally. Anything it doesn't own inside the batch (including SocketVerifier's own + * selectors, when `mockSocketVerifier` isn't active) gets a safe empty success slot instead of a + * real network round-trip. + */ +export function installMulticall3(context: BrowserContext, deps: { allowances: AllowancesMock }): void { + const configuredChainIdByUrl = resolveRpcChainIds() + + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + if (isConfiguredHost(request.url(), configuredChainIdByUrl)) return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] + try { + body = JSON.parse(request.postData() ?? '') as JsonRpcEntry | JsonRpcEntry[] + } catch { + return route.fallback() + } + + const entries = Array.isArray(body) ? body : [body] + const classified = entries.map((entry) => { + if (entry.method !== 'eth_call') return OPAQUE + const call = entry.params?.[0] + if (!call?.to || !call?.data) return OPAQUE + return classifyTopLevel(call.to, call.data) + }) + + if (classified.every((call) => call.kind === 'opaque')) return route.fallback() + + const chainId = chainIdFromUrl(request.url()) + + if (classified.every((call) => call.kind === 'batch')) { + const payload = entries.map((entry, index) => ({ + jsonrpc: '2.0', + id: entry.id, + result: encodeBatchResult(classified[index] as BatchCall, chainId, deps.allowances), + })) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), + }) + } + + return fulfillFromUpstream(route, entries, classified, chainId, deps.allowances) + }) +} + +/** + * Best-effort chain id for a host-agnostic request that's *not* on a configured host (those defer + * entirely, see `isConfiguredHost`) — there's no `REACT_APP_NETWORK_URL_` -> chain id + * mapping for an unpredictable host by definition, so this looks for the `chainId=eip155:` + * query param WalletConnect's RPC relay puts on its URLs (e.g. + * `rpc.walletconnect.org/v1/?chainId=eip155%3A1&...`), then falls back to mainnet. This is a + * heuristic based on what `logUnmockedRpcRequests` has actually observed (every logged + * `aggregate3` occurrence on an unconfigured host so far has been mainnet), not a general solution + * — a non-mainnet occurrence would resolve allowances against the wrong chain and needs a real fix + * (threading the chain id through some other signal) rather than another special case here. + */ +function chainIdFromUrl(rawUrl: string): number { + try { + const raw = new URL(rawUrl).searchParams.get('chainId') + const match = raw ? /^eip155:(\d+)$/.exec(raw) : null + if (match) return Number(match[1]) + } catch { + // Malformed URL — fall through to the mainnet default below. + } + return CHAIN_IDS.MAINNET +} + +/** + * Classifies one inner call by selector alone (not `to`) — same rationale as + * `allowances/codec.ts`'s `classifyCall`: calldata that decodes as `aggregate3` is a nested batch + * whatever it's addressed to, and unrecognized selectors default to `unknown` rather than + * `opaque`, since (unlike the top-level entry) this is always resolved locally once the outer + * `aggregate3` shape has been recognized. + */ +function classifyInner(to: string, data: string): ClassifiedCall { + const selector = data.slice(0, 10).toLowerCase() + + if (selector === ALLOWANCE_SELECTOR) return decodeAllowance(to, data) + if (selector === GET_ETH_BALANCE_SELECTOR || selector === BALANCE_OF_SELECTOR) return ZERO + if (selector === AGGREGATE3_SELECTOR) return decodeBatch(data, classifyInner) + return UNKNOWN +} + +/** Classifies the top-level `eth_call` — only an `aggregate3` call to Multicall3 itself engages this mock. */ +function classifyTopLevel(to: string, data: string): ClassifiedCall { + const selector = data.slice(0, 10).toLowerCase() + if (!areAddressesEqual(to, MULTICALL3_ADDRESS) || selector !== AGGREGATE3_SELECTOR) return OPAQUE + return decodeBatch(data, classifyInner) +} + +/** Mirrors `allowances/codec.ts`'s `classifyAllowance` decode step, normalizing via the same `getAddressKey`. */ +function decodeAllowance(to: string, data: string): ClassifiedCall { + try { + const [owner, spender] = decodeAbiParameters(ADDRESS_PAIR, `0x${data.slice(10)}` as Hex) + return { kind: 'allowance', token: getAddressKey(to), owner: getAddressKey(owner), spender: getAddressKey(spender) } + } catch { + return UNKNOWN + } +} + +/** Decodes an `aggregate3` payload into its inner calls, recursing for nested batches. */ +function decodeBatch(data: string, classify: (to: string, data: string) => ClassifiedCall): ClassifiedCall { + try { + const [calls] = decodeAbiParameters(CALL3_TUPLE, `0x${data.slice(10)}` as Hex) + return { + kind: 'batch', + calls: (calls as ReadonlyArray<{ target: string; callData: Hex }>).map((c) => classify(c.target, c.callData)), + } + } catch { + return OPAQUE + } +} + +function encodeBatchResult(call: BatchCall, chainId: number, allowances: AllowancesMock): Hex { + const slots: ResultSlot[] = call.calls.map((inner) => resolveSlot(inner, chainId, allowances)) + return encodeAbiParameters(RESULT_TUPLE, [slots]) +} + +/** + * A mixed batch alongside something this mock doesn't recognize as `aggregate3`-to-Multicall3 (rare + * — the log evidence shows this almost always arrives as a single `eth_call`) — same defensive + * try/catch as every other host-agnostic mock in this suite (`mockSocketVerifier`, + * `installEthBlockNumber`, `installEthGetCode`), patching only the recognized slots and forwarding + * the rest of the real response untouched. + */ +async function fulfillFromUpstream( + route: Route, + entries: JsonRpcEntry[], + classified: ClassifiedCall[], + chainId: number, + allowances: AllowancesMock, +): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + + const classifiedById = new Map() + entries.forEach((entry, index) => classifiedById.set(entry.id, classified[index])) + + const payload = upstreamEntries.map((entry) => { + const call = classifiedById.get(entry.id) + if (!call || call.kind !== 'batch') return entry + return { jsonrpc: '2.0', id: entry.id, result: encodeBatchResult(call, chainId, allowances) } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} + +/** Whether `rawUrl` is one of `mocks/allowances`'s own `REACT_APP_NETWORK_URL_`-configured + * hosts — if so, this mock must not answer at all; see `installMulticall3`'s doc comment. */ +function isConfiguredHost(rawUrl: string, configuredChainIdByUrl: Map): boolean { + try { + return configuredChainIdByUrl.has(normalizeRpcUrl(rawUrl)) + } catch { + return false + } +} + +/** + * Resolves one decoded inner call to its Multicall3 result slot. + * + * - `allowance` reads through `deps.allowances`'s live fixture/override state, so + * `mocks.allowances.set(...)` is honored no matter which real host answered the batch. + * - `zero` (Multicall3's own `getEthBalance` and ERC20 `balanceOf`) always returns `0`: balances in + * this suite are tracked via the balances-watcher SSE mock (`mocks/balances`), not via RPC reads, + * so there's no existing mocked state to reuse here, and these Multicall3 reads are typically for + * auxiliary/throwaway addresses (e.g. a bridging deposit address), not the tracked test wallet. A + * `set()`-style override could be added later if a specific test needs a non-zero value. + * - `batch` recurses; `unknown`/`opaque` get a safe empty success slot rather than ever triggering a + * real `route.fetch()` — the core fix this mock exists for. + */ +function resolveSlot(call: ClassifiedCall, chainId: number, allowances: AllowancesMock): ResultSlot { + if (call.kind === 'allowance') { + return { success: true, returnData: encodeAllowanceResult(allowances.resolve(chainId, call)) } + } + if (call.kind === 'zero') { + return { success: true, returnData: ZERO_UINT256 } + } + if (call.kind === 'batch') { + return { success: true, returnData: encodeBatchResult(call, chainId, allowances) } + } + return { success: true, returnData: '0x' } +} diff --git a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts index bf297385007..1a8009ef31a 100644 --- a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts @@ -150,6 +150,7 @@ export class SwapPage implements TradePage { } async clickPrimaryAction(): Promise { + await expect(this.primaryActionButton).toBeEnabled() await this.primaryActionButton.click() } } diff --git a/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts b/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts new file mode 100644 index 00000000000..e0580f2454d --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/logUnmockedRpcRequests.ts @@ -0,0 +1,115 @@ +import { appendFile, mkdir } from 'node:fs/promises' +import path from 'node:path' + +import type { APIResponse, BrowserContext, Route } from '@playwright/test' + +export interface UnmockedRpcLoggerOpts { + context: BrowserContext + worker: number + test: string + logPath?: string +} + +export interface UnmockedRpcRequestLogEntry { + timestamp: string + worker: number + test: string + url: string + request: unknown + status: number + response: unknown + durationMs: number + error?: string +} + +const DEFAULT_LOG_PATH = path.join('test-results', 'unmocked-rpc-requests.log') + +/** + * Diagnostic tool for CC-03/CC-26/CC-27-style flakiness ("Error loading price" under `pnpm e2e`'s + * full parallel load, not reproducible running one test at a time): several mocks + * (`mockSocketVerifier`, `mocks.allowances`, ...) fall back to a real `route.fetch()` against + * whatever real RPC the app picked (e.g. `ethereum-rpc.publicnode.com`) whenever a batch isn't + * *fully* recognized — reliable for one test, but exactly the kind of real, rate-limited + * dependency that starts 429-ing once dozens of parallel workers hit it at once. + * + * Enable with `LOG_UNMOCKED_RPC=1`. Registers a catch-all route with the lowest possible priority + * — call this before installing any other mock (first thing in the `mocks` fixture) so every + * other, more specific handler gets first refusal via `route.fallback()`. Whatever reaches this + * one is, by construction, not mocked by anything else. For JSON-RPC-shaped bodies (the shape + * every blockchain RPC call in this suite uses — CoW API/Bungee/etc. traffic has different shapes + * and is already excluded), it performs the real request itself, logs the request and the real + * response (status, body — including a real 429) as one JSON line to `logPath`, then fulfills with + * that same real response so test behavior is completely unchanged; this is observation-only. + */ +export function logUnmockedRpcRequests(opts: UnmockedRpcLoggerOpts): void { + const { context, worker, test, logPath = DEFAULT_LOG_PATH } = opts + + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: unknown + try { + body = request.postDataJSON() + } catch { + return route.fallback() + } + if (!looksLikeJsonRpc(body)) return route.fallback() + + const startedAt = Date.now() + try { + const response = await route.fetch() + const responseBody = await readBody(response) + void appendEntry(logPath, { + timestamp: new Date(startedAt).toISOString(), + worker, + test, + url: request.url(), + request: body, + status: response.status(), + response: responseBody, + durationMs: Date.now() - startedAt, + }) + await route.fulfill({ response }) + } catch (error) { + void appendEntry(logPath, { + timestamp: new Date(startedAt).toISOString(), + worker, + test, + url: request.url(), + request: body, + status: 0, + response: null, + durationMs: Date.now() - startedAt, + error: String(error), + }) + await route.fallback() + } + }) +} + +/** Best-effort: a logging failure must never break the real request it's observing. */ +async function appendEntry(logPath: string, entry: UnmockedRpcRequestLogEntry): Promise { + try { + await mkdir(path.dirname(logPath), { recursive: true }) + await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8') + } catch { + // Diagnostic logging is best-effort only. + } +} + +function looksLikeJsonRpc(body: unknown): boolean { + const isEntry = (entry: unknown): boolean => + typeof entry === 'object' && entry !== null && typeof (entry as { method?: unknown }).method === 'string' + + return Array.isArray(body) ? body.length > 0 && body.every(isEntry) : isEntry(body) +} + +async function readBody(response: APIResponse): Promise { + const text = await response.text() + try { + return JSON.parse(text) + } catch { + return text + } +} diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts index 3de4c1e8845..ec42451004d 100644 --- a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts +++ b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts @@ -1,13 +1,20 @@ import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, erc20Abi, type Hex } from 'viem' +import { areAddressesEqual } from '@cowprotocol/cow-sdk' + import { RpcStub } from '../mockWallet/walletEngine' import type { MockWalletApi } from '../fixtures/mockWallet' import type { AllowancesMock } from '../mocks/allowances' -import type { BrowserContext } from '@playwright/test' +import type { BrowserContext, Route } from '@playwright/test' const FAKE_APPROVE_TX_HASH = `0x${'ab'.repeat(32)}` as const +/** `approve(address,uint256)` selector — what the preflight `eth_call` this mock also stubs is checking won't revert. */ +const APPROVE_SELECTOR = '0x095ea7b3' +/** ABI-encoded `true` — the only thing a `bool`-returning `eth_call` needs to report success. */ +const APPROVE_CALL_SUCCESS_RESULT = encodeAbiParameters([{ type: 'bool' }], [true]) + export interface MockApproveTransactionHandle { /** The raw amount decoded from the actual approve(spender, amount) calldata, once sent. */ getApprovedAmount(): bigint | undefined @@ -42,6 +49,14 @@ interface ReceiptContext { * `mocks.balances`/`mocks.allowances` intercept — bypassing the wallet entirely, so it needs its * own route stub. The allowance mock is also kept in sync, since faking the send doesn't change * anything the real allowance-read mock would otherwise report. + * + * Before ever reaching that stubbed `eth_sendTransaction`, the wallet-connector layer also fires a + * preflight, non-batched `eth_call` for the same `approve(address,uint256)` calldata — a + * simulate-before-sign check that the call won't revert. Tracing real RPC traffic + * (`LOG_UNMOCKED_RPC=1`) showed this going straight to a real, hardcoded provider (Infura) rather + * than any URL this suite controls, and getting rate-limited (HTTP 429) under `pnpm e2e`'s full + * parallel load — so it's matched host-agnostically by `to`/`data` (like `mockSocketVerifier.ts`) + * and answered with a successful ABI-encoded `true`, same as the real call would return. */ export async function mockApproveTransaction(opts: MockApproveTransactionOpts): Promise { const { context, wallet, allowances, chainId, token } = opts @@ -76,6 +91,8 @@ export async function mockApproveTransaction(opts: MockApproveTransactionOpts): await route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) }) + await context.route('**/*', (route) => handleApproveSimulationCall(route, token)) + return { getApprovedAmount: () => approvedAmount, } @@ -128,3 +145,72 @@ function buildReceiptRpcResponse( const isOurReceipt = entry.method === 'eth_getTransactionReceipt' && entry.params[0] === FAKE_APPROVE_TX_HASH return { jsonrpc: '2.0', id: entry.id, result: isOurReceipt ? buildApproveReceipt(ctx) : null } } + +/** + * Not observed in practice (this preflight is always a standalone, non-batched `eth_call`) — but if + * it ever turns up mixed with other, unrecognized calls, fetch the real upstream and patch in only + * the entries this mock actually understands, rather than fabricate data for the rest. Same + * try/catch → `route.fallback()` guard as `mockSocketVerifier.ts`'s `fulfillFromUpstream`, so a + * transient real-RPC hiccup here can't abort the whole request. + */ +async function fulfillApproveSimulationFromUpstream( + route: Route, + entries: JsonRpcEntry[], + matches: boolean[], +): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const matchedIds = new Set(entries.filter((_, i) => matches[i]).map((entry) => entry.id)) + const payload = upstreamEntries.map((entry) => + matchedIds.has(entry.id) ? { jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT } : entry, + ) + await route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + } catch { + await route.fallback() + } +} + +/** + * Answers the preflight `approve(address,uint256)` simulation `eth_call` (see the doc comment on + * `mockApproveTransaction`) with a successful `true`, host-agnostically. Unlike `mockSocketVerifier.ts`, + * this call is never wrapped in a Multicall3 batch in practice (confirmed by tracing real RPC + * traffic), so no batch-decoding is needed — just the single/array JSON-RPC envelope every route in + * this suite already has to handle. + */ +async function handleApproveSimulationCall(route: Route, token: string): Promise { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] | null + try { + // Unlike `route.request().postDataJSON()` elsewhere in this file (only ever called against a + // known JSON-RPC endpoint), this route sees every request in the page — `postDataJSON()` + // returns `null` rather than throwing for a POST with no/non-JSON body (e.g. an analytics + // beacon), so that has to be checked explicitly, not just guarded by try/catch. + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] | null + } catch { + return route.fallback() + } + if (!body) return route.fallback() + + const entries = Array.isArray(body) ? body : [body] + const matches = entries.map((entry) => isApproveSimulationCall(entry, token)) + if (!matches.some(Boolean)) return route.fallback() + + if (matches.every(Boolean)) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + return fulfillApproveSimulationFromUpstream(route, entries, matches) +} + +/** Matches the preflight `eth_call` simulating `approve(address,uint256)` against the same token this mock was set up for, before the real `eth_sendTransaction` is ever asked for. */ +function isApproveSimulationCall(entry: JsonRpcEntry | null | undefined, token: string): boolean { + if (entry?.method !== 'eth_call') return false + const call = entry.params?.[0] as { to?: string; data?: string } | undefined + if (!call?.to || !call?.data) return false + return areAddressesEqual(call.to, token) && call.data.toLowerCase().startsWith(APPROVE_SELECTOR) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts index 8e1b706a635..66633f4e426 100644 --- a/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts +++ b/apps/cowswap-e2e-tests/src/support/mockEthFlowTransaction.ts @@ -2,7 +2,7 @@ import { decodeAbiParameters, encodeAbiParameters, type Hex } from 'viem' import type { MockWalletApi } from '../fixtures/mockWallet' import type { RpcStub } from '../mockWallet/walletEngine' -import type { BrowserContext } from '@playwright/test' +import type { BrowserContext, Route } from '@playwright/test' const FAKE_ETH_FLOW_TX_HASH = `0x${'ef'.repeat(32)}` as const @@ -196,6 +196,8 @@ interface JsonRpcEntry { result?: unknown } +type TxLookupEntry = { kind: 'receipt' } | { kind: 'transaction' } + /** * Fakes the ETH-flow order-creation transaction end-to-end. Selling native ETH doesn't post an * off-chain EIP-712-signed order like every other trade in this suite — it sends an on-chain @@ -230,12 +232,10 @@ export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): let mined = false let filled = false - wallet.stubRpc('eth_sendTransaction', (({ params }) => { - const tx = params[0] as { value?: string; data?: Hex } - sentValue = BigInt(tx.value ?? '0x0') - orderParams = decodeEthFlowOrderParams(tx.data) - return FAKE_ETH_FLOW_TX_HASH - }) as RpcStub) + stubEthFlowSend(wallet, (value, order) => { + sentValue = value + orderParams = order + }) const classify = (entry: JsonRpcEntry): ClassifiedEntry => { if (entry.method === 'eth_getTransactionReceipt' && entry.params[0] === FAKE_ETH_FLOW_TX_HASH) { @@ -255,7 +255,7 @@ export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): entry.kind === 'receipt' || (entry.kind === 'call' && isFullyMocked(entry.call)) const buildResult = (classified: ClassifiedEntry, remainingBalance: bigint, upstream?: Hex): unknown => { - if (classified.kind === 'receipt') return mined ? buildReceipt() : null + if (classified.kind === 'receipt') return mined ? buildReceipt(FAKE_ETH_FLOW_TX_HASH) : null if (classified.kind === 'call') { if (classified.call.kind === 'ownBalance') return encodeAbiParameters(UINT256, [remainingBalance]) if (classified.call.kind === 'opaque') return undefined @@ -264,7 +264,7 @@ export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): return undefined } - await mockEthEstimateGas(context) + await mockEthFlowTxLookupFallback(context, wallet.address, () => mined) await context.route(rpcUrl, async (route) => { const body = route.request().postDataJSON() as JsonRpcEntry | JsonRpcEntry[] @@ -317,9 +317,9 @@ export async function mockEthFlowTransaction(opts: MockEthFlowTransactionOpts): } } -function buildReceipt(): unknown { +function buildReceipt(txHash: string): unknown { return { - transactionHash: FAKE_ETH_FLOW_TX_HASH, + transactionHash: txHash, status: '0x1', blockNumber: '0x1', blockHash: `0x${'cd'.repeat(32)}`, @@ -334,6 +334,43 @@ function buildReceipt(): unknown { } } +/** A plausible-looking, mined `eth_getTransactionByHash` result — mirrors `buildReceipt`'s made-up + * but shape-correct fields (same fake block, same flat gas figures), plus the sender/value/nonce + * fields a receipt doesn't carry but a full transaction object does. */ +function buildTransaction(txHash: string, from: string): unknown { + return { + hash: txHash, + blockNumber: '0x1', + blockHash: `0x${'cd'.repeat(32)}`, + transactionIndex: '0x0', + from, + to: null, + value: '0x0', + nonce: '0x0', + gas: FAKE_GAS_ESTIMATE, + gasPrice: '0x3b9aca00', + input: '0x', + type: '0x0', + v: '0x1', + r: `0x${'11'.repeat(32)}`, + s: `0x${'22'.repeat(32)}`, + } +} + +function buildTxLookupResult(entry: TxLookupEntry, mined: boolean, from: string): unknown { + if (!mined) return null + return entry.kind === 'receipt' ? buildReceipt(FAKE_ETH_FLOW_TX_HASH) : buildTransaction(FAKE_ETH_FLOW_TX_HASH, from) +} + +/** Recognizes `eth_getTransactionReceipt`/`eth_getTransactionByHash` for the ETH-flow creation tx, + * regardless of which entry in a batch it is. */ +function classifyTxLookup(entry: JsonRpcEntry): TxLookupEntry | undefined { + if (entry?.params?.[0] !== FAKE_ETH_FLOW_TX_HASH) return undefined + if (entry.method === 'eth_getTransactionReceipt') return { kind: 'receipt' } + if (entry.method === 'eth_getTransactionByHash') return { kind: 'transaction' } + return undefined +} + /** Decodes `createOrder(EthFlowOrder.Data)`'s single struct argument straight off the sent calldata. */ function decodeEthFlowOrderParams(data: Hex | undefined): EthFlowOrderParams | undefined { if (!data) return undefined @@ -347,19 +384,23 @@ function decodeEthFlowOrderParams(data: Hex | undefined): EthFlowOrderParams | u } /** - * Before ever calling `eth_sendTransaction` (stubbed by the caller), the app estimates gas for the - * real `createOrder()` call via its own default public RPC — which, traced live, is *not* - * `REACT_APP_NETWORK_URL_{chainId}` at all (that only backs this suite's own wallet-side - * dispatch/proxy) but whichever of the app's own hardcoded providers (Infura, the WalletConnect RPC - * relay, ...) it happens to pick, unpredictable and outside this test's control. Left unmocked, - * that estimate is a REAL simulation against the wallet's REAL on-chain balance (zero, since this - * is a shared test key with no real funds) and fails with a genuine "exceeds the balance of the - * account" error — well before the stubbed send is ever reached. Matched host-agnostically by - * JSON-RPC method (like `mockSocketVerifier`) rather than by URL, since there's no fixed host to - * route on. + * Same class of bug documented on `mockEthEstimateGas` (now `installEthEstimateGas`), but for the + * two polls the app runs *after* sending the creation tx rather than before it: tracing real RPC + * traffic for the bridging ETH-flow path (`[CC-13]`) found `eth_getTransactionReceipt` AND + * `eth_getTransactionByHash` for this exact tx hash going out to a real Infura/WalletConnect-relay + * host that sometimes 429s — not the configured `REACT_APP_NETWORK_URL_{chainId}` this file's + * `context.route(rpcUrl, ...)` handler below is scoped to, so that handler's own (receipt-only) + * mocking never saw them. Registered host-agnostically, alongside `installEthEstimateGas`, as a + * second line of defense: for the configured RPC host, `context.route(rpcUrl, ...)` (registered + * after this one) still wins and answers first, so there's no double-handling; this one only ever + * fires for the *other*, unpredictable hosts the app's own independent client happens to pick. */ -async function mockEthEstimateGas(context: BrowserContext): Promise { - await context.route('**/*', async (route) => { +async function mockEthFlowTxLookupFallback( + context: BrowserContext, + from: string, + isMined: () => boolean, +): Promise { + await context.route('**/*', async (route: Route) => { const request = route.request() if (request.method() !== 'POST') return route.fallback() let body: JsonRpcEntry | JsonRpcEntry[] @@ -369,9 +410,29 @@ async function mockEthEstimateGas(context: BrowserContext): Promise { return route.fallback() } const entries = Array.isArray(body) ? body : [body] - if (!entries.length || !entries.every((e) => e?.method === 'eth_estimateGas')) return route.fallback() - - const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: FAKE_GAS_ESTIMATE })) + const classified = entries.map(classifyTxLookup) + if (!entries.length || classified.some((c) => !c)) return route.fallback() + + const mined = isMined() + const payload = entries.map((entry, i) => ({ + jsonrpc: '2.0', + id: entry.id, + result: buildTxLookupResult(classified[i] as TxLookupEntry, mined, from), + })) return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) }) } + +/** Wires the ETH-flow creation tx's `eth_sendTransaction` stub, decoding the sent value/order struct + * before handing them off to the caller — pulled out of `mockEthFlowTransaction` itself purely to + * keep that function under this repo's `max-lines-per-function` limit. */ +function stubEthFlowSend( + wallet: Pick, + onSent: (value: bigint, order: EthFlowOrderParams | undefined) => void, +): void { + wallet.stubRpc('eth_sendTransaction', (({ params }) => { + const tx = params[0] as { value?: string; data?: Hex } + onSent(BigInt(tx.value ?? '0x0'), decodeEthFlowOrderParams(tx.data)) + return FAKE_ETH_FLOW_TX_HASH + }) as RpcStub) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts index 9fba8b78de7..b6fa54deec6 100644 --- a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts +++ b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts @@ -99,26 +99,7 @@ export function mockSocketVerifier(context: BrowserContext): void { }) } - // Some entries need real data (fully opaque, or a batch only partially recognized) — fetch - // upstream and patch in only what's actually mocked, same merge technique as the allowances mock. - const upstream = await route.fetch() - const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] - const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] - - const classifiedById = new Map() - entries.forEach((entry, i) => classifiedById.set(entry.id, classified[i])) - - const payload = upstreamEntries.map((entry) => { - const classifiedEntry = classifiedById.get(entry.id) - if (!classifiedEntry || classifiedEntry.kind === 'opaque') return entry - const upstreamResult = typeof entry.result === 'string' ? (entry.result as Hex) : undefined - return { jsonrpc: '2.0', id: entry.id, result: buildResult(classifiedEntry, upstreamResult) } - }) - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), - }) + return fulfillFromUpstream(route, entries, classified) }) } @@ -166,6 +147,43 @@ function decodeResultSlots(blob: Hex): BatchResultSlot[] { } } +/** + * Some entries need real data (fully opaque, or a batch only partially recognized) — fetch + * upstream and patch in only what's actually mocked, same merge technique as the allowances mock. + * This is *always* the path taken here (the SocketVerifier call is never alone in its batch, see + * `classifyCall`'s doc comment), so every Bungee test's quote fetch depends on this real + * round-trip to whatever real RPC the app used — reliable for one test at a time, but a real, + * unmocked network dependency that can time out under `pnpm e2e`'s full parallel load (many + * workers hitting the same public endpoint at once). Mirror the allowances mock's own try/catch + * here: on failure, fall back instead of letting the rejection abort the request outright — the + * allowances mock (registered earlier) still gets a chance to answer the allowance slots, and a + * transient real-RPC hiccup no longer takes the whole quote down with it. + */ +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, i) => classifiedById.set(entry.id, classified[i])) + + const payload = upstreamEntries.map((entry) => { + const classifiedEntry = classifiedById.get(entry.id) + if (!classifiedEntry || classifiedEntry.kind === 'opaque') return entry + const upstreamResult = typeof entry.result === 'string' ? (entry.result as Hex) : undefined + return { jsonrpc: '2.0', id: entry.id, result: buildResult(classifiedEntry, upstreamResult) } + }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), + }) + } catch { + await route.fallback() + } +} + function isFullyMocked(call: ClassifiedCall): boolean { if (call.kind === 'stubbed') return true if (call.kind === 'opaque') return false diff --git a/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts b/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts index 6b9beccbb1b..d571f83b2e7 100644 --- a/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts +++ b/apps/cowswap-e2e-tests/src/support/setupTestConditions.test.ts @@ -45,6 +45,7 @@ function fakeAllowances(): AllowancesMock & { calls: Array<[string, number, Reco reads: () => [], reportUnknownOwners() {}, reset() {}, + resolve: () => 0n, } } From bbacd5f7ce9ccfa6552ab565de7ae9fb188309d3 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 12 Aug 2026 15:18:02 +0200 Subject: [PATCH 04/35] chore: merge changes --- apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 32ae355d5ec..283d4c77231 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -203,7 +203,7 @@ test.describe('Cross-chain swaps', () => { await confirmModal.confirmButton.click() await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) - posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE) + posting.fulfill(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. @@ -262,7 +262,7 @@ test.describe('Cross-chain swaps', () => { await confirmModal.confirmButton.click() await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) - posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE) + posting.fulfill(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. From a45006bd27b41a2e0cd1740fd8da14d51a34f23c Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 12 Aug 2026 16:53:07 +0200 Subject: [PATCH 05/35] chore: improve recoverDepositAddress hack --- apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts b/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts index 3aabad6fa68..da8480ad419 100644 --- a/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts +++ b/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts @@ -41,7 +41,13 @@ const NEAR_INTENTS_E2E_ATTESTATOR_ADDRESS: Address = '0x0073DD100b51C555E41B2a45 // is a live digital-signature check, not something a mocked pair can satisfy, so it's patched out // entirely for e2e rather than trying to forge a valid signature. See // `apps/cowswap-e2e-tests/src/mocks/nearIntents.ts`. -if (typeof window !== 'undefined' && window.__COWSWAP_E2E__) { +// The `NODE_ENV !== 'production'` check is load-bearing, not redundant with the `window` flag below: +// every deployed build (prod, staging, and Vercel preview) runs through the production webpack build +// (`NODE_ENV === 'production'`), so this whole branch — including the bypass itself — is dead code +// there and gets stripped by Terser. Only the local/CI dev server e2e tests actually run against +// (`NODE_ENV === 'development'`) ever evaluates it, so a real deployed bundle has no live code path +// that can disable this signature check, no matter what `window.__COWSWAP_E2E__` is set to. +if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined' && window.__COWSWAP_E2E__) { nearIntentsBridgeProvider.recoverDepositAddress = async ({ quote }) => ({ address: NEAR_INTENTS_E2E_ATTESTATOR_ADDRESS, quoteHash: quote.depositAddress ?? '0x0', From b91870f676d0c48f018bcfb7906924947afa9c19 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 12 Aug 2026 21:27:21 +0200 Subject: [PATCH 06/35] chore: improve code --- .../src/mocks/bridge/loadFixture.ts | 8 +++++++ apps/cowswap-e2e-tests/src/mocks/bungee.ts | 12 ++++------ .../src/mocks/nearIntents.ts | 9 +------- .../src/pages/BridgeRoutePanel.ts | 6 ++++- .../src/support/mockSocketVerifier.ts | 23 ++++++++++++------- .../src/tests/cross-chain-swaps.spec.ts | 12 +++++----- .../src/tradingSdk/bridgingSdk.ts | 6 ++--- libs/common-hooks/src/useFeatureFlags.ts | 12 +++++++--- 8 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 apps/cowswap-e2e-tests/src/mocks/bridge/loadFixture.ts 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 index 0c680b0184e..f4428f18717 100644 --- a/apps/cowswap-e2e-tests/src/mocks/bungee.ts +++ b/apps/cowswap-e2e-tests/src/mocks/bungee.ts @@ -1,14 +1,7 @@ -import { readFileSync } from 'node:fs' -import path from 'node:path' +import { loadFixture } from './bridge/loadFixture' import type { BrowserContext, Route } from '@playwright/test' -const FIXTURES_DIR = path.join(__dirname, 'bridge', 'fixtures') - -function loadFixture(name: string): unknown { - return JSON.parse(readFileSync(path.join(FIXTURES_DIR, name), 'utf8')) as unknown -} - // 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 = @@ -177,6 +170,9 @@ function scaleBungeeQuoteFixture(fixture: BungeeQuoteFixture, requestedInputAmou } } +// `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/nearIntents.ts b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts index 01a54f1875a..8924f28421c 100644 --- a/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts +++ b/apps/cowswap-e2e-tests/src/mocks/nearIntents.ts @@ -1,14 +1,7 @@ -import { readFileSync } from 'node:fs' -import path from 'node:path' +import { loadFixture } from './bridge/loadFixture' import type { BrowserContext, Route } from '@playwright/test' -const FIXTURES_DIR = path.join(__dirname, 'bridge', 'fixtures') - -function loadFixture(name: string): unknown { - return JSON.parse(readFileSync(path.join(FIXTURES_DIR, name), 'utf8')) as unknown -} - // The 1click SDK's `OpenAPI.BASE` — see `NearIntentsBridgeProvider` in `@cowprotocol/sdk-bridging`. const NEAR_INTENTS_URL_PATTERN = /^https:\/\/1click\.chaindefuser\.com\/v0\//i diff --git a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts index 04bfcae4733..0202d7c7f8a 100644 --- a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts +++ b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts @@ -19,7 +19,11 @@ export class BridgeRoutePanel { constructor(page: Page) { this.page = page - this.expandToggle = page.locator('[aria-expanded]').first() + // 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('[aria-expanded][class*="SummaryClickable-"]').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/) diff --git a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts index b6fa54deec6..05940c63c5c 100644 --- a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts +++ b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts @@ -1,14 +1,21 @@ -import { decodeAbiParameters, encodeAbiParameters, type Hex } from 'viem' +import { decodeAbiParameters, encodeAbiParameters, toFunctionSelector, type Hex } from 'viem' + +import { areAddressesEqual } from '@cowprotocol/cow-sdk' import { AGGREGATE3_SELECTOR } from '../mocks/allowances/codec' import type { BrowserContext, Route } from '@playwright/test' const SOCKET_VERIFIER_ADDRESS = '0xa27a3f5a96df7d8be26ee2790999860c00eb688d' -// `validateRotueId(bytes,uint32)` / `validateSocketRequest(bytes,(uint32,(uint256,address,uint256,address,bytes4)))` -// — both `nonpayable` with no outputs, called via `eth_call`; the SDK only checks the call -// doesn't revert (see `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`). -const STUBBED_SELECTORS = new Set(['0xeee54b0d', '0xf75d4a35']) +// 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 = new Set([ + toFunctionSelector('validateRotueId(bytes,uint32)'), + toFunctionSelector('validateSocketRequest(bytes,(uint32,(uint256,address,uint256,address,bytes4)))'), +]) const CALL3_TUPLE = [ { @@ -68,8 +75,8 @@ const OPAQUE: OpaqueCall = { kind: 'opaque' } * this needs a host-agnostic route rather than `rpcProxy.stubCall`. Without it, the real call * reverts with `RouteIdNotFound()` and every Bungee quote fetch fails with `TX_BUILD_ERROR`. */ -export function mockSocketVerifier(context: BrowserContext): void { - void context.route('**/*', async (route: Route) => { +export async function mockSocketVerifier(context: BrowserContext): Promise { + await context.route('**/*', async (route: Route) => { const request = route.request() if (request.method() !== 'POST') return route.fallback() @@ -120,7 +127,7 @@ function buildResult(call: ClassifiedCall, upstream?: Hex): unknown { function classifyCall(to: string, data: string): ClassifiedCall { const selector = data.slice(0, 10).toLowerCase() - if (to.toLowerCase() === SOCKET_VERIFIER_ADDRESS && STUBBED_SELECTORS.has(selector)) { + if (areAddressesEqual(to, SOCKET_VERIFIER_ADDRESS) && STUBBED_SELECTORS.has(selector)) { return { kind: 'stubbed' } } if (selector === AGGREGATE3_SELECTOR) { 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 index 283d4c77231..c7ddd3700da 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -1,5 +1,7 @@ import { parseUnits, type Hex } from 'viem' +import { areAddressesEqual } from '@cowprotocol/cow-sdk' + import { test, expect } from '../fixtures' import { reply } from '../mocks/cowProtocolApi' import { CHAIN_IDS } from '../support/constants' @@ -80,7 +82,7 @@ test.describe('Cross-chain swaps', () => { await mocks.launchDarkly.setFlag('isNearIntentsBridgeProviderEnabled', active === 'near-intents') mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: 999n, denominator: 1000n } }) if (active === 'bungee') { - mockSocketVerifier(context) + await mockSocketVerifier(context) } } @@ -188,7 +190,7 @@ test.describe('Cross-chain swaps', () => { 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(swapRecipientAddress?.toLowerCase()).not.toBe(wallet.address.toLowerCase()) + expect(areAddressesEqual(swapRecipientAddress, wallet.address)).toBe(false) // Bridge leg line items. await expect(swapPage.routePanel.bridgeEstTime()).toBeVisible() @@ -543,8 +545,7 @@ test.describe('Cross-chain swaps', () => { // `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') - const bridgeExpectedToReceive = await swapPage.routePanel.bridgeExpectedToReceive().getAttribute('title') - expect(formReceive).toBe(bridgeExpectedToReceive) + 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 @@ -586,8 +587,7 @@ test.describe('Cross-chain swaps', () => { await swapPage.routePanel.expand() const swapMinToReceive = await swapPage.routePanel.swapMinToReceive().getAttribute('title') - const bridgeMinToDeposit = await swapPage.routePanel.bridgeMinToDeposit().getAttribute('title') - expect(bridgeMinToDeposit).toBe(swapMinToReceive) + await expect(swapPage.routePanel.bridgeMinToDeposit()).toHaveAttribute('title', swapMinToReceive ?? '') } }) }) diff --git a/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts b/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts index da8480ad419..cffa55d540c 100644 --- a/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts +++ b/apps/cowswap-frontend/src/tradingSdk/bridgingSdk.ts @@ -44,9 +44,9 @@ const NEAR_INTENTS_E2E_ATTESTATOR_ADDRESS: Address = '0x0073DD100b51C555E41B2a45 // The `NODE_ENV !== 'production'` check is load-bearing, not redundant with the `window` flag below: // every deployed build (prod, staging, and Vercel preview) runs through the production webpack build // (`NODE_ENV === 'production'`), so this whole branch — including the bypass itself — is dead code -// there and gets stripped by Terser. Only the local/CI dev server e2e tests actually run against -// (`NODE_ENV === 'development'`) ever evaluates it, so a real deployed bundle has no live code path -// that can disable this signature check, no matter what `window.__COWSWAP_E2E__` is set to. +// there and gets stripped by Terser. Only the local/CI dev server that e2e tests run against +// (`NODE_ENV === 'development'`) evaluates it, so a real deployed bundle has no live code path that +// can disable this signature check, no matter what `window.__COWSWAP_E2E__` is set to. if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined' && window.__COWSWAP_E2E__) { nearIntentsBridgeProvider.recoverDepositAddress = async ({ quote }) => ({ address: NEAR_INTENTS_E2E_ATTESTATOR_ADDRESS, diff --git a/libs/common-hooks/src/useFeatureFlags.ts b/libs/common-hooks/src/useFeatureFlags.ts index 148e2b4ace7..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 { @@ -22,10 +24,14 @@ export function useFeatureFlags(): FeatureFlags { // 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`. - if (typeof window !== 'undefined' && window.__COWSWAP_E2E_FEATURE_FLAGS__) { + // `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 } From a273179826fdf86f9796160de9112ebb244d72f1 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 12 Aug 2026 21:53:26 +0200 Subject: [PATCH 07/35] chore: improve tests stability --- apps/cowswap-e2e-tests/playwright.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index 3c00d16975a..8378c3e8dd0 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -7,9 +7,10 @@ export default defineConfig({ // The Synpress MetaMask connect flow (extension boot + network switch + dapp approval) // takes ~20-25s on its own, so the 30s Playwright default leaves no room for the test body. timeout: 90_000, + expect: { timeout: 10_000 }, fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, + retries: 1, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : [['list'], ['html', { open: 'never' }]], globalSetup: path.resolve(__dirname, 'src/support/globalSetup.ts'), From bdd7e39f6b2d49d4cc4b1556f537a97e5a85609c Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 10:52:48 +0200 Subject: [PATCH 08/35] chore: improve tests stability --- apps/cowswap-e2e-tests/AGENTS.md | 72 +++++++++++++++++- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 5 ++ .../src/support/mockApproveSimulation.ts | 75 +++++++++++++++++++ .../src/support/mockApproveTransaction.ts | 4 +- .../src/tests/cross-chain-swaps.spec.ts | 6 +- .../src/tests/limit-orders.spec.ts | 2 +- .../src/tests/market-orders.spec.ts | 66 +++++++++++----- 7 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts diff --git a/apps/cowswap-e2e-tests/AGENTS.md b/apps/cowswap-e2e-tests/AGENTS.md index a0e5c5cd112..33b1bb968a3 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-11 +last_reviewed: 2026-08-13 source_of_truth_scope: cowswap-e2e-tests app-specific conventions, mocks, and debugging notes --- @@ -125,6 +125,76 @@ 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.** + `mockSocketVerifier`, `mocks.allowances`, and `installMulticall3` all 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. +- **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, diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 46a78b9d37d..165848c534e 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -22,6 +22,7 @@ import { LimitPage } from '../pages/LimitPage' import { SwapPage } from '../pages/SwapPage' import { TwapPage } from '../pages/TwapPage' import { logUnmockedRpcRequests } from '../support/logUnmockedRpcRequests' +import { mockApproveSimulation } from '../support/mockApproveSimulation' import { mockOrderPosting } from '../support/mockOrderPosting' import { createSetupTestConditions, type SetupTestConditions } from '../support/setupTestConditions' @@ -125,6 +126,10 @@ export const sharedFixtures: Fixtures< installEthEstimateGas(context) installEthGetTransactionCount(context) installMulticall3(context, { allowances }) + // Fires regardless of whether the UI ever shows an Approve step (confirmed by tracing real + // traffic under `LOG_UNMOCKED_RPC=1` — it hit cross-chain tests that pre-seed a sufficient + // allowance and never click Approve), so this is global rather than opt-in per test. + mockApproveSimulation(context) const tokenLists = installTokenLists(context) const safeSdk = installSafeSdk(context) const bungee = installBungee(context) diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts new file mode 100644 index 00000000000..beac7948bba --- /dev/null +++ b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts @@ -0,0 +1,75 @@ +import { APPROVE_CALL_SUCCESS_RESULT, APPROVE_SELECTOR } from './mockApproveTransaction' + +import type { BrowserContext, Route } from '@playwright/test' + +interface JsonRpcEntry { + id: number | string + method: string + params?: [{ to?: string; data?: string }, ...unknown[]] +} + +/** + * Answers the preflight `approve(address,uint256)` simulation `eth_call` (see + * `mockApproveTransaction.ts`'s doc comment) for every trade that pre-seeds a sufficient + * allowance via `seedTrader`/`mocks.allowances.set` and therefore never calls + * `mockApproveTransaction` at all — the wallet-connector layer still fires this simulate-before- + * sign check regardless of whether the UI ever shows an Approve step, and confirmed by tracing + * real traffic (`LOG_UNMOCKED_RPC=1`), it goes to the app's own hardcoded provider rather than any + * URL this suite controls, so it needs the same host-agnostic matching `mockSocketVerifier.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. + */ +export function mockApproveSimulation(context: BrowserContext): void { + void context.route('**/*', async (route: Route) => { + const request = route.request() + if (request.method() !== 'POST') return route.fallback() + + let body: JsonRpcEntry | JsonRpcEntry[] | null + try { + // Same rationale as `mockApproveTransaction.ts`'s own preflight handler: this route sees + // every request in the page, so a POST with no/non-JSON body (e.g. an analytics beacon) + // must be checked explicitly rather than relying on a try/catch alone. + body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[] | null + } catch { + return route.fallback() + } + if (!body) return route.fallback() + + const entries = Array.isArray(body) ? body : [body] + const matches = entries.map(isApproveSimulationCall) + if (!matches.some(Boolean)) return route.fallback() + + if (matches.every(Boolean)) { + const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT })) + return route.fulfill({ json: Array.isArray(body) ? payload : payload[0] }) + } + + return fulfillFromUpstream(route, entries, matches) + }) +} + +/** Same merge-with-upstream technique as `mockApproveTransaction.ts`'s own preflight handler. */ +async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[], matches: boolean[]): Promise { + try { + const upstream = await route.fetch() + const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[] + const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody] + const matchedIds = new Set(entries.filter((_, i) => matches[i]).map((entry) => entry.id)) + const payload = upstreamEntries.map((entry) => + matchedIds.has(entry.id) ? { jsonrpc: '2.0', id: entry.id, result: APPROVE_CALL_SUCCESS_RESULT } : entry, + ) + await route.fulfill({ json: Array.isArray(upstreamBody) ? payload : payload[0] }) + } catch { + await route.fallback() + } +} + +/** Matches any `eth_call` whose calldata is an `approve(address,uint256)` invocation, regardless of `to`. */ +function isApproveSimulationCall(entry: JsonRpcEntry | null | undefined): boolean { + if (entry?.method !== 'eth_call') return false + const call = entry.params?.[0] + if (!call?.to || !call?.data) return false + return call.data.toLowerCase().startsWith(APPROVE_SELECTOR) +} diff --git a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts index 54a239eff2a..6284ad073d4 100644 --- a/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts +++ b/apps/cowswap-e2e-tests/src/support/mockApproveTransaction.ts @@ -11,9 +11,9 @@ import type { BrowserContext, Route } from '@playwright/test' const FAKE_APPROVE_TX_HASH = `0x${'ab'.repeat(32)}` as const /** `approve(address,uint256)` selector — what the preflight `eth_call` this mock also stubs is checking won't revert. */ -const APPROVE_SELECTOR = '0x095ea7b3' +export const APPROVE_SELECTOR = '0x095ea7b3' /** ABI-encoded `true` — the only thing a `bool`-returning `eth_call` needs to report success. */ -const APPROVE_CALL_SUCCESS_RESULT = encodeAbiParameters([{ type: 'bool' }], [true]) +export const APPROVE_CALL_SUCCESS_RESULT = encodeAbiParameters([{ type: 'bool' }], [true]) export interface MockApproveTransactionHandle { /** The raw amount decoded from the actual approve(spender, amount) calldata, once sent. */ 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 index c7ddd3700da..57547386a6c 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -202,7 +202,7 @@ test.describe('Cross-chain swaps', () => { const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) await swapPage.clickPrimaryAction() - await confirmModal.confirmButton.click() + await confirmModal.confirm() await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE, 0n) @@ -261,7 +261,7 @@ test.describe('Cross-chain swaps', () => { const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) await swapPage.clickPrimaryAction() - await confirmModal.confirmButton.click() + await confirmModal.confirm() await swapPage.orderProgressBarModal.waitFor({ state: 'visible' }) posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE, 0n) @@ -368,7 +368,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.swapButton).toContainText(/swap.*bridge/i) await swapPage.clickSwap() - await confirmModal.confirmButton.click() + 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. 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 0792513fddf..9025538da9e 100644 --- a/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/limit-orders.spec.ts @@ -66,7 +66,7 @@ test.describe('Limit Orders', () => { await limitPage.placeOrder() await expect(confirmModal.confirmButton).toContainText('Place limit order') - await confirmModal.confirmButton.click() + await confirmModal.confirm() // The mock wallet signs and `postOrder` responds instantly, so the flow skips past any // transient progress step straight to the confirm modal's "Order Submitted" screen. 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 4e7538b4a1d..856458812c8 100644 --- a/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts @@ -89,7 +89,7 @@ test.describe('Market Orders', () => { await swapPage.waitForQuote() await swapPage.clickSwap() - await confirmModal.confirmButton.click() + await confirmModal.confirm() // Step 1 (INITIAL, backend OPEN/SCHEDULED) — order just posted, competition not started yet. await expect(swapPage.orderProgressBarModal).toContainText('Batching orders') @@ -178,7 +178,7 @@ test.describe('Market Orders', () => { await swapPage.waitForQuote() await swapPage.clickSwap() - await confirmModal.confirmButton.click() + await confirmModal.confirm() await expect(swapPage.orderProgressBarModal).toContainText('Batching orders') await swapPage.page.keyboard.press('Escape') @@ -506,7 +506,7 @@ test.describe('Market Orders', () => { await expect(swapPage.inputAmount).toHaveValue('0.5') await swapPage.clickSwap() - await confirmModal.confirmButton.click() + 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. @@ -615,7 +615,7 @@ test.describe('Market Orders', () => { await expect(swapPage.inputAmount).toHaveValue('0.5') await swapPage.clickSwap() - await confirmModal.confirmButton.click() + await confirmModal.confirm() // Creating (tx sent, not yet mined): "Sending ETH" is the active step, and the tx hash is // already linked as its "View transaction" explorer link — same signals as [CS-68]. @@ -817,7 +817,7 @@ test.describe('Market Orders', () => { // Only placing the swap from here on posts the order — proving the approval tx really did // happen before it, not just alongside it. - await confirmModal.confirmButton.click() + await confirmModal.confirm() await expect.poll(() => orderPosted).toBe(true) }) @@ -894,7 +894,7 @@ test.describe('Market Orders', () => { expect(wallet.rpcCalls('eth_sendTransaction')).toHaveLength(0) // Signing auto-advances into the swap confirm screen, same as a real approval does. - await confirmModal.confirmButton.click() + await confirmModal.confirm() // The signed permit is what gets "executed with the swap settlement": it's uploaded as a // pre-interaction CoW Hook on the order's appData, not a separate approve() call. @@ -1116,7 +1116,7 @@ test.describe('Market Orders', () => { await swapPage.waitForQuote() await swapPage.clickSwap() - await confirmModal.confirmButton.click() + await confirmModal.confirm() // Step 1 (INITIAL, backend OPEN/SCHEDULED) — order just signed and posted, competition hasn't // started yet. @@ -1212,17 +1212,28 @@ test.describe('Market Orders', () => { const readRowAmount = (label: string): Promise => readTitledAmount(tooltipBox.getByText(label, { exact: true }).locator('xpath=following-sibling::*[1]')) - const beforeCosts = await readRowAmount('Before costs') - const protocolFee = await readRowAmount('Protocol fee') - const networkCosts = await readRowAmount('Network costs') - const toAmount = await readRowAmount('To') + // See [CS-128]'s comment on the identical read: four separately-awaited reads risk a + // re-render (the form's own default-amount probe quote settling into the typed one) landing + // in between two of them, tearing the snapshot and skewing the ratio. Re-reading all four + // together on every poll attempt rides out that race. + let beforeCosts = 0n + let protocolFee = 0n + let networkCosts = 0n + let toAmount = 0n + + await expect + .poll(async () => { + beforeCosts = await readRowAmount('Before costs') + protocolFee = await readRowAmount('Protocol fee') + networkCosts = await readRowAmount('Network costs') + toAmount = await readRowAmount('To') + return Number(protocolFee) / Number(beforeCosts) + }) + .toBeCloseTo(0.0002, 6) expect(protocolFee).toBeGreaterThan(0n) expect(networkCosts).toBe(0n) - // Protocol fee ≈ Before costs × 0.0002 (2 bps). - expect(Number(protocolFee) / Number(beforeCosts)).toBeCloseTo(0.0002, 6) - // The core relationship: To = Before costs − Network costs − Protocol fee. expect(toAmount).toBe(beforeCosts - networkCosts - protocolFee) }) @@ -1307,10 +1318,28 @@ test.describe('Market Orders', () => { buyDecimals, ) - const beforeCosts = await readRowAmount('Before costs') - const protocolFee = await readRowAmount('Protocol fee') - const networkCosts = await readRowAmount('Network costs') - const toAmount = await readRowAmount('To') + // The tooltip briefly shows a stale quote (the form's own default-amount probe, fetched + // before the typed "1000" settles) — `waitForQuote()` only waits for the loading flag to + // clear once, not for these four rows to all reflect the *same* render. Reading them as + // four separately-awaited calls risks a re-render landing in between two of them, tearing + // the snapshot (e.g. `beforeCosts` from the stale quote, `protocolFee` from the fresh one) + // and skewing the ratio below by orders of magnitude. Re-reading all four together on every + // poll attempt, instead of trusting a single one-shot batch, rides out that race the same + // way the recipient-checkbox retry in `[CC-17]` rides out its own settling-debounce race. + let beforeCosts = 0n + let protocolFee = 0n + let networkCosts = 0n + let toAmount = 0n + + await expect + .poll(async () => { + beforeCosts = await readRowAmount('Before costs') + protocolFee = await readRowAmount('Protocol fee') + networkCosts = await readRowAmount('Network costs') + toAmount = await readRowAmount('To') + return Number(protocolFee) / Number(beforeCosts) + }) + .toBeCloseTo(0.00003, 6) expect(protocolFee).toBeGreaterThan(0n) expect(networkCosts).toBe(0n) @@ -1318,7 +1347,6 @@ test.describe('Market Orders', () => { // Protocol fee ≈ Before costs × 0.00003 (0.3 bps) — ~6.67× smaller than [CS-127]'s 2 bps tier // on equivalent volume. const ratio = Number(protocolFee) / Number(beforeCosts) - expect(ratio).toBeCloseTo(0.00003, 6) expect(STANDARD_TIER_RATIO / ratio).toBeCloseTo(6.667, 1) // The core relationship: To = Before costs − Network costs − Protocol fee. From f9b0726d12a5f5def4a1a7100195a449e6aa1f4d Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 11:00:47 +0200 Subject: [PATCH 09/35] chore: update test ids --- .../src/tests/cross-chain-swaps.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 57547386a6c..33fc9a14cea 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -117,7 +117,7 @@ test.describe('Cross-chain swaps', () => { await swapPage.unlockIfNeeded() } - test('[CC-01] Cross-chain swap UI: accessible via Swap form', async ({ swapPage, wallet, mocks, context }) => { + test('[CS-285] Cross-chain swap UI: accessible via Swap form', async ({ swapPage, wallet, mocks, context }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, @@ -150,7 +150,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.routePanel.bridgeStopTitle('Near Intents')).toBeVisible() }) - test('[CC-02] Cross-chain swap: Near provider', async ({ + test('[CS-286] Cross-chain swap: Near provider', async ({ swapPage, tradePage, wallet, @@ -212,7 +212,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) }) - test('[CC-03] Cross-chain swap: Bungee provider', async ({ + test('[CS-287] Cross-chain swap: Bungee provider', async ({ swapPage, tradePage, wallet, @@ -271,7 +271,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) }) - test('[CC-13] Cross-chain: ETH-flow source — native ETH sent cross-chain', async ({ + test('[CS-297] Cross-chain: ETH-flow source — native ETH sent cross-chain', async ({ swapPage, wallet, confirmModal, @@ -403,7 +403,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) }) - test('[CC-15] Cross-chain: swap to Solana — SOL or SPL token as destination', async ({ + test('[CS-299] Cross-chain: swap to Solana — SOL or SPL token as destination', async ({ swapPage, wallet, mocks, @@ -466,7 +466,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.primaryActionButton).toBeEnabled() }) - test('[CC-17] Cross-chain: swap to Bitcoin — BTC as destination', async ({ swapPage, wallet, mocks, context }) => { + test('[CS-301] Cross-chain: swap to Bitcoin — BTC as destination', async ({ swapPage, wallet, mocks, context }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, @@ -516,7 +516,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.primaryActionButton).toBeEnabled() }) - test('[CC-26] Cross-chain: calculation parity — form Receive equals bridge Expected to receive', async ({ + test('[CS-310] Cross-chain: calculation parity — form Receive equals bridge Expected to receive', async ({ swapPage, wallet, mocks, @@ -563,7 +563,7 @@ test.describe('Cross-chain swaps', () => { } }) - test('[CC-27] Cross-chain: calculation parity — bridge Min. to deposit equals swap Min. to receive', async ({ + test('[CS-311] Cross-chain: calculation parity — bridge Min. to deposit equals swap Min. to receive', async ({ swapPage, wallet, mocks, From 8b083e4df94c71883a4b2a1ee7b13df87b0e6c36 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 11:22:33 +0200 Subject: [PATCH 10/35] chore: add smoke tags --- .../src/tests/cross-chain-swaps.spec.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 index 33fc9a14cea..2e772edcb8b 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -117,7 +117,12 @@ test.describe('Cross-chain swaps', () => { await swapPage.unlockIfNeeded() } - test('[CS-285] Cross-chain swap UI: accessible via Swap form', async ({ swapPage, wallet, mocks, context }) => { + test('[CS-285] Cross-chain swap UI: accessible via Swap form @smoke', async ({ + swapPage, + wallet, + mocks, + context, + }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, @@ -212,7 +217,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) }) - test('[CS-287] Cross-chain swap: Bungee provider', async ({ + test('[CS-287] Cross-chain swap: Bungee provider @smoke', async ({ swapPage, tradePage, wallet, @@ -271,7 +276,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.orderProgressBarModal).toContainText('Bridging to destination', { timeout: 15_000 }) }) - test('[CS-297] Cross-chain: ETH-flow source — native ETH sent cross-chain', async ({ + test('[CS-297] Cross-chain: ETH-flow source — native ETH sent cross-chain @smoke', async ({ swapPage, wallet, confirmModal, @@ -403,7 +408,7 @@ test.describe('Cross-chain swaps', () => { 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', async ({ + test('[CS-299] Cross-chain: swap to Solana — SOL or SPL token as destination @smoke', async ({ swapPage, wallet, mocks, @@ -466,7 +471,12 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.primaryActionButton).toBeEnabled() }) - test('[CS-301] Cross-chain: swap to Bitcoin — BTC as destination', async ({ swapPage, wallet, mocks, context }) => { + test('[CS-301] Cross-chain: swap to Bitcoin — BTC as destination @smoke', async ({ + swapPage, + wallet, + mocks, + context, + }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, @@ -516,7 +526,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.primaryActionButton).toBeEnabled() }) - test('[CS-310] Cross-chain: calculation parity — form Receive equals bridge Expected to receive', async ({ + test('[CS-310] Cross-chain: calculation parity — form Receive equals bridge Expected to receive @smoke', async ({ swapPage, wallet, mocks, @@ -563,7 +573,7 @@ test.describe('Cross-chain swaps', () => { } }) - test('[CS-311] Cross-chain: calculation parity — bridge Min. to deposit equals swap Min. to receive', async ({ + test('[CS-311] Cross-chain: calculation parity — bridge Min. to deposit equals swap Min. to receive @smoke', async ({ swapPage, wallet, mocks, From 5e7b4ba96c8c292302cf513e2038bc7956e28309 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 12:17:45 +0200 Subject: [PATCH 11/35] docs(e2e): update readme --- apps/cowswap-e2e-tests/README.md | 66 ++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) 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. From db9e09b12bc599aefc2827bc8ab44783dc9206cc Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 15:22:50 +0200 Subject: [PATCH 12/35] chore: fix mockSocketVerifier --- .../src/support/mockSocketVerifier.ts | 220 ++---------------- .../src/tests/cross-chain-swaps.spec.ts | 38 +-- 2 files changed, 39 insertions(+), 219 deletions(-) diff --git a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts index 05940c63c5c..6cbb6b4d3bb 100644 --- a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts +++ b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts @@ -1,10 +1,6 @@ -import { decodeAbiParameters, encodeAbiParameters, toFunctionSelector, type Hex } from 'viem' +import { toFunctionSelector } from 'viem' -import { areAddressesEqual } from '@cowprotocol/cow-sdk' - -import { AGGREGATE3_SELECTOR } from '../mocks/allowances/codec' - -import type { BrowserContext, Route } from '@playwright/test' +import type { RpcProxyHandle } from '../fixtures/rpcProxy' const SOCKET_VERIFIER_ADDRESS = '0xa27a3f5a96df7d8be26ee2790999860c00eb688d' // Both `nonpayable` with no outputs, called via `eth_call`; the SDK only checks the call doesn't @@ -12,205 +8,27 @@ const SOCKET_VERIFIER_ADDRESS = '0xa27a3f5a96df7d8be26ee2790999860c00eb688d' // 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 = new Set([ +const STUBBED_SELECTORS = [ toFunctionSelector('validateRotueId(bytes,uint32)'), toFunctionSelector('validateSocketRequest(bytes,(uint32,(uint256,address,uint256,address,bytes4)))'), -]) - -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 - -interface BatchCall { - kind: 'batch' - calls: ClassifiedCall[] -} -interface BatchResultSlot { - success: boolean - returnData: Hex -} -type ClassifiedCall = StubbedCall | BatchCall | OpaqueCall -interface JsonRpcEntry { - id: number | string - method: string - params?: [{ to?: string; data?: string }, ...unknown[]] - result?: unknown -} - -interface OpaqueCall { - kind: 'opaque' -} - -interface StubbedCall { - kind: 'stubbed' -} - -const OPAQUE: OpaqueCall = { kind: 'opaque' } +] /** - * `BungeeBridgeProvider.getQuote()` verifies the build-tx it gets from Bungee's API by reading - * two functions on the on-chain SocketVerifier contract, on the origin chain — Near Intents never - * does this. This suite's own RPC proxy (`fixtures/rpcProxy.ts`) only sits in front of the - * *wallet's* provider requests; this contract read instead goes through the app's independent - * read-only RPC client (`RPC_URLS` in `libs/common-const/src/networks.ts`), which for any chain - * without a `REACT_APP_NETWORK_URL_` override (every chain here except Sepolia) falls - * back to a real public endpoint (a baked-in Infura key, confirmed by tracing real traffic) — so - * this needs a host-agnostic route rather than `rpcProxy.stubCall`. Without it, the real call - * reverts with `RouteIdNotFound()` and every Bungee quote fetch fails with `TX_BUILD_ERROR`. + * `BungeeBridgeProvider.getQuote()` verifies the build-tx it gets from Bungee's API by reading two + * functions on the on-chain SocketVerifier contract, on the origin chain — Near Intents never does + * this. This is *not* a call this suite's own read-only viem client makes on the app's behalf: the + * SDK adapter's `readContract` here runs against the **connected wallet's own provider**, not a + * separate HTTP transport — confirmed after `context.route()`-based interception (matching on the + * page's own network requests) turned out to miss it entirely under load, because there's no page + * network request to intercept in the first place. `eth_call`s made through the wallet's provider + * go through `walletEngine.ts`'s `dispatch()` → `forward()`, a plain Node-side `fetch()` to this + * suite's own RPC proxy (`support/rpcProxy.ts`) that never touches the browser's network layer at + * all. `rpcProxy.stubCall()` is the proxy's own existing per-`(to, selector)` stub primitive — the + * right layer to answer this, not a page-level route. Without it, the real call reverts with + * `RouteIdNotFound()` and every Bungee quote fetch fails with `TX_BUILD_ERROR`. */ -export async function mockSocketVerifier(context: BrowserContext): Promise { - await 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((c) => c.kind === 'opaque')) return route.fallback() - - if (classified.every(isFullyMocked)) { - const payload = entries.map((entry, i) => ({ jsonrpc: '2.0', id: entry.id, result: buildResult(classified[i]) })) - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(Array.isArray(body) ? payload : payload[0]), - }) - } - - return fulfillFromUpstream(route, entries, classified) - }) -} - -function buildResult(call: ClassifiedCall, upstream?: Hex): unknown { - if (call.kind === 'stubbed') return '0x' - if (call.kind === 'opaque') return undefined - return resolveBatchResult(call, upstream) -} - -/** - * Classifies one `eth_call` payload, recursively — mirrors `mocks/allowances/codec.ts`'s - * `classifyCall`, since Multicall3 batches nest the same way regardless of what's inside them. - * The app never calls the SocketVerifier directly: tracing real RPC traffic shows it's always - * bundled into a Multicall3 `aggregate3` batch alongside unrelated ERC20/allowance reads, so - * recognizing the target wherever it appears inside a batch (rather than requiring the *whole* - * batch to be nothing else) is what keeps this from having to understand every other call in it. - */ -function classifyCall(to: string, data: string): ClassifiedCall { - const selector = data.slice(0, 10).toLowerCase() - - if (areAddressesEqual(to, SOCKET_VERIFIER_ADDRESS) && STUBBED_SELECTORS.has(selector)) { - return { kind: 'stubbed' } +export async function mockSocketVerifier(rpcProxy: RpcProxyHandle, chainId: number): Promise { + for (const selector of STUBBED_SELECTORS) { + await rpcProxy.stubCall({ chainId, to: SOCKET_VERIFIER_ADDRESS, dataPrefix: selector, returnHex: '0x' }) } - if (selector === AGGREGATE3_SELECTOR) { - 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 - } - } - return OPAQUE -} - -function decodeResultSlots(blob: Hex): BatchResultSlot[] { - try { - return [...(decodeAbiParameters(RESULT_TUPLE, blob)[0] as ReadonlyArray)] - } catch { - return [] - } -} - -/** - * Some entries need real data (fully opaque, or a batch only partially recognized) — fetch - * upstream and patch in only what's actually mocked, same merge technique as the allowances mock. - * This is *always* the path taken here (the SocketVerifier call is never alone in its batch, see - * `classifyCall`'s doc comment), so every Bungee test's quote fetch depends on this real - * round-trip to whatever real RPC the app used — reliable for one test at a time, but a real, - * unmocked network dependency that can time out under `pnpm e2e`'s full parallel load (many - * workers hitting the same public endpoint at once). Mirror the allowances mock's own try/catch - * here: on failure, fall back instead of letting the rejection abort the request outright — the - * allowances mock (registered earlier) still gets a chance to answer the allowance slots, and a - * transient real-RPC hiccup no longer takes the whole quote down with it. - */ -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, i) => classifiedById.set(entry.id, classified[i])) - - const payload = upstreamEntries.map((entry) => { - const classifiedEntry = classifiedById.get(entry.id) - if (!classifiedEntry || classifiedEntry.kind === 'opaque') return entry - const upstreamResult = typeof entry.result === 'string' ? (entry.result as Hex) : undefined - return { jsonrpc: '2.0', id: entry.id, result: buildResult(classifiedEntry, upstreamResult) } - }) - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]), - }) - } catch { - await route.fallback() - } -} - -function isFullyMocked(call: ClassifiedCall): boolean { - if (call.kind === 'stubbed') return true - if (call.kind === 'opaque') return false - return call.calls.every(isFullyMocked) -} - -/** Same upstream-as-base patch technique as `codec.ts`'s `resolveBatchResult`. */ -function resolveBatchResult(call: BatchCall, upstream?: Hex): Hex { - const base = upstream ? decodeResultSlots(upstream) : [] - - const slots = call.calls.map((inner, index) => { - const fallback = base[index] ?? { success: false, returnData: '0x' as Hex } - - if (inner.kind === 'stubbed') return { success: true, returnData: '0x' as Hex } - if (inner.kind === 'batch') { - const nestedUpstream = fallback.success ? fallback.returnData : undefined - return { success: true, returnData: resolveBatchResult(inner, nestedUpstream) } - } - return fallback - }) - - return encodeAbiParameters(RESULT_TUPLE, [slots]) } 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 index 2e772edcb8b..9129fb1048d 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -10,10 +10,10 @@ import { mockFixedRateQuote } from '../support/mockFixedRateQuote' import { mockSocketVerifier } from '../support/mockSocketVerifier' 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' -import type { BrowserContext } from '@playwright/test' /** * Scope notes (see cross-chain-swaps.specs.md for the full scenarios): @@ -75,14 +75,14 @@ test.describe('Cross-chain swaps', () => { */ async function configureProviders( mocks: { launchDarkly: LaunchDarklyMock; cowApi: CowProtocolApiMock }, - context: BrowserContext, + 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 } }) if (active === 'bungee') { - await mockSocketVerifier(context) + await mockSocketVerifier(rpcProxy, MAINNET) } } @@ -121,14 +121,14 @@ test.describe('Cross-chain swaps', () => { swapPage, wallet, mocks, - context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, }) - await configureProviders(mocks, context, 'bungee') + await configureProviders(mocks, rpcProxy, 'bungee') await openCrossChainSwap(wallet, swapPage, { chainId: MAINNET, sell: USDC_MAINNET, @@ -141,7 +141,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.routePanel.swapStopTitle).toBeVisible() await expect(swapPage.routePanel.bridgeStopTitle('Bungee')).toBeVisible() - await configureProviders(mocks, context, 'near-intents') + await configureProviders(mocks, rpcProxy, 'near-intents') await openCrossChainSwap(wallet, swapPage, { chainId: MAINNET, sell: USDC_MAINNET, @@ -161,13 +161,13 @@ test.describe('Cross-chain swaps', () => { wallet, confirmModal, mocks, - context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, }) - await configureProviders(mocks, context, 'near-intents') + await configureProviders(mocks, rpcProxy, 'near-intents') await openCrossChainSwap(wallet, swapPage, { chainId: MAINNET, @@ -223,13 +223,13 @@ test.describe('Cross-chain swaps', () => { wallet, confirmModal, mocks, - context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, }) - await configureProviders(mocks, context, 'bungee') + await configureProviders(mocks, rpcProxy, 'bungee') await openCrossChainSwap(wallet, swapPage, { chainId: MAINNET, @@ -282,9 +282,10 @@ test.describe('Cross-chain swaps', () => { confirmModal, mocks, context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [NATIVE_ETH]: INITIAL_ETH_BALANCE } }) - await configureProviders(mocks, context, 'bungee') + 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 @@ -413,12 +414,13 @@ test.describe('Cross-chain swaps', () => { wallet, mocks, context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, }) - await configureProviders(mocks, context, 'near-intents') + 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 @@ -475,13 +477,13 @@ test.describe('Cross-chain swaps', () => { swapPage, wallet, mocks, - context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, allowances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, }) - await configureProviders(mocks, context, 'near-intents') + await configureProviders(mocks, rpcProxy, 'near-intents') await mocks.launchDarkly.setFlag('isBtcBridgeEnabled', true) await wallet.openApp({ chainId: MAINNET, sell: USDC_MAINNET }) @@ -530,7 +532,7 @@ test.describe('Cross-chain swaps', () => { swapPage, wallet, mocks, - context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, @@ -538,7 +540,7 @@ test.describe('Cross-chain swaps', () => { }) for (const provider of ['bungee', 'near-intents'] as const) { - await configureProviders(mocks, context, provider) + await configureProviders(mocks, rpcProxy, provider) await openCrossChainSwap(wallet, swapPage, { chainId: MAINNET, sell: USDC_MAINNET, @@ -577,7 +579,7 @@ test.describe('Cross-chain swaps', () => { swapPage, wallet, mocks, - context, + rpcProxy, }) => { seedTrader(mocks, wallet, MAINNET, { balances: { [USDC_MAINNET]: INITIAL_USDC_BALANCE }, @@ -585,7 +587,7 @@ test.describe('Cross-chain swaps', () => { }) for (const provider of ['bungee', 'near-intents'] as const) { - await configureProviders(mocks, context, provider) + await configureProviders(mocks, rpcProxy, provider) await openCrossChainSwap(wallet, swapPage, { chainId: MAINNET, sell: USDC_MAINNET, From 1af51b32599e17e3690748b1e3a9c81e406aad8a Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 15:27:17 +0200 Subject: [PATCH 13/35] chore: update docs --- apps/cowswap-e2e-tests/AGENTS.md | 50 +++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/apps/cowswap-e2e-tests/AGENTS.md b/apps/cowswap-e2e-tests/AGENTS.md index 33b1bb968a3..f8f6ec12b6e 100644 --- a/apps/cowswap-e2e-tests/AGENTS.md +++ b/apps/cowswap-e2e-tests/AGENTS.md @@ -140,12 +140,13 @@ never a logic bug in the test — check infrastructure contention first. [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.** - `mockSocketVerifier`, `mocks.allowances`, and `installMulticall3` all 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. + `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 @@ -225,14 +226,35 @@ never a logic bug in the test — check infrastructure contention first. `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`/ - `mockSocketVerifier` intercept by that exact URL (tx receipts, native-balance multicalls). Plenty of - other calls the *app itself* makes — Bungee's on-chain SocketVerifier check, `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 these is host-agnostic: `context.route('**/*', ...)`, - decode the JSON-RPC body, and match by `method` (see `mockSocketVerifier.ts` and - `mockEthEstimateGas` in `mockEthFlowTransaction.ts`), never by URL. + 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. +- **Not every on-chain read the app makes even reaches the page's network layer at all — some go + through the *connected wallet's own provider* instead, invisible to any `context.route()`.** + Bungee's on-chain SocketVerifier check (`validateRotueId`/`validateSocketRequest`, + `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`) was originally mocked with a + `context.route('**/*', ...)` handler decoding Multicall3 batches — modeled on `mockEthEstimateGas` + — and it 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 specific check runs 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. + `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`) — there is no page-level network request for `context.route()` to ever see. + Fixed in `mockSocketVerifier.ts` by switching to the RPC proxy's own existing per-`(to, selector)` + stub primitive instead: `rpcProxy.stubCall({ chainId, to: SOCKET_VERIFIER_ADDRESS, dataPrefix: + selector, returnHex: '0x' })` — no Multicall3-batch decoding needed at all, since a wallet-forwarded + `eth_call` is never batched. **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. - **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; From 7aa19422d91213d7acf3f03fcfc40a8de3e2db33 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 15:53:53 +0200 Subject: [PATCH 14/35] chore: fix workers count --- apps/cowswap-e2e-tests/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index 8378c3e8dd0..3ef06c37abf 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: 1, - workers: process.env.CI ? 2 : undefined, + workers: 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'), From 29a00d07bc5a548348b5a272079f3e0b72d674f4 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 17:51:53 +0200 Subject: [PATCH 15/35] chore: revert config --- apps/cowswap-e2e-tests/playwright.config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index 3ef06c37abf..3c00d16975a 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -7,11 +7,10 @@ export default defineConfig({ // The Synpress MetaMask connect flow (extension boot + network switch + dapp approval) // takes ~20-25s on its own, so the 30s Playwright default leaves no room for the test body. timeout: 90_000, - expect: { timeout: 10_000 }, fullyParallel: true, forbidOnly: !!process.env.CI, - retries: 1, - workers: 6, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, 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'), From 3f00b9714e78e08a15cf8a86894b06e198bf5f41 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 18:30:06 +0200 Subject: [PATCH 16/35] chore: improve selectors --- apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts | 2 +- .../src/tests/cross-chain-swaps.spec.ts | 1 - .../src/common/pure/TradeDetailsAccordion/index.tsx | 11 +++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts index 0202d7c7f8a..ceedcb03c4a 100644 --- a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts +++ b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts @@ -23,7 +23,7 @@ export class BridgeRoutePanel { // `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('[aria-expanded][class*="SummaryClickable-"]').first() + this.expandToggle = page.locator('.trade-details-accordion-toggle').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/) 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 index 9129fb1048d..a10468be5a2 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -114,7 +114,6 @@ test.describe('Cross-chain swaps', () => { const url = `/#/${opts.chainId}/swap/${opts.sell}/${opts.buy}?targetChainId=${opts.targetChainId}&sellAmount=${opts.sellAmount}` await swapPage.page.goto(url) await swapPage.page.reload() - await swapPage.unlockIfNeeded() } test('[CS-285] Cross-chain swap UI: accessible via Swap form @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} From 403d1c017f84d131f522e5458062f9e54a62d3a8 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 19:14:44 +0200 Subject: [PATCH 17/35] chore: fix BridgeRoutePanel.expand --- .../cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts | 13 +++++++++++-- .../bridge/pure/CollapsibleBridgeRoute/index.tsx | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts index ceedcb03c4a..59b307203cb 100644 --- a/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts +++ b/apps/cowswap-e2e-tests/src/pages/BridgeRoutePanel.ts @@ -11,6 +11,7 @@ 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 @@ -24,6 +25,7 @@ export class BridgeRoutePanel { // `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/) @@ -35,11 +37,18 @@ export class BridgeRoutePanel { 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 { - if ((await this.expandToggle.getAttribute('aria-expanded')) !== 'true') { + 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) } - await this.swapStopTitle.waitFor({ state: 'visible' }) } /** 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 && ( Date: Thu, 13 Aug 2026 19:16:13 +0200 Subject: [PATCH 18/35] chore: fix config --- apps/cowswap-e2e-tests/playwright.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index 3c00d16975a..a721b3a1f48 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: 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'), From b8372f9326f4c5537ddf36aa40d6826d6bda28b5 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 19:36:05 +0200 Subject: [PATCH 19/35] chore: mock token nonce call --- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 2 + .../cowswap-e2e-tests/src/mocks/tokenNonce.ts | 74 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 165848c534e..fa0a0ebb8e9 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -13,6 +13,7 @@ import { installMulticall3 } from '../mocks/multicall3' import { installNearIntents, type NearIntentsMock } from '../mocks/nearIntents' import { installSafeSdk, type SafeSdkMock } from '../mocks/safeSdk' import { installTokenLists, type TokenListsMock } from '../mocks/tokenLists' +import { installTokenNonce } from '../mocks/tokenNonce' import { installUsdPrices, type UsdPricesMock } from '../mocks/usdPrices' import { AccountModal } from '../pages/AccountModal' import { AccountPage } from '../pages/AccountPage' @@ -125,6 +126,7 @@ export const sharedFixtures: Fixtures< installEthBlockNumber(context) installEthEstimateGas(context) installEthGetTransactionCount(context) + installTokenNonce(context) installMulticall3(context, { allowances }) // Fires regardless of whether the UI ever shows an Approve step (confirmed by tracing real // traffic under `LOG_UNMOCKED_RPC=1` — it hit cross-chain tests that pre-seed a sufficient 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..88bc6738513 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts @@ -0,0 +1,74 @@ +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` — not the wallet's + * provider (unlike `mockSocketVerifier.ts`'s SocketVerifier reads) — so it's 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) +} From 01bb3f1743a054044066a339e53c9db5b648d89c Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 20:06:34 +0200 Subject: [PATCH 20/35] chore: fix test --- .../src/tests/cross-chain-swaps.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index a10468be5a2..4bc32e1aa32 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -98,11 +98,11 @@ test.describe('Cross-chain swaps', () => { * 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 — `bridgingSdk`'s available-provider set is a page-lifetime singleton - * (`tradingSdk/bridgingSdk.ts` seeds it once at module load), so a test that calls this twice to - * switch providers (`configureProviders` in between) needs a genuine reload for the switch to - * take effect; `page.reload()` re-reads whatever hash is already in the address bar, so it must - * run after the hash is set, not before. + * 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 }, @@ -113,7 +113,6 @@ test.describe('Cross-chain swaps', () => { await swapPage.unlockIfNeeded() const url = `/#/${opts.chainId}/swap/${opts.sell}/${opts.buy}?targetChainId=${opts.targetChainId}&sellAmount=${opts.sellAmount}` await swapPage.page.goto(url) - await swapPage.page.reload() } test('[CS-285] Cross-chain swap UI: accessible via Swap form @smoke', async ({ @@ -141,6 +140,7 @@ test.describe('Cross-chain swaps', () => { 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, From 9e4bcc4d68943f9c7add52992f469eedaa7334f1 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 21:48:21 +0200 Subject: [PATCH 21/35] chore: adjust config --- apps/cowswap-e2e-tests/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index a721b3a1f48..baef17b2aa2 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, expect: { timeout: 10_000 }, retries: process.env.CI ? 1 : 0, - workers: 6, + 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'), From 4f0cae1915ad0be968fd7a8f81c11a4050a4cdd6 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 21:57:51 +0200 Subject: [PATCH 22/35] feat(e2e): add OrdersMock, a multi-order registry replacing the single-slot postedOrder mock --- .gitignore | 3 + .../src/mocks/orders/index.test.ts | 149 ++++++++++ .../src/mocks/orders/index.ts | 271 ++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts create mode 100644 apps/cowswap-e2e-tests/src/mocks/orders/index.ts 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/src/mocks/orders/index.test.ts b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts new file mode 100644 index 00000000000..ef09a4c0177 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts @@ -0,0 +1,149 @@ +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 { 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) +}) 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..69c9a2c75a2 --- /dev/null +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.ts @@ -0,0 +1,271 @@ +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')}` +} + +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(_opts) { + throw new Error('seedOpenOrder: not implemented yet (Task 2)') + }, + wasCancelRequested(_orderId) { + throw new Error('wasCancelRequested: not implemented yet (Task 2)') + }, + markCancelled(_orderId) { + throw new Error('markCancelled: not implemented yet (Task 2)') + }, + trackEthFlowOrder(_ethFlow) { + throw new Error('trackEthFlowOrder: not implemented yet (Task 3)') + }, + + getOrder(orderId) { + return state.registry.get(orderId)?.order ?? undefined + }, + + reset() { + state.registry.clear() + state.ethFlowTracker = null + }, + } +} + +/** Placeholder so Task 1 compiles standalone; replaced by the real implementation in Task 3. */ +function buildEthFlowOrder(_ethFlow: MockEthFlowTransactionHandle, defaults: Record): unknown { + return defaults +} + +/** 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 }, + }, + ], + } +} + +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 +} From 999afd99f996dc0555f5fa4dbed6cd9668a094ab Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 22:01:05 +0200 Subject: [PATCH 23/35] chore: fix config --- apps/cowswap-e2e-tests/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index baef17b2aa2..424739c3604 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, expect: { timeout: 10_000 }, retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 2 : 6, + workers: process.env.CI ? 1 : 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'), From de8060e97be0fc0f6c78210b9a4398acd05c02b2 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 22:04:47 +0200 Subject: [PATCH 24/35] feat(e2e): add seedOpenOrder/wasCancelRequested/markCancelled to OrdersMock --- .../src/mocks/orders/index.test.ts | 78 +++++++++++++++++++ .../src/mocks/orders/index.ts | 72 +++++++++++++++-- 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts index ef09a4c0177..3bd991e5f0c 100644 --- a/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts @@ -147,3 +147,81 @@ test('reset() clears the registry', async () => { 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/) +}) diff --git a/apps/cowswap-e2e-tests/src/mocks/orders/index.ts b/apps/cowswap-e2e-tests/src/mocks/orders/index.ts index 69c9a2c75a2..862763b193c 100644 --- a/apps/cowswap-e2e-tests/src/mocks/orders/index.ts +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.ts @@ -137,14 +137,28 @@ export function installOrdersMock(cowApi: CowProtocolApiMock): OrdersMock { entry.stage = 'executing' }, - seedOpenOrder(_opts) { - throw new Error('seedOpenOrder: not implemented yet (Task 2)') + 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) { - throw new Error('wasCancelRequested: not implemented yet (Task 2)') + + wasCancelRequested(orderId) { + const entry = state.registry.get(orderId) + if (!entry) throw new Error(`wasCancelRequested: unknown orderId ${orderId}`) + return entry.cancelRequested }, - markCancelled(_orderId) { - throw new Error('markCancelled: not implemented yet (Task 2)') + + 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) { throw new Error('trackEthFlowOrder: not implemented yet (Task 3)') @@ -229,6 +243,52 @@ function buildOrderStatus(type: 'executing' | 'traded', body: OrderCreation): { } } +/** 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) { From 415fb186d5b4938662f41fccf50b51a4be4d73b4 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 22:09:57 +0200 Subject: [PATCH 25/35] feat(e2e): add trackEthFlowOrder to OrdersMock, completing the merged orders registry --- .../src/mocks/orders/index.test.ts | 59 +++++++++++++++++++ .../src/mocks/orders/index.ts | 30 ++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts index 3bd991e5f0c..d600bf0c4cb 100644 --- a/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.test.ts @@ -8,6 +8,7 @@ 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)}` @@ -225,3 +226,61 @@ 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 index 862763b193c..6aac1eaa3ca 100644 --- a/apps/cowswap-e2e-tests/src/mocks/orders/index.ts +++ b/apps/cowswap-e2e-tests/src/mocks/orders/index.ts @@ -83,6 +83,7 @@ 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 } @@ -160,8 +161,13 @@ export function installOrdersMock(cowApi: CowProtocolApiMock): OrdersMock { if (!entry?.order) throw new Error(`markCancelled: unknown orderId ${orderId}`) entry.order = { ...entry.order, invalidated: true } }, - trackEthFlowOrder(_ethFlow) { - throw new Error('trackEthFlowOrder: not implemented yet (Task 3)') + trackEthFlowOrder(ethFlow) { + state.ethFlowTracker = { ethFlow, indexed: false } + return { + markIndexed: () => { + if (state.ethFlowTracker) state.ethFlowTracker.indexed = true + }, + } }, getOrder(orderId) { @@ -175,9 +181,23 @@ export function installOrdersMock(cowApi: CowProtocolApiMock): OrdersMock { } } -/** Placeholder so Task 1 compiles standalone; replaced by the real implementation in Task 3. */ -function buildEthFlowOrder(_ethFlow: MockEthFlowTransactionHandle, defaults: Record): unknown { - return defaults +/** 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. */ From 6e02a5b134b8ad37eddb4ac94dd387185a377f93 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 22:14:56 +0200 Subject: [PATCH 26/35] feat(e2e): wire OrdersMock into the mocks fixture as mocks.orders --- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index fa0a0ebb8e9..09d383da5fc 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -11,6 +11,7 @@ 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 { installTokenNonce } from '../mocks/tokenNonce' @@ -45,6 +46,7 @@ export interface SharedFixtures { allowances: AllowancesMock balances: BalancesMock cowApi: CowProtocolApiMock + orders: OrdersMock ethGetCode: EthGetCodeMock tokenLists: TokenListsMock safeSdk: SafeSdkMock @@ -122,6 +124,7 @@ 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) @@ -143,6 +146,7 @@ export const sharedFixtures: Fixtures< allowances, balances, cowApi, + orders, ethGetCode, tokenLists, safeSdk, @@ -164,6 +168,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 { From fff52c0afdb6f1f552d2914c7ee033b0a0c115eb Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 22:50:50 +0200 Subject: [PATCH 27/35] refactor(e2e): migrate market-orders.spec.ts to mocks.orders --- .../src/tests/market-orders.spec.ts | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) 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 ({ From b79eaa19986ac6c3d1a0b75f40b71a16e3e611c3 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 22:59:32 +0200 Subject: [PATCH 28/35] fix(e2e): restore tradePage destructuring in market-orders.spec.ts (Task 8 removes it) --- apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts | 3 +++ 1 file changed, 3 insertions(+) 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 eda8ace2c16..9b410b59833 100644 --- a/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts @@ -37,6 +37,7 @@ test.describe('Market Orders', () => { test('[CS-59] Sell order: ERC-20 → ERC-20 @smoke', async ({ swapPage, + tradePage, wallet, confirmModal, accountModal, @@ -146,6 +147,7 @@ test.describe('Market Orders', () => { test('[CS-60] Buy order: specify exact buy amount (ERC-20) @smoke', async ({ swapPage, + tradePage, wallet, confirmModal, accountModal, @@ -1095,6 +1097,7 @@ test.describe('Market Orders', () => { test('[CS-118] Progress bar: regular order happy path — steps 1 → 2 → 3 → 4', async ({ swapPage, + tradePage, wallet, confirmModal, mocks, From f68596df25cc963b93d6d1ca346f32ff3d3b4917 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 23:03:24 +0200 Subject: [PATCH 29/35] refactor(e2e): migrate cross-chain-swaps.spec.ts to mocks.orders --- .../src/tests/cross-chain-swaps.spec.ts | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) 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 index 4bc32e1aa32..4b82aa29618 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -4,6 +4,7 @@ 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' @@ -203,13 +204,18 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.routePanel.bridgeMinToDeposit()).toHaveAttribute('title', /.+/) await expect(swapPage.routePanel.bridgeMinToReceive()).toHaveAttribute('title', /.+/) - const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) - - await swapPage.clickPrimaryAction() - await confirmModal.confirm() + 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' }) - posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE, 0n) + 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. @@ -262,13 +268,18 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.routePanel.bridgeMinToDeposit()).toHaveAttribute('title', /.+/) await expect(swapPage.routePanel.bridgeMinToReceive()).toHaveAttribute('title', /.+/) - const posting = tradePage.mockOrderPosting(mocks.cowApi, wallet.address) - - await swapPage.clickPrimaryAction() - await confirmModal.confirm() + 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' }) - posting.fulfill(mocks.balances, MAINNET, INITIAL_USDC_BALANCE, 0n) + 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. From 90cd46bc6a84e3008374a7a3af157fabf1ae238e Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 23:08:48 +0200 Subject: [PATCH 30/35] refactor(e2e): migrate limit-orders.spec.ts to mocks.orders --- .../src/tests/limit-orders.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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. From 68ffe34a2666864caad1a2531c8dcaaf09371c1a Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 23:28:59 +0200 Subject: [PATCH 31/35] refactor(e2e): remove old order mocks --- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 6 - .../src/support/mockCancellableOrder.ts | 101 ------------ .../src/support/mockEthFlowOrderIndexing.ts | 53 ------ .../src/support/mockOrderPosting.ts | 156 ------------------ .../src/tests/cross-chain-swaps.spec.ts | 10 +- .../src/tests/market-orders.spec.ts | 3 - 6 files changed, 1 insertion(+), 328 deletions(-) delete mode 100644 apps/cowswap-e2e-tests/src/support/mockCancellableOrder.ts delete mode 100644 apps/cowswap-e2e-tests/src/support/mockEthFlowOrderIndexing.ts delete mode 100644 apps/cowswap-e2e-tests/src/support/mockOrderPosting.ts diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 09d383da5fc..26f6fe33917 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -25,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' @@ -40,8 +39,6 @@ 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 @@ -92,9 +89,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() 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 index 4b82aa29618..7aeba220f90 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -155,14 +155,7 @@ test.describe('Cross-chain swaps', () => { await expect(swapPage.routePanel.bridgeStopTitle('Near Intents')).toBeVisible() }) - test('[CS-286] Cross-chain swap: Near provider', async ({ - swapPage, - tradePage, - wallet, - confirmModal, - mocks, - rpcProxy, - }) => { + 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 }, @@ -224,7 +217,6 @@ test.describe('Cross-chain swaps', () => { test('[CS-287] Cross-chain swap: Bungee provider @smoke', async ({ swapPage, - tradePage, wallet, confirmModal, mocks, 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 9b410b59833..eda8ace2c16 100644 --- a/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/market-orders.spec.ts @@ -37,7 +37,6 @@ test.describe('Market Orders', () => { test('[CS-59] Sell order: ERC-20 → ERC-20 @smoke', async ({ swapPage, - tradePage, wallet, confirmModal, accountModal, @@ -147,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, @@ -1097,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, From 08d4834880b1798c071b185202ad225809422181 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 23:30:10 +0200 Subject: [PATCH 32/35] chore: bump workers --- apps/cowswap-e2e-tests/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-e2e-tests/playwright.config.ts b/apps/cowswap-e2e-tests/playwright.config.ts index 424739c3604..baef17b2aa2 100644 --- a/apps/cowswap-e2e-tests/playwright.config.ts +++ b/apps/cowswap-e2e-tests/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, expect: { timeout: 10_000 }, retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 1 : 6, + 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'), From c66d30e4e64c2876850ec86cfc151da31ffa295a Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 13 Aug 2026 23:51:03 +0200 Subject: [PATCH 33/35] chore: remove tokens mock --- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 5 -- .../cowswap-e2e-tests/src/mocks/tokenLists.ts | 51 ------------------- 2 files changed, 56 deletions(-) delete mode 100644 apps/cowswap-e2e-tests/src/mocks/tokenLists.ts diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 26f6fe33917..619cbec9832 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -13,7 +13,6 @@ 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 { installTokenNonce } from '../mocks/tokenNonce' import { installUsdPrices, type UsdPricesMock } from '../mocks/usdPrices' import { AccountModal } from '../pages/AccountModal' @@ -45,7 +44,6 @@ export interface SharedFixtures { cowApi: CowProtocolApiMock orders: OrdersMock ethGetCode: EthGetCodeMock - tokenLists: TokenListsMock safeSdk: SafeSdkMock bungee: BungeeMock nearIntents: NearIntentsMock @@ -129,7 +127,6 @@ export const sharedFixtures: Fixtures< // 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) @@ -142,7 +139,6 @@ export const sharedFixtures: Fixtures< cowApi, orders, ethGetCode, - tokenLists, safeSdk, bungee, nearIntents, @@ -151,7 +147,6 @@ export const sharedFixtures: Fixtures< }) ethGetCode.reset() - tokenLists.reset() bungee.reset() nearIntents.reset() await launchDarkly.reset() 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() - }, - } -} From 513a336734d00a2ff79d1bab0d61f8fab27a2f4e Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Fri, 14 Aug 2026 00:48:18 +0200 Subject: [PATCH 34/35] chore: fix socketVerifier mock --- apps/cowswap-e2e-tests/AGENTS.md | 62 +++-- apps/cowswap-e2e-tests/src/fixtures/shared.ts | 9 +- apps/cowswap-e2e-tests/src/mocks/bungee.ts | 2 +- .../src/mocks/ethEstimateGas.ts | 2 +- .../cowswap-e2e-tests/src/mocks/multicall3.ts | 20 +- .../src/mocks/socketVerifier.ts | 232 ++++++++++++++++++ .../cowswap-e2e-tests/src/mocks/tokenNonce.ts | 3 +- .../src/support/logUnmockedRpcRequests.ts | 2 +- .../src/support/mockApproveSimulation.ts | 2 +- .../src/support/mockApproveTransaction.ts | 12 +- .../src/support/mockSocketVerifier.ts | 34 --- .../src/tests/cross-chain-swaps.spec.ts | 4 - 12 files changed, 300 insertions(+), 84 deletions(-) create mode 100644 apps/cowswap-e2e-tests/src/mocks/socketVerifier.ts delete mode 100644 apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts diff --git a/apps/cowswap-e2e-tests/AGENTS.md b/apps/cowswap-e2e-tests/AGENTS.md index f8f6ec12b6e..0419c8e0b4e 100644 --- a/apps/cowswap-e2e-tests/AGENTS.md +++ b/apps/cowswap-e2e-tests/AGENTS.md @@ -234,27 +234,47 @@ never a logic bug in the test — check infrastructure contention first. 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. -- **Not every on-chain read the app makes even reaches the page's network layer at all — some go - through the *connected wallet's own provider* instead, invisible to any `context.route()`.** - Bungee's on-chain SocketVerifier check (`validateRotueId`/`validateSocketRequest`, - `verifyBungeeBuildTxData` in `@cowprotocol/sdk-bridging`) was originally mocked with a - `context.route('**/*', ...)` handler decoding Multicall3 batches — modeled on `mockEthEstimateGas` - — and it 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 specific check runs 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. - `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`) — there is no page-level network request for `context.route()` to ever see. - Fixed in `mockSocketVerifier.ts` by switching to the RPC proxy's own existing per-`(to, selector)` - stub primitive instead: `rpcProxy.stubCall({ chainId, to: SOCKET_VERIFIER_ADDRESS, dataPrefix: - selector, returnHex: '0x' })` — no Multicall3-batch decoding needed at all, since a wallet-forwarded - `eth_call` is never batched. **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. +- **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; diff --git a/apps/cowswap-e2e-tests/src/fixtures/shared.ts b/apps/cowswap-e2e-tests/src/fixtures/shared.ts index 619cbec9832..3bfc2c2d136 100644 --- a/apps/cowswap-e2e-tests/src/fixtures/shared.ts +++ b/apps/cowswap-e2e-tests/src/fixtures/shared.ts @@ -13,6 +13,7 @@ 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 { installSocketVerifier } from '../mocks/socketVerifier' import { installTokenNonce } from '../mocks/tokenNonce' import { installUsdPrices, type UsdPricesMock } from '../mocks/usdPrices' import { AccountModal } from '../pages/AccountModal' @@ -101,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 }) } @@ -123,6 +124,10 @@ export const sharedFixtures: Fixtures< 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. diff --git a/apps/cowswap-e2e-tests/src/mocks/bungee.ts b/apps/cowswap-e2e-tests/src/mocks/bungee.ts index f4428f18717..713f64281e1 100644 --- a/apps/cowswap-e2e-tests/src/mocks/bungee.ts +++ b/apps/cowswap-e2e-tests/src/mocks/bungee.ts @@ -106,7 +106,7 @@ export function installBungee(context: BrowserContext): BungeeMock { 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 `mockSocketVerifier`. + // 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 { 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/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/tokenNonce.ts b/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts index 88bc6738513..3271e745a51 100644 --- a/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts +++ b/apps/cowswap-e2e-tests/src/mocks/tokenNonce.ts @@ -16,8 +16,7 @@ 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` — not the wallet's - * provider (unlike `mockSocketVerifier.ts`'s SocketVerifier reads) — so it's a real page network + * `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 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 beac7948bba..d7ef435be69 100644 --- a/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts +++ b/apps/cowswap-e2e-tests/src/support/mockApproveSimulation.ts @@ -15,7 +15,7 @@ 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, so it needs the same host-agnostic matching `mockSocketVerifier.ts` + * 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 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/mockSocketVerifier.ts b/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts deleted file mode 100644 index 6cbb6b4d3bb..00000000000 --- a/apps/cowswap-e2e-tests/src/support/mockSocketVerifier.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { toFunctionSelector } from 'viem' - -import type { RpcProxyHandle } from '../fixtures/rpcProxy' - -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)))'), -] - -/** - * `BungeeBridgeProvider.getQuote()` verifies the build-tx it gets from Bungee's API by reading two - * functions on the on-chain SocketVerifier contract, on the origin chain — Near Intents never does - * this. This is *not* a call this suite's own read-only viem client makes on the app's behalf: the - * SDK adapter's `readContract` here runs against the **connected wallet's own provider**, not a - * separate HTTP transport — confirmed after `context.route()`-based interception (matching on the - * page's own network requests) turned out to miss it entirely under load, because there's no page - * network request to intercept in the first place. `eth_call`s made through the wallet's provider - * go through `walletEngine.ts`'s `dispatch()` → `forward()`, a plain Node-side `fetch()` to this - * suite's own RPC proxy (`support/rpcProxy.ts`) that never touches the browser's network layer at - * all. `rpcProxy.stubCall()` is the proxy's own existing per-`(to, selector)` stub primitive — the - * right layer to answer this, not a page-level route. Without it, the real call reverts with - * `RouteIdNotFound()` and every Bungee quote fetch fails with `TX_BUILD_ERROR`. - */ -export async function mockSocketVerifier(rpcProxy: RpcProxyHandle, chainId: number): Promise { - for (const selector of STUBBED_SELECTORS) { - await rpcProxy.stubCall({ chainId, to: SOCKET_VERIFIER_ADDRESS, dataPrefix: selector, returnHex: '0x' }) - } -} 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 index 7aeba220f90..1e8b63e330a 100644 --- a/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts +++ b/apps/cowswap-e2e-tests/src/tests/cross-chain-swaps.spec.ts @@ -8,7 +8,6 @@ import { generateOrderId } from '../mocks/orders' import { CHAIN_IDS } from '../support/constants' import { mockEthFlowTransaction } from '../support/mockEthFlowTransaction' import { mockFixedRateQuote } from '../support/mockFixedRateQuote' -import { mockSocketVerifier } from '../support/mockSocketVerifier' import { seedTrader } from '../support/seedTrader' import type { RpcProxyHandle } from '../fixtures/rpcProxy' @@ -82,9 +81,6 @@ test.describe('Cross-chain swaps', () => { await mocks.launchDarkly.setFlag('isBungeeBridgeProviderEnabled', active === 'bungee') await mocks.launchDarkly.setFlag('isNearIntentsBridgeProviderEnabled', active === 'near-intents') mockFixedRateQuote({ cowApi: mocks.cowApi, rate: { numerator: 999n, denominator: 1000n } }) - if (active === 'bungee') { - await mockSocketVerifier(rpcProxy, MAINNET) - } } /** From 36d390d45ec7b51365486e4cc87741c66d89cd9c Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Fri, 14 Aug 2026 00:53:33 +0200 Subject: [PATCH 35/35] chore: fix tooltip --- apps/cowswap-e2e-tests/src/pages/SwapPage.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts index 61176100630..44311235df5 100644 --- a/apps/cowswap-e2e-tests/src/pages/SwapPage.ts +++ b/apps/cowswap-e2e-tests/src/pages/SwapPage.ts @@ -86,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()