diff --git a/packages/public-api/src/routes/quote/extractTransactionData.ts b/packages/public-api/src/routes/quote/extractTransactionData.ts index b1871f49974..60ac02858f0 100644 --- a/packages/public-api/src/routes/quote/extractTransactionData.ts +++ b/packages/public-api/src/routes/quote/extractTransactionData.ts @@ -8,7 +8,6 @@ import type { TransactionData, UtxoTransactionData, } from '../../types' -import type { DepositExtractionContext } from './types' import { getEvmChainIdNumber } from './utils' const extractEvmTransactionData = (step: TradeQuoteStep): EvmTransactionData | undefined => { @@ -90,6 +89,16 @@ const extractEvmTransactionData = (step: TradeQuoteStep): EvmTransactionData | u } } + if (step.thorchainTransactionMetadata?.data) { + return { + type: 'evm' as const, + chainId, + to: step.thorchainTransactionMetadata.to, + data: step.thorchainTransactionMetadata.data, + value: step.thorchainTransactionMetadata.value ?? '0', + } + } + return undefined })() @@ -124,10 +133,7 @@ const extractSolanaTransactionData = (step: TradeQuoteStep): SolanaTransactionDa } } -const extractUtxoTransactionData = ( - step: TradeQuoteStep, - context: DepositExtractionContext = {}, -): UtxoTransactionData | undefined => { +const extractUtxoTransactionData = (step: TradeQuoteStep): UtxoTransactionData | undefined => { if (step.relayTransactionMetadata?.to) { return { type: 'utxo_deposit', @@ -146,39 +152,37 @@ const extractUtxoTransactionData = ( } } - if (context.depositAddress && context.memo !== undefined) { + if (step.thorchainTransactionMetadata?.to) { return { type: 'utxo_deposit', - depositAddress: context.depositAddress, - memo: context.memo, - value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + depositAddress: step.thorchainTransactionMetadata.to, + memo: step.thorchainTransactionMetadata.memo ?? '', + value: + step.thorchainTransactionMetadata.value ?? + step.sellAmountIncludingProtocolFeesCryptoBaseUnit, } } return undefined } -const extractCosmosTransactionData = ( - step: TradeQuoteStep, - context: DepositExtractionContext = {}, -): CosmosTransactionData | undefined => { - if (context.depositAddress && context.memo !== undefined) { +const extractCosmosTransactionData = (step: TradeQuoteStep): CosmosTransactionData | undefined => { + if (step.thorchainTransactionMetadata?.to) { return { type: 'cosmos', chainId: step.sellAsset.chainId, - to: context.depositAddress, - value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, - memo: context.memo, + to: step.thorchainTransactionMetadata.to, + value: + step.thorchainTransactionMetadata.value ?? + step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + memo: step.thorchainTransactionMetadata.memo ?? '', } } return undefined } -export const extractTransactionData = ( - step: TradeQuoteStep, - context: DepositExtractionContext = {}, -): TransactionData | undefined => { +export const extractTransactionData = (step: TradeQuoteStep): TransactionData | undefined => { const { chainNamespace } = fromChainId(step.sellAsset.chainId) if (chainNamespace === 'eip155') { @@ -190,11 +194,11 @@ export const extractTransactionData = ( } if (chainNamespace === 'bip122') { - return extractUtxoTransactionData(step, context) + return extractUtxoTransactionData(step) } if (chainNamespace === 'cosmos') { - return extractCosmosTransactionData(step, context) + return extractCosmosTransactionData(step) } return undefined diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 1db5a4da95a..662c8b87b3a 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -19,7 +19,7 @@ import type { ErrorResponse } from '../../types' import { PartnerCodeHeaderSchema, rateLimitResponse } from '../../types' import type { QuoteResponse } from './types' import { QuoteRequestSchema, QuoteResponseSchema } from './types' -import { buildApprovalInfo, resolveDepositContext, transformQuoteStep } from './utils' +import { buildApprovalInfo, transformQuoteStep } from './utils' registry.registerPath({ method: 'post', @@ -211,14 +211,6 @@ export const getQuote = async (req: Request, res: Response): Promise => { status: 'pending', }) - const depositContextResult = await resolveDepositContext(quote, firstStep, validSwapperName) - if (!depositContextResult.ok) { - res.status(depositContextResult.statusCode).json(depositContextResult.error) - return - } - - const { context: depositContext } = depositContextResult - const response: QuoteResponse = { quoteId, swapperName: validSwapperName, @@ -231,9 +223,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { affiliateBps: req.affiliateInfo?.affiliateBps ?? env.DEFAULT_AFFILIATE_BPS, slippageTolerancePercentageDecimal: quote.slippageTolerancePercentageDecimal, networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, - steps: quote.steps.map((step, index) => - transformQuoteStep(step, index === 0 ? depositContext : {}), - ), + steps: quote.steps.map(transformQuoteStep), approval: sendAddress ? await buildApprovalInfo(firstStep, sendAddress) : { isRequired: false, spender: '' }, diff --git a/packages/public-api/src/routes/quote/types.ts b/packages/public-api/src/routes/quote/types.ts index 640ec80811b..96b13f1b153 100644 --- a/packages/public-api/src/routes/quote/types.ts +++ b/packages/public-api/src/routes/quote/types.ts @@ -1,22 +1,9 @@ -import type { TradeQuote } from '@shapeshiftoss/swapper' import { z } from 'zod' import { booleanFromString } from '../../lib/zod' import { registry } from '../../registry' -import type { ErrorResponse } from '../../types' import { AssetSchema } from '../assets/types' -export type ThorLikeQuote = TradeQuote & { memo?: string } - -export type DepositExtractionContext = { - memo?: string - depositAddress?: string -} - -export type DepositContextResult = - | { ok: true; context: DepositExtractionContext } - | { ok: false; error: ErrorResponse; statusCode: number } - const EvmTransactionDataSchema = z.object({ type: z.literal('evm').openapi({ example: 'evm' }), chainId: z.number().openapi({ example: 1 }), diff --git a/packages/public-api/src/routes/quote/utils.ts b/packages/public-api/src/routes/quote/utils.ts index 3b4083a0865..d9facb69354 100644 --- a/packages/public-api/src/routes/quote/utils.ts +++ b/packages/public-api/src/routes/quote/utils.ts @@ -1,39 +1,11 @@ import { CHAIN_NAMESPACE, fromAssetId, fromChainId } from '@shapeshiftoss/caip' import { viemClientByChainId } from '@shapeshiftoss/contracts' -import type { SwapperName, TradeQuote, TradeQuoteStep } from '@shapeshiftoss/swapper' -import { getDaemonUrl, getInboundAddressDataForChain } from '@shapeshiftoss/swapper' +import type { TradeQuoteStep } from '@shapeshiftoss/swapper' import { isToken } from '@shapeshiftoss/utils' import { erc20Abi, getAddress } from 'viem' -import { getServerConfig } from '../../config' import { extractTransactionData } from './extractTransactionData' -import type { - ApiQuoteStep, - ApprovalInfo, - DepositContextResult, - DepositExtractionContext, - ThorLikeQuote, -} from './types' - -export const fetchInboundAddress = async ( - assetId: string, - swapperName: SwapperName, -): Promise => { - const config = getServerConfig() - const daemonUrl = getDaemonUrl(config, swapperName) - - const result = await getInboundAddressDataForChain(daemonUrl, assetId, false, swapperName) - - if (result.isOk()) { - return result.unwrap().address - } - - console.error( - `Failed to fetch inbound address for ${assetId} (${swapperName}):`, - result.unwrapErr(), - ) - return undefined -} +import type { ApiQuoteStep, ApprovalInfo } from './types' export const getEvmChainIdNumber = (chainId: string): number => { const { chainReference } = fromChainId(chainId) @@ -69,10 +41,7 @@ export const buildApprovalInfo = async ( } // Transform quote step to API format -export const transformQuoteStep = ( - step: TradeQuoteStep, - context: DepositExtractionContext = {}, -): ApiQuoteStep => ({ +export const transformQuoteStep = (step: TradeQuoteStep): ApiQuoteStep => ({ sellAsset: step.sellAsset, buyAsset: step.buyAsset, sellAmountCryptoBaseUnit: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, @@ -80,31 +49,5 @@ export const transformQuoteStep = ( allowanceContract: step.allowanceContract, estimatedExecutionTimeMs: step.estimatedExecutionTimeMs, source: step.source, - transactionData: extractTransactionData(step, context), + transactionData: extractTransactionData(step), }) - -export const resolveDepositContext = async ( - quote: TradeQuote, - firstStep: TradeQuoteStep, - swapperName: SwapperName, -): Promise => { - const thorLikeQuote = quote as ThorLikeQuote - if (!thorLikeQuote.memo) return { ok: true, context: {} } - - const { chainNamespace } = fromChainId(firstStep.sellAsset.chainId) - if (chainNamespace !== 'bip122' && chainNamespace !== 'cosmos') return { ok: true, context: {} } - - const depositAddress = await fetchInboundAddress(firstStep.sellAsset.assetId, swapperName) - if (!depositAddress) { - return { - ok: false, - statusCode: 503, - error: { - error: 'Failed to fetch deposit address for this swap', - code: 'DEPOSIT_ADDRESS_UNAVAILABLE', - }, - } - } - - return { ok: true, context: { memo: thorLikeQuote.memo, depositAddress } } -} diff --git a/packages/swap-widget/src/constants/swappers.ts b/packages/swap-widget/src/constants/swappers.ts index df48087156d..eaa7769c6d2 100644 --- a/packages/swap-widget/src/constants/swappers.ts +++ b/packages/swap-widget/src/constants/swappers.ts @@ -5,7 +5,8 @@ export const SWAPPER_ICONS: Partial> = { 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/near-intents-icon.png', [SwapperName.Relay]: 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/relay-icon.svg', - //[SwapperName.Thorchain]: 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/thorchain-icon.png', + [SwapperName.Thorchain]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/thorchain-icon.png', //[SwapperName.Mayachain]: 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/maya_logo.png', //[SwapperName.ArbitrumBridge]: 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/arbitrum-bridge-icon.png', //[SwapperName.Bebop]: 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/bebop-icon.png', @@ -19,7 +20,7 @@ export const SWAPPER_ICONS: Partial> = { export const SWAPPER_COLORS: Partial> = { [SwapperName.NearIntents]: '#000000', [SwapperName.Relay]: '#6366F1', - //[SwapperName.Thorchain]: '#00CCFF', + [SwapperName.Thorchain]: '#00CCFF', //[SwapperName.Mayachain]: '#4169E1', //[SwapperName.ArbitrumBridge]: '#28A0F0', //[SwapperName.Bebop]: '#E91E63', diff --git a/packages/swap-widget/src/hooks/useSwapApproval.ts b/packages/swap-widget/src/hooks/useSwapApproval.ts index 2836291651f..a27700eb2c5 100644 --- a/packages/swap-widget/src/hooks/useSwapApproval.ts +++ b/packages/swap-widget/src/hooks/useSwapApproval.ts @@ -7,10 +7,10 @@ import { switchOrAddChain, VIEM_CHAINS_BY_ID } from '../constants/viemChains' import { useSwapWallet } from '../contexts/SwapWalletContext' import { SwapMachineCtx } from '../machines/SwapMachineContext' import { getEvmNetworkId } from '../types' +import { getErrorMessage } from '../utils/errors' export const useSwapApproval = () => { const stateValue = SwapMachineCtx.useSelector(s => s.value) - const context = SwapMachineCtx.useSelector(s => s.context) const actorRef = SwapMachineCtx.useActorRef() const { walletClient, walletAddress } = useSwapWallet() @@ -18,8 +18,7 @@ export const useSwapApproval = () => { const approvingRef = useRef(false) useEffect(() => { - const snap = actorRef.getSnapshot() - if (!snap.matches('approving') || approvingRef.current) return + if (stateValue !== 'approving' || approvingRef.current) return approvingRef.current = true const executeApproval = async () => { @@ -29,13 +28,14 @@ export const useSwapApproval = () => { return } - const quote = context.quote + const { quote, sellAsset, sellAmountBaseUnit } = actorRef.getSnapshot().context + if (!quote?.approval?.spender) { actorRef.send({ type: 'APPROVAL_ERROR', error: 'No approval data in quote' }) return } - const sellAssetAddress = context.sellAsset.assetId.split('/')[1]?.split(':')[1] + const sellAssetAddress = sellAsset.assetId.split('/')[1]?.split(':')[1] if (!sellAssetAddress || !/^0x[a-fA-F0-9]{40}$/.test(sellAssetAddress)) { actorRef.send({ type: 'APPROVAL_ERROR', @@ -44,7 +44,7 @@ export const useSwapApproval = () => { return } - const requiredChainId = getEvmNetworkId(context.sellAsset.chainId) + const requiredChainId = getEvmNetworkId(sellAsset.chainId) const client = walletClient as WalletClient const currentChainId = await client.getChainId() @@ -52,7 +52,7 @@ export const useSwapApproval = () => { await switchOrAddChain(client, requiredChainId) } - const baseAsset = getBaseAsset(context.sellAsset.chainId) + const baseAsset = getBaseAsset(sellAsset.chainId) const nativeCurrency = baseAsset ? { name: baseAsset.name, symbol: baseAsset.symbol, decimals: baseAsset.precision } : { name: 'ETH', symbol: 'ETH', decimals: 18 } @@ -65,7 +65,7 @@ export const useSwapApproval = () => { rpcUrls: { default: { http: [] } }, } - if (!context.sellAmountBaseUnit || context.sellAmountBaseUnit === '0') { + if (!sellAmountBaseUnit || sellAmountBaseUnit === '0') { actorRef.send({ type: 'APPROVAL_ERROR', error: 'No sell amount specified' }) return } @@ -73,7 +73,7 @@ export const useSwapApproval = () => { const approvalData = encodeFunctionData({ abi: erc20Abi, functionName: 'approve', - args: [quote.approval.spender as `0x${string}`, BigInt(context.sellAmountBaseUnit)], + args: [quote.approval.spender as `0x${string}`, BigInt(sellAmountBaseUnit)], }) const approvalHash = await client.sendTransaction({ @@ -93,14 +93,16 @@ export const useSwapApproval = () => { actorRef.send({ type: 'APPROVAL_SUCCESS', txHash: approvalHash }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Approval failed' - actorRef.send({ type: 'APPROVAL_ERROR', error: errorMessage }) + actorRef.send({ + type: 'APPROVAL_ERROR', + error: getErrorMessage(error, 'Approval failed'), + }) } finally { approvingRef.current = false } } executeApproval() - // eslint-disable-next-line react-hooks/exhaustive-deps -- stateValue is the sole trigger; other deps are stable refs read from snapshot + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-fire on state machine transitions; wallet handles close over the latest render }, [stateValue]) } diff --git a/packages/swap-widget/src/hooks/useSwapExecution.ts b/packages/swap-widget/src/hooks/useSwapExecution.ts index f418aae4f83..34493ea583d 100644 --- a/packages/swap-widget/src/hooks/useSwapExecution.ts +++ b/packages/swap-widget/src/hooks/useSwapExecution.ts @@ -1,14 +1,111 @@ import { useEffect, useRef } from 'react' -import type { WalletClient } from 'viem' +import type { Hex, WalletClient } from 'viem' +import { getAddress } from 'viem' import { switchOrAddChain, VIEM_CHAINS_BY_ID } from '../constants/viemChains' import { useSwapWallet } from '../contexts/SwapWalletContext' import { SwapMachineCtx } from '../machines/SwapMachineContext' -import { getEvmNetworkId } from '../types' +import type { + EvmTransactionData, + SolanaTransactionData, + UtxoDepositTransactionData, +} from '../types' +import { getErrorMessage } from '../utils/errors' + +type BitcoinSigner = ReturnType['bitcoin'] +type SolanaSigner = ReturnType['solana'] + +const executeEvm = async ( + txData: EvmTransactionData, + walletClient: unknown, + walletAddress: string | undefined, +): Promise => { + if (!walletClient || !walletAddress) throw new Error('No wallet connected') + + if (!txData.to || !txData.data) { + console.error('Incomplete EVM transaction data', txData) + throw new Error('Failed to execute swap — please try again') + } + + const client = walletClient as WalletClient + const requiredChainId = txData.chainId + + const currentChainId = await client.getChainId() + if (currentChainId !== requiredChainId) { + await switchOrAddChain(client, requiredChainId) + } + + const chain = VIEM_CHAINS_BY_ID[requiredChainId] + if (!chain) { + console.error('Missing viem chain config for EVM chain ID', requiredChainId) + throw new Error('This network is not yet supported — please try a different route') + } + + return client.sendTransaction({ + to: getAddress(txData.to), + data: txData.data as Hex, + value: BigInt(txData.value ?? '0'), + gas: txData.gasLimit ? BigInt(txData.gasLimit) : undefined, + chain, + account: getAddress(walletAddress), + }) +} + +const executeUtxoDeposit = ( + txData: UtxoDepositTransactionData, + bitcoin: BitcoinSigner, +): Promise => { + if (!bitcoin.isConnected || !bitcoin.address) throw new Error('Bitcoin wallet not connected') + + bitcoin.reset() + + return bitcoin.sendTransfer({ + recipientAddress: txData.depositAddress, + amount: txData.value, + memo: txData.memo, + }) +} + +const executeSolana = async ( + txData: SolanaTransactionData, + solana: SolanaSigner, +): Promise => { + if (!solana.isConnected || !solana.address || !solana.connection) { + throw new Error('Solana wallet not connected') + } + + solana.reset() + + const { Transaction, PublicKey, TransactionInstruction } = await import('@solana/web3.js') + + const instructions = txData.instructions.map(ix => { + return new TransactionInstruction({ + keys: ix.keys.map(key => ({ + pubkey: new PublicKey(key.pubkey), + isSigner: key.isSigner, + isWritable: key.isWritable, + })), + programId: new PublicKey(ix.programId), + data: Buffer.from(ix.data, 'base64'), + }) + }) + + const transaction = new Transaction().add(...instructions) + + const conn = solana.connection as { + getLatestBlockhash: (commitment: string) => Promise<{ blockhash: string }> + } + + const { blockhash } = await conn.getLatestBlockhash('confirmed') + + transaction.recentBlockhash = blockhash + transaction.feePayer = new PublicKey(solana.address) + + return solana.sendTransaction({ transaction }) +} export const useSwapExecution = () => { const stateValue = SwapMachineCtx.useSelector(s => s.value) - const context = SwapMachineCtx.useSelector(s => s.context) const actorRef = SwapMachineCtx.useActorRef() const { walletClient, walletAddress, bitcoin, solana } = useSwapWallet() @@ -16,184 +113,65 @@ export const useSwapExecution = () => { const executingRef = useRef(false) useEffect(() => { - const snap = actorRef.getSnapshot() - if (!snap.matches('executing') || executingRef.current) return + if (stateValue !== 'executing' || executingRef.current) return executingRef.current = true const executeSwap = async () => { try { - const quote = context.quote + const quote = actorRef.getSnapshot().context.quote if (!quote) { - actorRef.send({ type: 'EXECUTE_ERROR', error: 'No quote available' }) + console.error('Reached executing state without a quote in context') + actorRef.send({ + type: 'EXECUTE_ERROR', + error: 'Failed to execute swap — please try again', + }) return } - if (context.isSellAssetEvm) { - if (!walletClient || !walletAddress) { - actorRef.send({ type: 'EXECUTE_ERROR', error: 'No wallet connected' }) - return - } - - const requiredChainId = getEvmNetworkId(context.sellAsset.chainId) - const client = walletClient as WalletClient - - const currentChainId = await client.getChainId() - if (currentChainId !== requiredChainId) { - await switchOrAddChain(client, requiredChainId) - } - - const viemChain = VIEM_CHAINS_BY_ID[requiredChainId] - if (!viemChain) { - throw new Error( - `Unsupported EVM chain ID ${requiredChainId}. No RPC configuration available.`, - ) - } - const chain = viemChain - - const outerStep = quote.steps?.[0] - const innerStep = quote.quote?.steps?.[0] - - const transactionData = - quote.transactionData ?? - outerStep?.transactionData ?? - outerStep?.relayTransactionMetadata ?? - outerStep?.butterSwapTransactionMetadata ?? - innerStep?.transactionData ?? - innerStep?.relayTransactionMetadata ?? - innerStep?.butterSwapTransactionMetadata - - if (!transactionData) { - throw new Error( - `No transaction data returned. Response keys: ${Object.keys(quote).join(', ')}`, - ) - } - - const txData = transactionData as { - type?: string - to: string - data: string - value?: string - gasLimit?: string - } - - if (txData.type && txData.type !== 'evm') { - throw new Error( - `Expected EVM transaction data but got type '${txData.type}'. Check swapper configuration.`, - ) - } - - if (!txData.to || !txData.data) { - throw new Error(`Invalid EVM transaction data: missing 'to' or 'data' fields.`) - } - - const txHash = await client.sendTransaction({ - to: txData.to as `0x${string}`, - data: txData.data as `0x${string}`, - value: BigInt(txData.value ?? '0'), - gas: txData.gasLimit ? BigInt(txData.gasLimit) : undefined, - chain, - account: walletAddress as `0x${string}`, + const txData = quote.steps[0]?.transactionData + if (!txData) { + console.error('Quote missing transactionData in steps[0]', quote) + actorRef.send({ + type: 'EXECUTE_ERROR', + error: 'Failed to execute swap — please try again', }) + return + } - actorRef.send({ type: 'EXECUTE_SUCCESS', txHash }) - } else if (context.isSellAssetUtxo) { - if (!bitcoin.isConnected || !bitcoin.address) { - actorRef.send({ type: 'EXECUTE_ERROR', error: 'Bitcoin wallet not connected' }) - return - } - - bitcoin.reset() - - const outerStep = quote.steps?.[0] - const innerStep = quote.quote?.steps?.[0] - const transactionData = - quote.transactionData ?? outerStep?.transactionData ?? innerStep?.transactionData - - if (!transactionData) { - throw new Error( - `No transaction data returned. Response keys: ${Object.keys(quote).join(', ')}`, - ) - } - - let txid: string - - if (transactionData.type === 'utxo_deposit') { - txid = await bitcoin.sendTransfer({ - recipientAddress: transactionData.depositAddress, - amount: transactionData.value, - memo: transactionData.memo, - }) - } else { - throw new Error( - `Unsupported UTXO transaction type: ${transactionData.type}. Only utxo_deposit is supported.`, - ) - } - - actorRef.send({ type: 'EXECUTE_SUCCESS', txHash: txid }) - } else if (context.isSellAssetSolana) { - if (!solana.isConnected || !solana.address || !solana.connection) { - actorRef.send({ type: 'EXECUTE_ERROR', error: 'Solana wallet not connected' }) - return - } - - solana.reset() - - const outerStep = quote.steps?.[0] - const innerStep = quote.quote?.steps?.[0] - - const txData = outerStep?.transactionData ?? innerStep?.transactionData - - if (!txData || txData.type !== 'solana') { - throw new Error( - `No Solana transaction metadata returned. Response keys: ${Object.keys(quote).join( - ', ', - )}`, - ) + const txHash = await ((): Promise => { + switch (txData.type) { + case 'evm': + return executeEvm(txData, walletClient, walletAddress) + case 'utxo_deposit': + return executeUtxoDeposit(txData, bitcoin) + case 'solana': + return executeSolana(txData, solana) + case 'utxo_psbt': + case 'cosmos': + throw new Error('This swap is not yet supported — please try a different route') + default: { + const _exhaustive: never = txData + console.error( + 'Unhandled transaction data type', + (_exhaustive as { type?: string }).type, + ) + throw new Error('Failed to execute swap — please try again') + } } + })() - const { Transaction, PublicKey, TransactionInstruction } = await import('@solana/web3.js') - - const instructions = txData.instructions.map(ix => { - const keys = ix.keys.map(key => ({ - pubkey: new PublicKey(key.pubkey), - isSigner: key.isSigner, - isWritable: key.isWritable, - })) - - return new TransactionInstruction({ - keys, - programId: new PublicKey(ix.programId), - data: Buffer.from(ix.data, 'base64'), - }) - }) - - const transaction = new Transaction().add(...instructions) - const conn = solana.connection as { - getLatestBlockhash: (commitment: string) => Promise<{ blockhash: string }> - } - const { blockhash } = await conn.getLatestBlockhash('confirmed') - transaction.recentBlockhash = blockhash - transaction.feePayer = new PublicKey(solana.address) - - const signature = await solana.sendTransaction({ transaction }) - actorRef.send({ type: 'EXECUTE_SUCCESS', txHash: signature }) - } else { - actorRef.send({ type: 'EXECUTE_ERROR', error: 'Unsupported chain type' }) - } + actorRef.send({ type: 'EXECUTE_SUCCESS', txHash }) } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : JSON.stringify(error) ?? 'Transaction failed' - actorRef.send({ type: 'EXECUTE_ERROR', error: errorMessage }) + actorRef.send({ + type: 'EXECUTE_ERROR', + error: getErrorMessage(error, 'Failed to execute swap — please try again'), + }) } finally { executingRef.current = false } } executeSwap() - // eslint-disable-next-line react-hooks/exhaustive-deps -- stateValue is the sole trigger; other deps are stable refs read from snapshot + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-fire on state machine transitions; wallet handles close over the latest render }, [stateValue]) } diff --git a/packages/swap-widget/src/types/index.ts b/packages/swap-widget/src/types/index.ts index 4fd70e8db37..41bedf9e93f 100644 --- a/packages/swap-widget/src/types/index.ts +++ b/packages/swap-widget/src/types/index.ts @@ -37,7 +37,7 @@ export { erc20Abi as ERC20_ABI } from 'viem' export enum SwapperName { NearIntents = 'NEAR Intents', Relay = 'Relay', - //Thorchain = 'THORChain', + Thorchain = 'THORChain', //Mayachain = 'MAYAChain', //ArbitrumBridge = 'Arbitrum Bridge', //Avnu = 'AVNU', @@ -192,15 +192,6 @@ export type ApiQuoteStep = { estimatedExecutionTimeMs: number | undefined source: string transactionData?: TransactionData - relayTransactionMetadata?: TransactionData - butterSwapTransactionMetadata?: TransactionData - solanaTransactionMetadata?: { - instructions: { - programId: string - keys: { pubkey: string; isSigner: boolean; isWritable: boolean }[] - data: { data: number[] } - }[] - } } export type ApprovalInfo = { @@ -223,16 +214,12 @@ export type QuoteResponse = { buyAmountBeforeFeesCryptoBaseUnit: string buyAmountAfterFeesCryptoBaseUnit: string affiliateBps: string + affiliateAddress?: string slippageTolerancePercentageDecimal: string | undefined networkFeeCryptoBaseUnit: string | undefined - /** @deprecated Use `quote.steps` instead. Top-level `steps` is kept for backward compatibility. */ steps: ApiQuoteStep[] approval: ApprovalInfo expiresAt: number - transactionData?: TransactionData - quote?: { - steps?: ApiQuoteStep[] - } } export type AssetsResponse = { diff --git a/packages/swap-widget/src/utils/errors.ts b/packages/swap-widget/src/utils/errors.ts new file mode 100644 index 00000000000..c45dcf341bf --- /dev/null +++ b/packages/swap-widget/src/utils/errors.ts @@ -0,0 +1,9 @@ +export const getErrorMessage = (error: unknown, fallback = 'Unknown error'): string => { + if (typeof error === 'string') return error + if (error && typeof error === 'object') { + const e = error as { shortMessage?: unknown; message?: unknown } + if (typeof e.shortMessage === 'string' && e.shortMessage) return e.shortMessage + if (typeof e.message === 'string' && e.message) return e.message + } + return fallback +} diff --git a/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts b/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts index c65e3ea21f7..e462d0f3f39 100644 --- a/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts +++ b/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts @@ -92,6 +92,11 @@ const expectedQuoteResponse: Omit[] = [ thorchainSpecific: { maxStreamingQuantity: undefined, }, + thorchainTransactionMetadata: { + to: '0x3624525075b88B24ecc29CE226b0CEc1fFcB6976', + data: '0x', + value: '0', + }, }, ], }, @@ -135,6 +140,11 @@ const expectedQuoteResponse: Omit[] = [ thorchainSpecific: { maxStreamingQuantity: undefined, }, + thorchainTransactionMetadata: { + to: '0x3624525075b88B24ecc29CE226b0CEc1fFcB6976', + data: '0x', + value: '0', + }, }, ], }, diff --git a/packages/swapper/src/thorchain-utils/cosmossdk/getThorTxData.ts b/packages/swapper/src/thorchain-utils/cosmossdk/getThorTxData.ts new file mode 100644 index 00000000000..138e8afccf9 --- /dev/null +++ b/packages/swapper/src/thorchain-utils/cosmossdk/getThorTxData.ts @@ -0,0 +1,33 @@ +import type { Asset } from '@shapeshiftoss/types' + +import type { SwapperConfig, SwapperName } from '../../types' +import { getInboundAddressDataForChain } from '../getInboundAddressDataForChain' +import { getChainIdBySwapper, getDaemonUrl } from '../index' + +type GetThorTxDataArgs = { + sellAsset: Asset + config: SwapperConfig + swapperName: SwapperName +} + +type GetThorTxDataReturn = { + vault: string +} + +export const getThorTxData = async ({ + sellAsset, + config, + swapperName, +}: GetThorTxDataArgs): Promise => { + // Native assets on the swapper's own chain (RUNE on THORChain, CACAO on MAYAChain) + // deposit via MsgDeposit and have no recipient address. + if (sellAsset.chainId === getChainIdBySwapper(swapperName)) return { vault: '' } + + // Everything else (e.g. ATOM via THORChain, RUNE via MAYAChain) sends via MsgSend + // to the swapper's inbound vault on that chain. + const daemonUrl = getDaemonUrl(config, swapperName) + const res = await getInboundAddressDataForChain(daemonUrl, sellAsset.assetId, true, swapperName) + if (res.isErr()) throw res.unwrapErr() + + return { vault: res.unwrap().address } +} diff --git a/packages/swapper/src/thorchain-utils/cosmossdk/index.ts b/packages/swapper/src/thorchain-utils/cosmossdk/index.ts index e917eef03b0..1fcad3796b7 100644 --- a/packages/swapper/src/thorchain-utils/cosmossdk/index.ts +++ b/packages/swapper/src/thorchain-utils/cosmossdk/index.ts @@ -1 +1,2 @@ export * from './getCosmosSdkTransactionFees' +export * from './getThorTxData' diff --git a/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts b/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts index a3f2bb8fb2a..59766bef101 100644 --- a/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts +++ b/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts @@ -20,6 +20,7 @@ import { v4 as uuid } from 'uuid' import { getDefaultSlippageDecimalPercentageForSwapper } from '../index' import { buildAffiliateFee } from '../swappers/utils/affiliateFee' +import { isNativeEvmAsset } from '../swappers/utils/helpers/helpers' import type { CommonTradeQuoteInput, GetEvmTradeQuoteInput, @@ -34,6 +35,7 @@ import type { } from '../types' import { TradeQuoteError } from '../types' import { getInputOutputRate, makeSwapErrorRight } from '../utils' +import * as cosmossdk from './cosmossdk' import * as evm from './evm' import { getLimitWithManualSlippage } from './getLimitWithManualSlippage/getLimitWithManualSlippage' import { getQuote } from './getQuote' @@ -71,6 +73,7 @@ type MakeThorTradeInputBase = { memo: string allowanceContract: string feeData: QuoteFeeData + thorchainTransactionMetadata?: ThorTradeRateOrQuote['steps']['0']['thorchainTransactionMetadata'] } type MakeThorTradeInput = T extends ThorEvmTradeRateOrQuote @@ -258,6 +261,7 @@ export const getL1RateOrQuote = async ( data, router, vault, + thorchainTransactionMetadata, }: MakeThorTradeInput): T => { const buyAmountAfterFeesCryptoBaseUnit = convertPrecision({ value: route.expectedAmountOutThorBaseUnit, @@ -314,6 +318,7 @@ export const getL1RateOrQuote = async ( thorchainSpecific: { maxStreamingQuantity: route.quote.max_streaming_quantity, }, + thorchainTransactionMetadata, }, ], } as T @@ -353,6 +358,11 @@ export const getL1RateOrQuote = async ( data, router, vault, + thorchainTransactionMetadata: { + to: router, + data, + value: isNativeEvmAsset(sellAsset.assetId) ? sellAmountCryptoBaseUnit : '0', + }, feeData: { protocolFees: getProtocolFees(route.quote), networkFeeCryptoBaseUnit, @@ -414,6 +424,11 @@ export const getL1RateOrQuote = async ( route, allowanceContract: '0x0', // not applicable to UTXOs memo, + thorchainTransactionMetadata: { + to: vault, + memo, + value: sellAmountCryptoBaseUnit, + }, feeData, }) }), @@ -436,13 +451,20 @@ export const getL1RateOrQuote = async ( const cosmosChainAdapter = assertGetCosmosSdkChainAdapter(sellAsset.chainId) const { fast } = await cosmosChainAdapter.getFeeData({}) + const { vault } = await cosmossdk.getThorTxData({ sellAsset, config, swapperName }) return Ok( perRouteValues.map((route): T => { + const memo = getMemo(route) return makeThorTradeRateOrQuote({ route, - memo: getMemo(route), + memo, allowanceContract: '0x0', // not applicable to cosmossdk + thorchainTransactionMetadata: { + to: vault, + memo, + value: sellAmountCryptoBaseUnit, + }, feeData: { networkFeeCryptoBaseUnit: fast.txFee, protocolFees: getProtocolFees(route.quote), @@ -458,6 +480,8 @@ export const getL1RateOrQuote = async ( const adapter = assertGetSolanaChainAdapter(sellAsset.chainId) const sendAddress = (input as CommonTradeQuoteInput).sendAddress + const { vault } = await solana.getThorTxData({ sellAsset, config, swapperName }) + const maybeRoutes = await Promise.allSettled( perRouteValues.map(async (route): Promise => { const memo = getMemo(route) @@ -465,7 +489,6 @@ export const getL1RateOrQuote = async ( const feeData = await (async (): Promise => { if (!sendAddress) return { networkFeeCryptoBaseUnit: undefined, protocolFees } - const { vault } = await solana.getThorTxData({ sellAsset, config, swapperName }) const memoInstruction = new TransactionInstruction({ keys: [], programId: new PublicKey(SOLANA_MEMO_PROGRAM_ID), @@ -492,6 +515,11 @@ export const getL1RateOrQuote = async ( route, allowanceContract: '0x0', memo, + thorchainTransactionMetadata: { + to: vault, + memo, + value: sellAmountCryptoBaseUnit, + }, feeData, }) }), @@ -509,24 +537,19 @@ export const getL1RateOrQuote = async ( return Ok(routes) } case CHAIN_NAMESPACE.Tron: { + const { vault } = await tron.getThorTxData({ sellAsset, config, swapperName }) + const maybeRoutes = await Promise.allSettled( perRouteValues.map(async (route): Promise => { const memo = getMemo(route) let networkFeeCryptoBaseUnit: string | undefined = undefined // Calculate fees for rates when we have a receive address (wallet connected) - if (input.quoteOrRate === 'rate' && input.receiveAddress) { + if (input.quoteOrRate === 'rate' && input.receiveAddress && vault) { try { const { sellAsset, sellAmountIncludingProtocolFeesCryptoBaseUnit } = input const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - // Get vault address - const { vault } = await tron.getThorTxData({ - sellAsset, - config, - swapperName, - }) - // Estimate fees using the receive address for accurate energy calculation const tronWeb = new TronWeb({ fullHost: deps.config.VITE_TRON_NODE_URL, @@ -584,6 +607,11 @@ export const getL1RateOrQuote = async ( route, allowanceContract: '0x0', // not applicable to TRON memo, + thorchainTransactionMetadata: { + to: vault, + memo, + value: sellAmountCryptoBaseUnit, + }, feeData: { networkFeeCryptoBaseUnit, protocolFees: getProtocolFees(route.quote), diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index dbca4b28c35..c6e5288a76c 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -459,6 +459,12 @@ export type TradeQuoteStep = { thorchainSpecific?: { maxStreamingQuantity?: number } + thorchainTransactionMetadata?: { + to: string + data?: string + value?: string + memo?: string + } relayTransactionMetadata?: RelayTransactionMetadata butterSwapTransactionMetadata?: { to: string