Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
adc3beb
feat(balances): track delegations for Solana
limitofzero Jul 24, 2026
e967c23
feat(account): add solana approve state(readonly)
limitofzero Jul 27, 2026
ba18a7a
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 27, 2026
6a3294c
refactor: rename hook + remove comments
limitofzero Jul 27, 2026
3ee5fc2
Merge branch 'feat/sol-delegation-tracking' of github.com:cowprotocol…
limitofzero Jul 27, 2026
090112f
Merge branch 'develop' into feat/sol-delegation-tracking
limitofzero Jul 27, 2026
3486391
fix: reset allowances when an address is being changed
limitofzero Jul 27, 2026
0025e59
Merge branch 'feat/sol-delegation-tracking' of github.com:cowprotocol…
limitofzero Jul 27, 2026
3d07e4f
fix: address review remarks
limitofzero Jul 27, 2026
a45f1b3
test: fix units
limitofzero Jul 28, 2026
817aeda
Merge branch 'develop' into feat/sol-delegation-tracking
elena-zh Jul 28, 2026
3e805b0
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Jul 28, 2026
2b2e9dd
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Jul 28, 2026
4044e40
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Jul 28, 2026
4847055
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Jul 28, 2026
94a71e2
feat: move settlement address to cow-sdk
limitofzero Jul 29, 2026
087d3e0
Merge branch 'develop' into feat/sol-delegation-tracking
limitofzero Jul 29, 2026
473dacd
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Jul 29, 2026
ef969c1
Merge branch 'develop' into feat/sol-delegation-tracking
limitofzero Jul 30, 2026
7f02643
Merge branch 'feat/sol-delegation-tracking' of github.com:cowprotocol…
limitofzero Jul 30, 2026
83e8bbb
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Jul 30, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ReactNode } from 'react'

import { isFractionFalsy } from '@cowprotocol/common-utils'
import { CurrencyAmount, Token } from '@cowprotocol/currency'
import { TokenAmount } from '@cowprotocol/ui'

import { Trans } from '@lingui/react/macro'

import { ApproveLabel, NotDelegatedLabel } from './styled'

type SplDelegationCellProps = {
balance: CurrencyAmount<Token> | undefined
allowance: CurrencyAmount<Token> | undefined
}

