Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ef14f70
fix: switch network through reown
shoom3301 Jul 13, 2026
4789ba4
docs: design for EVM/non-EVM network switching flow
shoom3301 Jul 13, 2026
a9ad910
feat(solana): switch between EVM/non-EVM chains
shoom3301 Jul 13, 2026
fdbc00b
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 13, 2026
dde49f1
fix: support non-EVM network switching from URL
shoom3301 Jul 13, 2026
c97af95
fix: handle wallet disconnect error
shoom3301 Jul 13, 2026
11e5ce3
chore: rename test
shoom3301 Jul 14, 2026
2a8d3ee
feat(solana): load token balances
shoom3301 Jul 14, 2026
f249cde
Merge branch 'develop' of https://github.com/cowprotocol/cowswap into…
shoom3301 Jul 14, 2026
e9e29e2
fix: confirm cross-chain switch on currency select
shoom3301 Jul 14, 2026
1db89fb
chore: update text
shoom3301 Jul 14, 2026
755d8d3
Merge branch 'develop' into solana/web-1
fairlighteth Jul 14, 2026
b1d4de1
chore: rename text
shoom3301 Jul 14, 2026
2306273
chore: ff IS_SOLANA_ENABLED in URL
shoom3301 Jul 14, 2026
0a6d43d
chore: ff IS_SOLANA_ENABLED in URL
shoom3301 Jul 14, 2026
6dd1ff6
Merge branch 'develop' of https://github.com/cowprotocol/cowswap into…
shoom3301 Jul 14, 2026
bb85f30
Merge remote-tracking branch 'origin/solana/web-1' into solana/web-1
shoom3301 Jul 14, 2026
855a751
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 14, 2026
079e0f0
chore: update confirm text
shoom3301 Jul 15, 2026
0cdca9a
fix: disable bridge from Solana
shoom3301 Jul 15, 2026
e0fd749
fix: update text
shoom3301 Jul 15, 2026
1fce32b
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 15, 2026
72254aa
fix: fix network switching
shoom3301 Jul 15, 2026
f1d582d
Merge remote-tracking branch 'origin/solana/web-1' into solana/web-1
shoom3301 Jul 15, 2026
f17495f
fix: fix network switching
shoom3301 Jul 15, 2026
5c916cd
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 Jul 15, 2026
a3e8e29
feat(solana): support token2022
shoom3301 Jul 15, 2026
8144ef2
fix: capture network switch error
shoom3301 Jul 15, 2026
e7c453a
fix: fix account.toLowerCase
shoom3301 Jul 15, 2026
a4c85c9
fix: isolates malformed mint
shoom3301 Jul 15, 2026
68c3211
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 Jul 15, 2026
e624ae6
chore: add solana default RPC
shoom3301 Jul 15, 2026
8d440d7
fix(solana): fix recent wallet reconnect
shoom3301 Jul 15, 2026
27bff7d
fix: fix solana dedupe
shoom3301 Jul 15, 2026
979400f
fix: fix build
shoom3301 Jul 15, 2026
72dd7d8
chore: remove DISABLED_SOURCE_CHAINS
shoom3301 Jul 16, 2026
cbd9c2e
chore: fix solana bridge
shoom3301 Jul 16, 2026
eaf2e51
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 Jul 16, 2026
d0e58b0
Merge remote-tracking branch 'origin/solana/web-2' into solana/web-2
shoom3301 Jul 16, 2026
49150e3
Merge branch 'develop' into solana/web-2
shoom3301 Jul 17, 2026
65b0b61
chore: update comment
shoom3301 Jul 17, 2026
4c9078d
chore: add refetchOnMount
shoom3301 Jul 17, 2026
a228233
chore: add try catch
shoom3301 Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cowswap-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@reach/menu-button": "0.18.0",
"@react-spring/web": "9.7.3",
"@reduxjs/toolkit": "1.9.5",
"@reown/appkit-adapter-solana": "1.8.19",
"@reown/appkit-adapter-wagmi": "1.8.19",
"@reown/appkit-controllers": "1.8.19",
"@reown/appkit": "1.8.19",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useAtomValue } from 'jotai'

import type { BalancesState } from '@cowprotocol/balances-and-allowances'

