diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 9d35edccb8e..66c30980150 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -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" @@ -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" @@ -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" @@ -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" @@ -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}" @@ -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" diff --git a/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx b/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx index dd0d61e72e9..80d0469e178 100644 --- a/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx +++ b/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx @@ -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' @@ -123,6 +124,9 @@ export const swapTradeButtonsMap: Record = { /> ), + [SwapFormState.SolanaWrapAndDelegate]: (props: SwapTradeButtonsContext, isDisabled: boolean) => ( + + ), [SwapFormState.SellNativeInHooks]: (props: SwapTradeButtonsContext) => { const currency = props.inputCurrency const symbol = currency?.symbol diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFormState.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFormState.ts index 9b5932c0d20..15c48d79c08 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFormState.ts +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFormState.ts @@ -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' @@ -13,6 +14,7 @@ export enum SwapFormState { WrapAndSwap = 'WrapAndSwap', WrapAndSwapAndBridge = 'WrapAndSwapAndBridge', SellNativeInHooks = 'SellNativeInHooks', + SolanaWrapAndDelegate = 'SolanaWrapAndDelegate', } export function useSwapFormState(): SwapFormState | null { @@ -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 diff --git a/apps/cowswap-frontend/src/modules/trade/containers/SolanaWrapAndDelegateButton/index.tsx b/apps/cowswap-frontend/src/modules/trade/containers/SolanaWrapAndDelegateButton/index.tsx new file mode 100644 index 00000000000..d0919135c69 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/containers/SolanaWrapAndDelegateButton/index.tsx @@ -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(null) + + const { callback: onClick, isExecuting } = usePreventDoubleExecution(async () => { + setError(null) + + try { + await wrapAndDelegate?.() + } catch (err: unknown) { + const error = normalizeError(err) + + setError(error.message) + } + }) + + return ( + +
+ + Wrap and Swap + + {error && {error}
} />} + +
+ ) +} diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/useSolanaWrapAndDelegateCallback.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useSolanaWrapAndDelegateCallback.ts new file mode 100644 index 00000000000..7264578a8d8 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/hooks/useSolanaWrapAndDelegateCallback.ts @@ -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): 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]) +} diff --git a/apps/cowswap-frontend/src/modules/trade/index.ts b/apps/cowswap-frontend/src/modules/trade/index.ts index 4db12e6e7bf..21d2f6a87d7 100644 --- a/apps/cowswap-frontend/src/modules/trade/index.ts +++ b/apps/cowswap-frontend/src/modules/trade/index.ts @@ -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' @@ -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' diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.test.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.test.ts new file mode 100644 index 00000000000..77e30cf68d2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.test.ts @@ -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') + }) +}) diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.ts new file mode 100644 index 00000000000..0de39e22004 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planDelegateStep.ts @@ -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}`, + } +} diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.test.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.test.ts new file mode 100644 index 00000000000..dd1fda2ccbd --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.test.ts @@ -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') + }) +}) diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.ts new file mode 100644 index 00000000000..75566e11123 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/planWrapStep.ts @@ -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`, + } +} diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/sendSolanaFlow.test.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/sendSolanaFlow.test.ts new file mode 100644 index 00000000000..f668a07f6c5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/sendSolanaFlow.test.ts @@ -0,0 +1,91 @@ +/** + * PublicKey.isOnCurve misreports every point as on-curve under jsdom, exhausting findProgramAddressSync's bumps. + * @jest-environment node + */ +import { Connection, PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js' + +import { sendSolanaFlow, SolanaFlowContext } from './sendSolanaFlow' +import { SolanaFlowStep } from './types' + +import type { Provider as SolanaProvider } from '@reown/appkit-adapter-solana/react' + +const OWNER = new PublicKey('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM') +const SIGNATURE = '5x8VXqZ8pQ2mJ7Yb1kL3nR4tW6uH9dF2sG5cA7eB1vN3mK4pQ8rT2yU6iO9aS1dF' +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' +const LAST_VALID_BLOCK_HEIGHT = 1_234 + +interface Harness { + context: SolanaFlowContext + sentTransactions: Transaction[] + addTransaction: jest.Mock +} + +function createHarness(): Harness { + const sentTransactions: Transaction[] = [] + + const connection = { + getLatestBlockhash: jest + .fn() + .mockResolvedValue({ blockhash: BLOCKHASH, lastValidBlockHeight: LAST_VALID_BLOCK_HEIGHT }), + } as unknown as Connection + + const provider = { + sendTransaction: jest.fn(async (transaction: Transaction) => { + sentTransactions.push(transaction) + + return SIGNATURE + }), + } as unknown as SolanaProvider + + const addTransaction = jest.fn() + + return { context: { connection, provider, owner: OWNER, addTransaction }, sentTransactions, addTransaction } +} + +function dummyInstruction(): TransactionInstruction { + return new TransactionInstruction({ keys: [], programId: OWNER, data: Buffer.from([]) }) +} + +function step(instructions: TransactionInstruction[], summary: string): SolanaFlowStep { + return { instructions, summary } +} + +describe('sendSolanaFlow', () => { + it('throws when given no steps', async () => { + const { context } = createHarness() + + await expect(sendSolanaFlow(context, [])).rejects.toThrow('sendSolanaFlow: no steps to send') + }) + + it("flattens every step's instructions into a single transaction, in order", async () => { + const { context, sentTransactions } = createHarness() + const [ixA, ixB, ixC] = [dummyInstruction(), dummyInstruction(), dummyInstruction()] + + await sendSolanaFlow(context, [step([ixA, ixB], 'Wrap 1 SOL'), step([ixC], 'Approve WSOL')]) + + expect(sentTransactions[0].instructions).toEqual([ixA, ixB, ixC]) + }) + + it('records one transaction joining every step summary', async () => { + const { context, addTransaction } = createHarness() + + await sendSolanaFlow(context, [ + step([dummyInstruction()], 'Wrap 1 SOL'), + step([dummyInstruction()], 'Approve WSOL'), + ]) + + expect(addTransaction).toHaveBeenCalledWith({ + hash: SIGNATURE, + summary: 'Wrap 1 SOL, Approve WSOL', + data: { lastValidBlockHeight: LAST_VALID_BLOCK_HEIGHT }, + }) + }) + + it('returns the transaction hash', async () => { + const { context } = createHarness() + + const result = await sendSolanaFlow(context, [step([dummyInstruction()], 'Wrap 1 SOL')]) + + expect(result).toEqual({ hash: SIGNATURE }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/sendSolanaFlow.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/sendSolanaFlow.ts new file mode 100644 index 00000000000..51ec57fdc98 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/sendSolanaFlow.ts @@ -0,0 +1,33 @@ +import { Connection, PublicKey } from '@solana/web3.js' + +import { TransactionAdder } from 'legacy/state/enhancedTransactions/hooks' + +import { SolanaFlowStep } from './types' + +import { sendSolanaTransaction } from '../solanaSend/sendSolanaTransaction' + +import type { Provider as SolanaProvider } from '@reown/appkit-adapter-solana/react' + +export interface SolanaFlowContext { + connection: Connection + provider: SolanaProvider + owner: PublicKey + addTransaction: TransactionAdder +} + +// Sends whatever steps a flow assembled as one transaction; deliberately has no idea what "wrap" or "delegate" means, so it serves any combination of steps. +export async function sendSolanaFlow(context: SolanaFlowContext, steps: SolanaFlowStep[]): Promise<{ hash: string }> { + if (steps.length === 0) { + throw new Error('sendSolanaFlow: no steps to send') + } + + const { connection, provider, owner, addTransaction } = context + const instructions = steps.flatMap((step) => step.instructions) + const summary = steps.map((step) => step.summary).join(', ') + + const { hash, lastValidBlockHeight } = await sendSolanaTransaction(connection, provider, owner, instructions) + + addTransaction({ hash, summary, data: { lastValidBlockHeight } }) + + return { hash } +} diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/solanaNativeSwapCallback.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/solanaNativeSwapCallback.ts new file mode 100644 index 00000000000..20119b8eddc --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/solanaNativeSwapCallback.ts @@ -0,0 +1,48 @@ +import { WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/common-const' +import { normalizeError } from '@cowprotocol/common-utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { Connection, PublicKey } from '@solana/web3.js' + +import { TransactionAdder } from 'legacy/state/enhancedTransactions/hooks' + +import { planDelegateStep } from './planDelegateStep' +import { planWrapStep } from './planWrapStep' +import { sendSolanaFlow } from './sendSolanaFlow' +import { SolanaFlowStep } from './types' + +import { handleSolanaSendError } from '../solanaSend/handleSolanaSendError' + +import type { Provider as SolanaProvider } from '@reown/appkit-adapter-solana/react' + +export interface SolanaNativeSwapContext { + account: string + connection: Connection + provider: SolanaProvider + addTransaction: TransactionAdder + // Native SOL lamports being sold — the trade's exact sell amount. + sellAmount: bigint + // Currently delegated WSOL amount, e.g. from `useSolanaDelegationAllowance`. + currentDelegation: bigint +} + +const WSOL = WRAPPED_NATIVE_CURRENCIES[SupportedChainId.SOLANA] + +// Wraps and delegates the exact sell amount in one signed transaction. Order creation isn't part of this yet — this only covers the "enable trading" prerequisite. +export async function solanaNativeSwapCallback(context: SolanaNativeSwapContext): Promise<{ hash: string } | null> { + const { account, connection, provider, addTransaction, sellAmount, currentDelegation } = context + + try { + const owner = new PublicKey(account) + + const wrapStep = planWrapStep({ owner, sellAmount }) + const delegateStep = planDelegateStep({ owner, token: WSOL, amount: sellAmount, currentDelegation }) + const steps = [wrapStep, delegateStep].filter((step): step is SolanaFlowStep => step !== null) + + return await sendSolanaFlow({ connection, provider, owner, addTransaction }, steps) + } catch (err: unknown) { + const error = normalizeError(err) + + return handleSolanaSendError(error, { useModals: false }) + } +} diff --git a/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/types.ts b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/types.ts new file mode 100644 index 00000000000..3f8bea2c027 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/services/solanaFlow/types.ts @@ -0,0 +1,8 @@ +import type { TransactionInstruction } from '@solana/web3.js' + +// One action (wrap, delegate, ...) contributing instructions to a bundled tx; `sendSolanaFlow` only flattens and sends these, agnostic to what each step means. +export interface SolanaFlowStep { + instructions: TransactionInstruction[] + // Joined with the other steps' summaries into one transaction-history entry. + summary: string +}