Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
6 changes: 6 additions & 0 deletions apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -4455,6 +4455,7 @@ msgstr "When you swap (sell) <0/>, solvers handle the transaction by purchasing

#: apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx
#: apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx
#: apps/cowswap-frontend/src/modules/trade/containers/SolanaWrapAndDelegateButton/index.tsx
msgid "Wrap <0/> and Swap"
msgstr "Wrap <0/> and Swap"

Expand Down Expand Up @@ -6638,6 +6639,10 @@ msgstr "Tip: Custom tokens are stored locally in your browser"
msgid "You receive at least"
msgstr "You receive at least"

#: apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.ts
msgid "Wrap {sellAmountStr} SOL"
msgstr "Wrap {sellAmountStr} SOL"

#: apps/cowswap-frontend/src/common/pure/ChainPrefixWarning/index.tsx
#~ msgid "You are connected to"
#~ msgstr "You are connected to"
Expand Down Expand Up @@ -7179,6 +7184,7 @@ msgstr "Add custom hook"

#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTableRow.tsx
#: apps/cowswap-frontend/src/modules/trade/services/solanaApprove/solanaApproveCallback.ts
#: apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.ts
#: apps/cowswap-frontend/src/modules/twap/utils/buildEoaTwapConfirmationPendingSteps.tsx
msgid "Approve {symbol}"
msgstr "Approve {symbol}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import styled from 'styled-components/macro'
import { Field } from 'legacy/state/types'

import { EthFlowBanner } from 'modules/ethFlow'
import { SolanaWrapAndDelegateButton } from 'modules/trade'

import { SwapFormState } from '../../hooks/useSwapFormState'