import { balancesCombinedAtom } from '../state/balanceCombinedAtom'

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function useTokensBalancesCombined() {
export function useTokensBalancesCombined(): BalancesState {
return useAtomValue(balancesCombinedAtom)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import { useIsHooksTradeType } from 'modules/trade'

import { balancesCombinedAtom } from '../state/balanceCombinedAtom'

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function BalancesCombinedUpdater() {
export function BalancesCombinedUpdater(): null {
const { account, chainId } = useWalletInfo()
const setBalancesCombined = useSetAtom(balancesCombinedAtom)
const preHooksBalancesDiff = usePreHookBalanceDiff()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react'
import { useConfig, usePublicClient } from 'wagmi'

import { getIsNativeToken, getWrappedToken, COW_PROTOCOL_VAULT_RELAYER_ADDRESS } from '@cowprotocol/common-utils'
import { getAddressKey, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk'
import { getAddressKey, isNonEvmChain, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk'
import { Currency } from '@cowprotocol/currency'
import { DEFAULT_MIN_GAS_LIMIT, getTokenPermitInfo, PermitInfo } from '@cowprotocol/permit-utils'
import { useWalletInfo } from '@cowprotocol/wallet'
Expand Down Expand Up @@ -73,9 +73,11 @@ export function usePermitInfo(

const spender = customSpender || COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId]

// eslint-disable-next-line complexity
useEffect(() => {
if (
!chainId ||
isNonEvmChain(chainId) ||
!isPermitEnabled ||
!lowerCaseAddress ||
!config ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { MouseEventHandler, ReactNode } from 'react'

import { TokenWithLogo } from '@cowprotocol/common-const'
import { getCurrencyAddress } from '@cowprotocol/common-utils'
import { areAddressesEqual, getAddressKey, getTokenId, isEvmChain } from '@cowprotocol/cow-sdk'
import { areAddressesEqual, getAddressKey, getTokenId } from '@cowprotocol/cow-sdk'
import { Currency, CurrencyAmount } from '@cowprotocol/currency'
import { TokenListTags } from '@cowprotocol/tokens'
import { FiatAmount, HoverTooltip, LoadingRows, LoadingRowSmall, TokenAmount } from '@cowprotocol/ui'
Expand Down Expand Up @@ -108,12 +108,7 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
})

const isTokenSelected = checkIsTokenSelected(token, selectedToken)
// Balances are only fetched for EVM chains (wagmi + BFF are EVM-only). Bridge-only
// destinations like Solana or Bitcoin land here too — for them, skip the balance column
// entirely instead of rendering an indefinite loading skeleton.
// todo remove it when FE supports Solana rpc connection
const canShowBalances = isEvmChain(token.chainId)
const shouldShowBalances = isWalletConnected && canShowBalances
const shouldShowBalances = isWalletConnected
const shouldFormatBalances = shouldShowBalances && hasIntersected
const balanceAmount =
shouldFormatBalances && balance !== undefined ? CurrencyAmount.fromRawAmount(token, balance.toString()) : undefined
Expand Down
10 changes: 9 additions & 1 deletion apps/cowswap-frontend/vite.config.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="vitest" />
import { lingui } from '@lingui/vite-plugin'

import { sentryVitePlugin } from '@sentry/vite-plugin'
import react from '@vitejs/plugin-react-swc'
import { bundleStats } from 'rollup-plugin-bundle-stats'
Expand Down Expand Up @@ -289,7 +290,14 @@ export default defineConfig(({ mode, isPreview }) => {
// in @reown/appkit-adapter-solana (#7709), pnpm resolves the appkit family to two
// peer-instances; without deduping the controllers package, code in libs/wallet reads
// an empty ConnectorController while the deduped appkit/adapter-wagmi populate the other.
dedupe: ['react-router', '@reown/appkit', '@reown/appkit-adapter-wagmi', '@reown/appkit-controllers', 'wagmi'],
dedupe: [
'react-router',
'@reown/appkit',
'@reown/appkit-adapter-wagmi',
'@reown/appkit-adapter-solana',
'@reown/appkit-controllers',
'wagmi',
],
},

build: {
Expand Down
4 changes: 4 additions & 0 deletions libs/balances-and-allowances/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
"@cowprotocol/wallet": "workspace:*",
"@cowprotocol/wallet-provider": "workspace:*",
"@cowprotocol/currency": "workspace:*",
"@reown/appkit-adapter-solana": "1.8.19",
"@solana/spl-token": "0.4.14",
"@solana/web3.js": "1.98.4",
"@tanstack/react-query": "5.90.20",
"jotai": "2.16.2",
"ms.macro": "2.0.0",
"react": "19.1.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ jest.mock('wagmi', () => ({
useReadContracts: jest.fn(),
}))

// The Solana path has its own dedicated test; stub it here so this suite stays focused on
// EVM wagmi gating and avoids pulling in the reown/web3/react-query runtime.
jest.mock('./usePersistSolanaBalancesViaWebCalls', () => ({
usePersistSolanaBalancesViaWebCalls: jest.fn(),
}))

const mockBalancesUpdate: PersistentStateByChain<Record<string, number | undefined>> = mapSupportedNetworks({})

const wrapper = ({ children }: { children: ReactNode }): ReactNode => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getIsNativeToken } from '@cowprotocol/common-utils'
import { isEvmChain, SupportedChainId } from '@cowprotocol/cow-sdk'

import { useIsBlockNumberRelevant } from './useIsBlockNumberRelevant'
import { usePersistSolanaBalancesViaWebCalls } from './usePersistSolanaBalancesViaWebCalls'

import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom'

Expand Down Expand Up @@ -49,6 +50,9 @@ export function usePersistBalancesViaWebCalls(params: PersistBalancesAndAllowanc
// wagmi + viem only support evm chains
const isEvm = isEvmChain(chainId)

// Non-EVM chains (e.g. Solana) load balances via their own web calls
usePersistSolanaBalancesViaWebCalls(params)

const {
data: balances,
isLoading: isBalancesLoading,
Expand Down Expand Up @@ -79,21 +83,21 @@ export function usePersistBalancesViaWebCalls(params: PersistBalancesAndAllowanc

// Set balances loading state
useEffect(() => {
if (!setLoadingState) return
if (!setLoadingState || !isEvm) return

setBalances((state) => ({ ...state, isLoading: isBalancesLoading, chainId }))
}, [setBalances, isBalancesLoading, setLoadingState, chainId])
}, [setBalances, isBalancesLoading, setLoadingState, isEvm, chainId])

// Set balances error state for full balances fetches only
useEffect(() => {
if (!setLoadingState) return
if (!setLoadingState || !isEvm) return

if (!error) return

const message = error instanceof Error ? error.message : String(error)

setBalances((state) => ({ ...state, error: message, isLoading: false }))
}, [setBalances, error, setLoadingState])
}, [setBalances, error, setLoadingState, isEvm])

// Set balances to the store
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { Provider, useAtomValue } from 'jotai'
import { useHydrateAtoms } from 'jotai/utils'
import React, { ReactNode } from 'react'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

import { TOKEN_2022_TAG } from '@cowprotocol/common-const'
import { getAddressKey, SupportedChainId, mapSupportedNetworks, solana } from '@cowprotocol/cow-sdk'
import { PersistentStateByChain } from '@cowprotocol/types'

import { PublicKey } from '@solana/web3.js'
import { renderHook, waitFor } from '@testing-library/react'

import { PersistBalancesAndAllowancesParams } from './usePersistBalancesViaWebCalls'
import { usePersistSolanaBalancesViaWebCalls } from './usePersistSolanaBalancesViaWebCalls'

import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom'

// Valid base58 addresses so `new PublicKey(...)` inside the hook does not throw.
const ACCOUNT = 'So11111111111111111111111111111111111111112'
const MINT_A = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
const MINT_B = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
const NATIVE_MINT = solana.nativeCurrency.address

const CLASSIC_PROGRAM = 'TOKEN_PROGRAM_ID'
const TOKEN_2022_PROGRAM = 'TOKEN_2022_PROGRAM_ID'

// The ATA key encodes the selected program, so a test can assert which token program a mint was read under.
function ataKey(mint: string, program: string = CLASSIC_PROGRAM): string {
return `ata:${program}:${mint}`
}

let mockConnection: MockConnection | undefined
// getAddressKey(address) -> token metadata, mirroring the token list; its tags drive program selection.
let mockTokensByAddress: Record<string, { tags: string[] } | undefined>

jest.mock('@reown/appkit-adapter-solana/react', () => ({
useAppKitConnection: () => ({ connection: mockConnection }),
}))

jest.mock('@cowprotocol/tokens', () => ({
useTokensByAddressMapForChain: () => mockTokensByAddress,
}))

// The ATA-derivation math is not what we are testing; make it deterministic and echo the mint plus the
// selected program back so the mocked RPC/`unpackAccount` can look accounts up by their ATA key. We only
// assert what lands in the atom and which program each ATA was derived with.
jest.mock('@solana/spl-token', () => ({
TOKEN_PROGRAM_ID: 'TOKEN_PROGRAM_ID',
TOKEN_2022_PROGRAM_ID: 'TOKEN_2022_PROGRAM_ID',
ASSOCIATED_TOKEN_PROGRAM_ID: 'ASSOCIATED_TOKEN_PROGRAM_ID',
getAssociatedTokenAddressSync: (
mint: { toBase58(): string },
_owner: unknown,
_allowOwnerOffCurve: boolean,
programId: string,
) => ({ toBase58: () => `ata:${programId}:${mint.toBase58()}` }),
unpackAccount: (ata: { toBase58(): string }) => ({ amount: mockAmountByAta[ata.toBase58()] }),
}))

interface MockConnection {
rpcEndpoint: string
getMultipleAccountsInfo: jest.Mock
}

// ATA base58 -> token amount, read by the mocked `unpackAccount`.
let mockAmountByAta: Record<string, bigint>
// ATA base58 -> account info; an absent entry means "no account exists" (a zero balance).
let mockInfoByAta: Record<string, { present: true } | undefined>

function createConnection(): MockConnection {
return {
rpcEndpoint: 'https://solana.example/rpc',
getMultipleAccountsInfo: jest.fn((batch: Array<{ toBase58(): string }>) =>
Promise.resolve(batch.map((ata) => mockInfoByAta[ata.toBase58()] ?? null)),
),
}
}

const mockBalancesUpdate: PersistentStateByChain<Record<string, number | undefined>> = mapSupportedNetworks({})

function makeParams(overrides: Partial<PersistBalancesAndAllowancesParams> = {}): PersistBalancesAndAllowancesParams {
return {
account: ACCOUNT,
chainId: SupportedChainId.SOLANA,
tokenAddresses: [MINT_A, MINT_B],
setLoadingState: true,
...overrides,
}
}

function renderWithBalances(params: PersistBalancesAndAllowancesParams): { result: { current: BalancesState } } {
return renderHook(
() => {
usePersistSolanaBalancesViaWebCalls(params)
return useAtomValue(balancesAtom)
},
{ wrapper },
)
}

function wrapper({ children }: { children: ReactNode }): ReactNode {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })

const HydrateAtoms = ({ children }: { children: ReactNode }): ReactNode => {
useHydrateAtoms([
[
balancesAtom,
{
isLoading: false,
chainId: SupportedChainId.SOLANA,
values: {},
fromCache: false,
hasFirstLoad: false,
error: null,
} as BalancesState,
],
[balancesUpdateAtom, mockBalancesUpdate],
])
return <>{children}</>
}

return (
<QueryClientProvider client={queryClient}>
<Provider>
<HydrateAtoms>{children}</HydrateAtoms>
</Provider>
</QueryClientProvider>
)
}

describe('usePersistSolanaBalancesViaWebCalls', () => {
beforeEach(() => {
mockTokensByAddress = {}
mockAmountByAta = { [ataKey(MINT_A)]: 100n, [ataKey(MINT_B)]: 250n }
mockInfoByAta = { [ataKey(MINT_A)]: { present: true }, [ataKey(MINT_B)]: { present: true } }
mockConnection = createConnection()
})

it('reads SPL balances via a single batched RPC call and persists them to the atom', async () => {
const { result } = renderWithBalances(makeParams())

await waitFor(() => expect(result.current.hasFirstLoad).toBe(true))

expect(mockConnection?.getMultipleAccountsInfo).toHaveBeenCalledTimes(1)
expect(result.current.values[getAddressKey(MINT_A)]).toBe(100n)
expect(result.current.values[getAddressKey(MINT_B)]).toBe(250n)
})

it('clears the loading state once balances are loaded', async () => {
const { result } = renderWithBalances(makeParams())

await waitFor(() => expect(result.current.hasFirstLoad).toBe(true))

expect(result.current.isLoading).toBe(false)
})

it('treats a missing token account as a zero balance rather than an error', async () => {
delete mockInfoByAta[ataKey(MINT_B)]

const { result } = renderWithBalances(makeParams())

await waitFor(() => expect(result.current.hasFirstLoad).toBe(true))

expect(result.current.values[getAddressKey(MINT_A)]).toBe(100n)
expect(result.current.values[getAddressKey(MINT_B)]).toBe(0n)
expect(result.current.error).toBeNull()
})

it('batches ATAs into chunks of 100 so large token lists do not exceed the RPC limit', async () => {
// 250 mints -> ceil(250 / 100) = 3 `getMultipleAccountsInfo` calls, none over the 100-account limit.
const mints = Array.from({ length: 250 }, (_, i) =>
new PublicKey(Uint8Array.from({ length: 32 }, (_, j) => (i + j + 1) % 256)).toBase58(),
)
mints.forEach((mint) => {
mockAmountByAta[ataKey(mint)] = 7n
mockInfoByAta[ataKey(mint)] = { present: true }
})

const { result } = renderWithBalances(makeParams({ tokenAddresses: mints }))

await waitFor(() => expect(result.current.hasFirstLoad).toBe(true))

expect(mockConnection?.getMultipleAccountsInfo).toHaveBeenCalledTimes(3)
mockConnection?.getMultipleAccountsInfo.mock.calls.forEach(([batch]) => {
expect(batch.length).toBeLessThanOrEqual(100)
})
expect(Object.keys(result.current.values)).toHaveLength(250)
expect(result.current.values[getAddressKey(mints[0])]).toBe(7n)
expect(result.current.values[getAddressKey(mints[249])]).toBe(7n)
})

it('excludes the native SOL address from the SPL batch (it has no ATA)', async () => {
renderWithBalances(makeParams({ tokenAddresses: [NATIVE_MINT, MINT_A] }))

await waitFor(() => expect(mockConnection?.getMultipleAccountsInfo).toHaveBeenCalled())

const [atas] = mockConnection!.getMultipleAccountsInfo.mock.calls[0]
expect(atas).toHaveLength(1)
expect(atas[0].toBase58()).toBe(ataKey(MINT_A))
})

it('keys the update timestamp by the case-sensitive Solana account, not a lowercased alias', async () => {
const { result } = renderHook(
() => {
usePersistSolanaBalancesViaWebCalls(makeParams())
return useAtomValue(balancesUpdateAtom)
},
{ wrapper },
)

await waitFor(() => expect(result.current[SupportedChainId.SOLANA]?.[getAddressKey(ACCOUNT)]).toBeDefined())

// getAddressKey preserves case for Solana pubkeys; a lowercased key would alias distinct owners.
expect(ACCOUNT).not.toBe(ACCOUNT.toLowerCase())
expect(result.current[SupportedChainId.SOLANA]?.[ACCOUNT.toLowerCase()]).toBeUndefined()
})

it('derives a Token-2022 ATA for mints tagged as Token-2022 in the token list', async () => {
mockTokensByAddress = { [getAddressKey(MINT_A)]: { tags: [TOKEN_2022_TAG] } }
mockAmountByAta = { [ataKey(MINT_A, TOKEN_2022_PROGRAM)]: 999n }
mockInfoByAta = { [ataKey(MINT_A, TOKEN_2022_PROGRAM)]: { present: true } }

const { result } = renderWithBalances(makeParams({ tokenAddresses: [MINT_A] }))

await waitFor(() => expect(result.current.hasFirstLoad).toBe(true))

const [atas] = mockConnection!.getMultipleAccountsInfo.mock.calls[0]
expect(atas[0].toBase58()).toBe(ataKey(MINT_A, TOKEN_2022_PROGRAM))
expect(result.current.values[getAddressKey(MINT_A)]).toBe(999n)
})
})
Loading
Loading