/**
* Read-only "Actions" cell for Solana rows. Solana has no manual approve flow: the SPL delegation to the
* CoW settlement authority is fetched read-only (persisted as an allowance), so this mirrors the EVM row's
* labels without any action button.
*/
export function SplDelegationCell({ balance, allowance }: SplDelegationCellProps): ReactNode {
// No delegation to the CoW settlement authority — neutral placeholder, not a green "approved" state.
if (isFractionFalsy(allowance)) {
return <NotDelegatedLabel>—</NotDelegatedLabel>
}

// Delegation covers the whole balance → surface it as fully approved, like the EVM row does.
const fullyDelegated = !!balance && !!allowance && !balance.greaterThan(allowance)

if (fullyDelegated) {
return (
<ApproveLabel>
<Trans>Approved</Trans> ✓
</ApproveLabel>
)
}

return (
<ApproveLabel>
<Trans>Approved</Trans>:{' '}
<strong>
<TokenAmount amount={allowance} />
</strong>
</ApproveLabel>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
isFractionFalsy,
COW_PROTOCOL_VAULT_RELAYER_ADDRESS,
} from '@cowprotocol/common-utils'
import { MAX_UINT256, getAddressKey } from '@cowprotocol/cow-sdk'
import { MAX_UINT256, getAddressKey, isSolanaChain } from '@cowprotocol/cow-sdk'
import { CurrencyAmount, Token } from '@cowprotocol/currency'
import { useAreThereTokensWithSameSymbol } from '@cowprotocol/tokens'
import { Command } from '@cowprotocol/types'
Expand All @@ -34,6 +34,7 @@ import { CardsSpinner, ExtLink } from 'pages/Account/styled'
import BalanceCell from './BalanceCell'
import FavoriteTokenButton from './FavoriteTokenButton'
import { FiatBalanceCell } from './FiatBalanceCell'
import { SplDelegationCell } from './SplDelegationCell'
import {
ApproveLabel,
BalanceValue,
Expand Down Expand Up @@ -67,6 +68,7 @@ export const TokensTableRow = ({
toggleWalletModal,
}: DataRowParams): ReactNode => {
const { account, chainId } = useWalletInfo()
const isSolana = isSolanaChain(chainId)
const areThereTokensWithSameSymbol = useAreThereTokensWithSameSymbol()

const theme = useTheme()
Expand Down Expand Up @@ -199,6 +201,14 @@ export const TokensTableRow = ({
return <CardsSpinner />
}, [account, isNativeToken, allowance, handleApprove, approvalState, balanceLessThanAllowance])

const explorerLink = (
<ExtLink href={getBlockExplorerUrl(chainId, 'token', tokenData.address)}>
<TableButton>
<SVG src={iconEtherscanSrc} title={t`View token contract`} description={t`View token contract`} />
</TableButton>
</ExtLink>
)

return (
<>
<Cell>
Expand Down Expand Up @@ -229,16 +239,22 @@ export const TokensTableRow = ({
<Cell>{fiatValue}</Cell>

<Cell>
{displayApproveContent && (
<>
<ExtLink href={getBlockExplorerUrl(chainId, 'token', tokenData.address)}>
<TableButton>
<SVG src={iconEtherscanSrc} title={t`View token contract`} description={t`View token contract`} />
</TableButton>
</ExtLink>
{displayApproveContent}
</>
)}
{/* This EVM/Solana split is temporary. Once a Solana approve (delegation) flow exists,
unify both branches into a single allowance cell instead of branching on chain here. */}
{isSolana
? !isNativeToken && (
<>
{explorerLink}
{/* Delegation status is only meaningful for a connected wallet; hide it otherwise. */}
{account && <SplDelegationCell balance={balance} allowance={allowance} />}
</>
)
: displayApproveContent && (
<>
{explorerLink}
{displayApproveContent}
</>
)}
</Cell>
</>
)
Expand Down
8 changes: 8 additions & 0 deletions apps/cowswap-frontend/src/legacy/components/Tokens/styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ export const ApproveLabel = styled.span`
font-weight: 500;
`

// Neutral (muted) label for the Solana "not delegated" placeholder — deliberately not the green
// `ApproveLabel`, since a dash is an empty state rather than an approval.
export const NotDelegatedLabel = styled.span`
color: inherit;
opacity: 0.5;
font-weight: 500;
`

export const CustomLimit = styled.div`
> span:last-child {
cursor: default;
Expand Down
2 changes: 2 additions & 0 deletions apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,8 @@ msgid "is on {chainName} network"
msgstr "is on {chainName} network"

#: apps/cowswap-frontend/src/common/pure/PermitModal/index.tsx
#: apps/cowswap-frontend/src/legacy/components/Tokens/SplDelegationCell.tsx
#: apps/cowswap-frontend/src/legacy/components/Tokens/SplDelegationCell.tsx
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTableRow.tsx
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTableRow.tsx
msgid "Approved"
Expand Down
26 changes: 26 additions & 0 deletions libs/balances-and-allowances/src/const/solanaSettlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { isBarnBackendEnv } from '@cowprotocol/common-utils'

import { PublicKey } from '@solana/web3.js'

// CoW Protocol settlement program on Solana — https://github.com/cowprotocol/solana-programs
// Prod vs staging is picked by environment, mirroring how EVM contract addresses are handled
// (see `COW_PROTOCOL_VAULT_RELAYER_ADDRESS`). Also mirrors the order-flow constant in
// cowswap-frontend's solanaOrderFlow; consolidate to one source once both land.
const SOLANA_SETTLEMENT_PROGRAM_ID_PROD = new PublicKey('moosEjJg5mbGRPRU7Vg4AaHZLvbbgknevWR9J1bNgME')
Comment thread
limitofzero marked this conversation as resolved.
Outdated
// TODO: swap in the dedicated staging program id once deployed — same as prod until then.
const SOLANA_SETTLEMENT_PROGRAM_ID_STAGING = SOLANA_SETTLEMENT_PROGRAM_ID_PROD

export const SOLANA_SETTLEMENT_PROGRAM_ID = isBarnBackendEnv
? SOLANA_SETTLEMENT_PROGRAM_ID_STAGING
: SOLANA_SETTLEMENT_PROGRAM_ID_PROD

const SETTLEMENT_SEED = new TextEncoder().encode('settlement')

/**
* Settlement state PDA — the SPL delegate a sell-token account is approved to. A token's delegation
* counts as a CoW approval only when its on-account `delegate` equals this PDA (the program pulls the
* sell funds through it at execution time). This is the Solana analogue of the EVM vault relayer spender.
*/
export function findSolanaSettlementStatePda(): PublicKey {
return PublicKey.findProgramAddressSync([SETTLEMENT_SEED], SOLANA_SETTLEMENT_PROGRAM_ID)[0]
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ jest.mock('wagmi', () => ({

// 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(),
jest.mock('./usePersistSplViaMulticall', () => ({
usePersistSplViaMulticall: jest.fn(),
}))

const mockBalancesUpdate: PersistentStateByChain<Record<string, number | undefined>> = mapSupportedNetworks({})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getIsNativeToken } from '@cowprotocol/common-utils'
import { isEvmChain, SupportedChainId } from '@cowprotocol/cow-sdk'

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

import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom'
import { REPORT_THROTTLE_MS, reportBalancesError } from '../utils/reportBalancesError'
Expand Down Expand Up @@ -52,8 +52,8 @@ 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)
// Non-EVM chains (e.g. Solana) load balances and delegations via their own web calls
usePersistSplViaMulticall(params)

const {
data: balances,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { PublicKey } from '@solana/web3.js'
import { renderHook, waitFor } from '@testing-library/react'

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

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

Expand Down Expand Up @@ -58,6 +58,12 @@ jest.mock('@solana/spl-token', () => ({
unpackAccount: (ata: { toBase58(): string }) => ({ amount: mockAmountByAta[ata.toBase58()] }),
}))

// The settlement PDA derivation relies on ed25519 curve math that this env can't run; the delegate
// matching itself is covered in fetchSolanaTokenAccounts.test — here we only assert balances.
jest.mock('../const/solanaSettlement', () => ({
findSolanaSettlementStatePda: () => ({ equals: () => false }),
}))

interface MockConnection {
rpcEndpoint: string
getMultipleAccountsInfo: jest.Mock
Expand Down Expand Up @@ -92,7 +98,7 @@ function makeParams(overrides: Partial<PersistBalancesAndAllowancesParams> = {})
function renderWithBalances(params: PersistBalancesAndAllowancesParams): { result: { current: BalancesState } } {
return renderHook(
() => {
usePersistSolanaBalancesViaWebCalls(params)
usePersistSplViaMulticall(params)
return useAtomValue(balancesAtom)
},
{ wrapper },
Expand Down Expand Up @@ -129,7 +135,7 @@ function wrapper({ children }: { children: ReactNode }): ReactNode {
)
}

describe('usePersistSolanaBalancesViaWebCalls', () => {
describe('usePersistSplViaMulticall', () => {
beforeEach(() => {
mockTokensByAddress = {}
mockAmountByAta = { [ataKey(MINT_A)]: 100n, [ataKey(MINT_B)]: 250n }
Expand Down Expand Up @@ -203,7 +209,7 @@ describe('usePersistSolanaBalancesViaWebCalls', () => {
it('keys the update timestamp by the case-sensitive Solana account, not a lowercased alias', async () => {
const { result } = renderHook(
() => {
usePersistSolanaBalancesViaWebCalls(makeParams())
usePersistSplViaMulticall(makeParams())
return useAtomValue(balancesUpdateAtom)
},
{ wrapper },
Expand Down
Loading
Loading