Expand Down Expand Up @@ -123,6 +124,9 @@ export const swapTradeButtonsMap: Record<SwapFormState, SwapTradeButton> = {
/>
</Wrapper>
),
[SwapFormState.SolanaWrapAndDelegate]: (props: SwapTradeButtonsContext, isDisabled: boolean) => (
<SolanaWrapAndDelegateButton isDisabled={isDisabled} clickEvent={props.swapBridgeClickEvent} />
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[SwapFormState.SellNativeInHooks]: (props: SwapTradeButtonsContext) => {
const currency = props.inputCurrency
const symbol = currency?.symbol
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from 'react'

import { getIsNativeToken } from '@cowprotocol/common-utils'
import { isSolanaChain } from '@cowprotocol/cow-sdk'
import { useIsSmartContractWallet, useIsTxBundlingSupported } from '@cowprotocol/wallet'

import { useIsHooksTradeType } from 'modules/trade'
Expand All @@ -13,6 +14,7 @@ export enum SwapFormState {
WrapAndSwap = 'WrapAndSwap',
WrapAndSwapAndBridge = 'WrapAndSwapAndBridge',
SellNativeInHooks = 'SellNativeInHooks',
SolanaWrapAndDelegate = 'SolanaWrapAndDelegate',
}

export function useSwapFormState(): SwapFormState | null {
Expand All @@ -24,6 +26,7 @@ export function useSwapFormState(): SwapFormState | null {
return useMemo(() => {
if (state.inputCurrency && getIsNativeToken(state.inputCurrency)) {
if (isHooksStore) return SwapFormState.SellNativeInHooks
if (isSolanaChain(state.inputCurrency.chainId)) return SwapFormState.SolanaWrapAndDelegate

const isBridging =
state.inputCurrency && state.outputCurrency && state.inputCurrency.chainId !== state.outputCurrency.chainId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ReactNode, useState } from 'react'

import { usePreventDoubleExecution } from '@cowprotocol/common-hooks'
import { ButtonError, ButtonSize, HelpTooltip, TokenSymbol } from '@cowprotocol/ui'

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

import { useDerivedTradeState } from '../../hooks/useDerivedTradeState'
import { useSolanaWrapAndDelegateCallback } from '../../hooks/useSolanaWrapAndDelegateCallback'

export interface SolanaWrapAndDelegateButtonProps {
isDisabled?: boolean
clickEvent?: string
}

export function SolanaWrapAndDelegateButton({ isDisabled, clickEvent }: SolanaWrapAndDelegateButtonProps): ReactNode {
const state = useDerivedTradeState()
const sellAmount = state?.inputCurrencyAmount ? BigInt(state.inputCurrencyAmount.quotient.toString()) : undefined
const wrapAndDelegate = useSolanaWrapAndDelegateCallback(sellAmount)
const [error, setError] = useState<string | null>(null)

const { callback: onClick, isExecuting } = usePreventDoubleExecution(async () => {
setError(null)

try {
await wrapAndDelegate?.()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
})

return (
<ButtonError
id="do-trade-button"
buttonSize={ButtonSize.BIG}
onClick={onClick}
disabled={isDisabled || !wrapAndDelegate || isExecuting}
data-click-event={clickEvent}
>
<div>
<Trans>
Wrap <TokenSymbol token={state?.inputCurrency} length={6} /> and Swap
</Trans>
{error && <HelpTooltip placement="top" text={<div>{error}</div>} />}
</div>
</ButtonError>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from 'react'

import { WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/common-const'
import { isSolanaChain, SupportedChainId } from '@cowprotocol/cow-sdk'
import { useSolanaWalletProvider, useWalletInfo } from '@cowprotocol/wallet'

import { useAppKitConnection } from '@reown/appkit-adapter-solana/react'
import { Nullish } from 'types'

import { useTransactionAdder } from 'legacy/state/enhancedTransactions/hooks'

import { useSolanaDelegationAllowance } from 'common/hooks/useSolanaDelegationAllowance'

import { solanaNativeSwapCallback } from '../services/solanaFlow/solanaNativeSwapCallback'

export type SolanaWrapAndDelegateCallback = () => Promise<{ hash: string } | null>

/**
* Enables trading native SOL for an SPL token, in one signed transaction. Returns `null` on every
* non-Solana chain, mirroring `useSolanaWrapNativeCallback`/`useSolanaApproveCallback`.
*/
export function useSolanaWrapAndDelegateCallback(sellAmount: Nullish<bigint>): SolanaWrapAndDelegateCallback | null {
const { chainId, account } = useWalletInfo()
const provider = useSolanaWalletProvider()
const { connection } = useAppKitConnection()
const addTransaction = useTransactionAdder()
const currentDelegation = useSolanaDelegationAllowance(WRAPPED_NATIVE_CURRENCIES[SupportedChainId.SOLANA].address)

return useMemo(() => {
if (!isSolanaChain(chainId) || !account || !provider || !connection || !sellAmount) {
return null
}

return () =>
solanaNativeSwapCallback({
account,
connection,
provider,
addTransaction,
sellAmount,
currentDelegation: currentDelegation ?? 0n,
})
}, [chainId, account, provider, connection, sellAmount, addTransaction, currentDelegation])
}
2 changes: 2 additions & 0 deletions apps/cowswap-frontend/src/modules/trade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './utils/logger'
export { addPendingOrderStep } from './utils/addPendingOrderStep'
export * from './const/tradeUrl'
export * from './containers/TradeRouteRedirect'
export * from './containers/SolanaWrapAndDelegateButton'
export * from './containers/TradeWidget'
export * from './containers/TradeConfirmModal'
export * from './containers/TradeWidgetLinks'
Expand All @@ -35,6 +36,7 @@ export * from './hooks/useTradePriceImpact'
export * from './hooks/setupTradeState/useSetupTradeState'
export * from './hooks/useWrapNativeFlow'
export * from './hooks/useSolanaApproveCallback'
export * from './hooks/useSolanaWrapAndDelegateCallback'
export * from './hooks/useDerivedTradeState'
export * from './hooks/useSetupTradeAmountsFromUrl'
export * from './hooks/useTradeNavigate'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Program-address derivation needs a working ed25519 curve check, and `PublicKey.isOnCurve` misreports
* every point as on-curve under jsdom — which makes `findProgramAddressSync` exhaust all 255 bumps.
* @jest-environment node
*/
import { TokenWithLogo } from '@cowprotocol/common-const'

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

import { planDelegateStep } from './planDelegateStep'

import { buildApproveInstruction } from '../solanaApprove/buildApproveInstruction'

// `@cowprotocol/balances-and-allowances` pulls in `@cowprotocol/tokens`, which reads `window.location`
// at import time — avoid that entirely rather than fight the node/jsdom test-environment mismatch.
jest.mock('@cowprotocol/balances-and-allowances', () => ({
findSolanaSettlementStatePda: jest.fn(() => 'SETTLEMENT_PDA'),
}))

jest.mock('../solanaApprove/buildApproveInstruction', () => ({
buildApproveInstruction: jest.fn(() => 'APPROVE_IX'),
}))

const OWNER = new PublicKey('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM')
const TOKEN = {
address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
symbol: 'WSOL',
tags: [],
} as unknown as TokenWithLogo

const mockBuildApprove = buildApproveInstruction as jest.Mock

beforeEach(() => jest.clearAllMocks())

describe('planDelegateStep', () => {
it('returns null for a non-positive amount', () => {
expect(planDelegateStep({ owner: OWNER, token: TOKEN, amount: 0n, currentDelegation: 0n })).toBeNull()
expect(mockBuildApprove).not.toHaveBeenCalled()
})

it('returns null when the existing delegation already covers the amount', () => {
expect(planDelegateStep({ owner: OWNER, token: TOKEN, amount: 500n, currentDelegation: 500n })).toBeNull()
expect(mockBuildApprove).not.toHaveBeenCalled()
})

it('builds an approve instruction when the existing delegation falls short', () => {
const step = planDelegateStep({ owner: OWNER, token: TOKEN, amount: 500n, currentDelegation: 100n })

expect(step?.instructions).toEqual(['APPROVE_IX'])
expect(mockBuildApprove).toHaveBeenCalledWith(expect.objectContaining({ owner: OWNER, amount: 500n }))
})

it('summarizes with the token symbol', () => {
const step = planDelegateStep({ owner: OWNER, token: TOKEN, amount: 500n, currentDelegation: 0n })

expect(step?.summary).toBe('Approve WSOL')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { findSolanaSettlementStatePda } from '@cowprotocol/balances-and-allowances'
import { getIsToken2022, TokenWithLogo } from '@cowprotocol/common-const'

import { t } from '@lingui/core/macro'
import { PublicKey } from '@solana/web3.js'

import { SolanaFlowStep } from './types'

import { buildApproveInstruction } from '../solanaApprove/buildApproveInstruction'

export interface PlanDelegateStepParams {
owner: PublicKey
token: TokenWithLogo
amount: bigint
/** Currently delegated amount on this token's ATA, e.g. from `useSolanaDelegationAllowance`. */
currentDelegation: bigint
}

/**
* Plans the delegate step for a bundled flow. Skips the step when the existing delegation already
* covers `amount` — reused as-is by both the native-SOL swap flow (delegating WSOL) and the future
* SPL delegate+create-order flow (delegating the sell token directly), so this never needs to know
* which flow it's called from.
*/
export function planDelegateStep({
owner,
token,
amount,
currentDelegation,
}: PlanDelegateStepParams): SolanaFlowStep | null {
if (amount <= 0n || currentDelegation >= amount) return null

const instruction = buildApproveInstruction({
owner,
mint: new PublicKey(token.address),
isToken2022: getIsToken2022(token),
delegate: findSolanaSettlementStatePda(),
amount,
})

const symbol = token.symbol ?? ''

return {
instructions: [instruction],
summary: t`Approve ${symbol}`,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Program-address derivation needs a working ed25519 curve check, and `PublicKey.isOnCurve` misreports
* every point as on-curve under jsdom — which makes `findProgramAddressSync` exhaust all 255 bumps.
* @jest-environment node
*/
import { decodeSyncNativeInstruction, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token'
import { Connection, PublicKey, SystemInstruction } from '@solana/web3.js'

import { planWrapStep } from './planWrapStep'

import { WSOL_MINT } from '../wrapNativeSolana/const'

const OWNER = new PublicKey('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM')
const ata = getAssociatedTokenAddressSync(WSOL_MINT, OWNER, false, TOKEN_PROGRAM_ID)

function createConnection({
accountExists,
rentExemptLamports = 9_000,
}: {
accountExists: boolean
rentExemptLamports?: number
}): Connection {
return {
getAccountInfo: jest.fn().mockResolvedValue(accountExists ? {} : null),
getMinimumBalanceForRentExemption: jest.fn().mockResolvedValue(rentExemptLamports),
} as unknown as Connection
}

describe('planWrapStep', () => {
it('returns null for a non-positive amount', async () => {
const connection = createConnection({ accountExists: true })

expect(await planWrapStep({ connection, owner: OWNER, sellAmount: 0n })).toBeNull()
})

it('transfers exactly the sell amount when the WSOL account already exists', async () => {
const connection = createConnection({ accountExists: true })

const step = await planWrapStep({ connection, owner: OWNER, sellAmount: 10_000n })

const [, transfer] = step!.instructions
expect(SystemInstruction.decodeTransfer(transfer).lamports).toBe(10_000n)
})

it('grows the transfer by the rent-exempt deposit when the WSOL account does not exist yet, so exactly the sell amount lands as WSOL', async () => {
const connection = createConnection({ accountExists: false, rentExemptLamports: 9_000 })

const step = await planWrapStep({ connection, owner: OWNER, sellAmount: 10_000n })

const [, transfer, syncNative] = step!.instructions
expect(SystemInstruction.decodeTransfer(transfer).lamports).toBe(19_000n)
expect(decodeSyncNativeInstruction(syncNative, TOKEN_PROGRAM_ID).keys.account.pubkey.equals(ata)).toBe(true)
})

it('summarizes the SOL amount wrapped', async () => {
const connection = createConnection({ accountExists: true })

const step = await planWrapStep({ connection, owner: OWNER, sellAmount: 10_000n })

expect(step!.summary).toBe('Wrap 0.00001 SOL')
})
})
Loading
Loading