Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 6 additions & 3 deletions apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,6 @@ msgid "Sell amount"
msgstr "Sell amount"

#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderEstimatedExecutionPrice/OrderEstimatedExecutionPrice.pure.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTable/Row/WarningEstimatedPrice/OrderRowWarningEstimatedPrice.pure.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts
#: apps/cowswap-frontend/src/modules/ordersTable/state/params/ordersTableParams.atom.ts
msgid "Unfillable"
Expand Down Expand Up @@ -4400,7 +4399,6 @@ msgid "Limit Order"
msgstr "Limit Order"

#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderEstimatedExecutionPrice/OrderEstimatedExecutionPrice.pure.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTable/Row/WarningEstimatedPrice/OrderRowWarningEstimatedPrice.pure.tsx
msgid "Insufficient allowance"
msgstr "Insufficient allowance"

Expand Down Expand Up @@ -4460,6 +4458,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 @@ -6643,6 +6642,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 @@ -7184,6 +7187,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 Expand Up @@ -8367,7 +8371,6 @@ msgid "View on Bridge Explorer ↗"
msgstr "View on Bridge Explorer ↗"

#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderEstimatedExecutionPrice/OrderEstimatedExecutionPrice.pure.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTable/Row/WarningEstimatedPrice/OrderRowWarningEstimatedPrice.pure.tsx
msgid "Insufficient balance"
msgstr "Insufficient balance"

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,51 @@
import { ReactNode, useState } from 'react'

import { usePreventDoubleExecution } from '@cowprotocol/common-hooks'
import { normalizeError } from '@cowprotocol/common-utils'
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: unknown) {
const error = normalizeError(err)

setError(error.message)
}
})

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,41 @@
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>

// 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,56 @@
/**
* PublicKey.isOnCurve misreports every point as on-curve under jsdom, exhausting findProgramAddressSync's 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'

// Avoids `@cowprotocol/tokens` (pulled in via balances-and-allowances), which reads `window.location` at import time.
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,41 @@
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
currentDelegation: bigint
}

// Skips the step when the existing delegation already covers `amount`; reused as-is by both the native-SOL flow and the future SPL delegate+order flow.
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,34 @@
/**
* PublicKey.isOnCurve misreports every point as on-curve under jsdom, exhausting findProgramAddressSync's bumps.
* @jest-environment node
*/
import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token'
import { 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)

describe('planWrapStep', () => {
it('returns null for a non-positive amount', () => {
expect(planWrapStep({ owner: OWNER, sellAmount: 0n })).toBeNull()
})

it('transfers exactly the sell amount, regardless of whether the WSOL account already exists', () => {
const step = planWrapStep({ owner: OWNER, sellAmount: 10_000n })

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

it('summarizes the SOL amount wrapped', () => {
const step = planWrapStep({ owner: OWNER, sellAmount: 10_000n })

expect(step!.summary).toBe('Wrap 0.00001 SOL')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NATIVE_CURRENCIES } from '@cowprotocol/common-const'
import { formatTokenAmount } from '@cowprotocol/common-utils'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { CurrencyAmount } from '@cowprotocol/currency'

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

import { SolanaFlowStep } from './types'

import { buildWrapSolInstructions } from '../wrapNativeSolana/buildWrapSolInstructions'

export interface PlanWrapStepParams {
owner: PublicKey
// Native SOL lamports that must land as WSOL — the trade's exact sell amount.
sellAmount: bigint
}

// `SyncNative` sets the token amount to the account's lamports minus the *current* rent-exempt
// minimum, so the rent an idempotent create instruction funds is always netted back out —
// regardless of whether the WSOL account already existed. `sellAmount` alone is therefore always
// the resulting WSOL amount; no adjustment for account creation is needed (contrast with the
// standalone wrap flow's `getSolanaWrapPreview`, which caps *total spend* at the typed amount instead).
export function planWrapStep({ owner, sellAmount }: PlanWrapStepParams): SolanaFlowStep | null {
if (sellAmount <= 0n) return null

const sellCurrencyAmount = CurrencyAmount.fromRawAmount(NATIVE_CURRENCIES[SupportedChainId.SOLANA], sellAmount)
const sellAmountStr = formatTokenAmount(sellCurrencyAmount)

return {
instructions: buildWrapSolInstructions({ owner, transferLamports: sellAmount }),
summary: t`Wrap ${sellAmountStr} SOL`,
}
}
Loading
Loading