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
39 changes: 34 additions & 5 deletions apps/agentic-chat/src/components/tools/InitiateSwapUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StepStatus } from '@/lib/stepUtils'
import { firstFourLastFour } from '@/lib/utils'

import { Amount } from '../ui/Amount'
import { Button } from '../ui/Button'
import { Skeleton } from '../ui/Skeleton'
import { TxStepCard } from '../ui/TxStepCard'

Expand All @@ -12,12 +13,15 @@ import { SWAP_STEPS, useSwapExecution } from './useSwapExecution'

export function InitiateSwapUI({ toolPart }: ToolUIComponentProps<'initiateSwapTool' | 'initiateSwapUsdTool'>) {
const { state: toolState, output, toolCallId } = toolPart
const swapOutput = output
const swapData = toolState === 'output-available' && output ? output : null
const { state, steps, networkName, quote, awaitingAcceptance, acceptQuote, cancelQuote } = useSwapExecution(
toolCallId,
toolState,
swapData
)
const swapOutput = quote ?? output
const address = swapOutput?.swapData.sellAccount

const swapData = toolState === 'output-available' && swapOutput ? swapOutput : null
const { state, steps, networkName } = useSwapExecution(toolCallId, toolState, swapData)

const quoteStepStatus = steps[SWAP_STEPS.QUOTE]?.status ?? StepStatus.NOT_STARTED

const swap = swapOutput?.swapData
Expand Down Expand Up @@ -68,6 +72,17 @@ export function InitiateSwapUI({ toolPart }: ToolUIComponentProps<'initiateSwapT
{swap && (
<TxStepCard.Content>
<TxStepCard.Details>
<TxStepCard.DetailItem label="Provider" value={swapOutput?.summary.exchange.provider ?? '—'} />
{swap.approvalTarget && (
<TxStepCard.DetailItem
label="Approval spender"
value={
<span className="break-all" title={swap.approvalTarget}>
{firstFourLastFour(swap.approvalTarget)}
</span>
}
/>
)}
<TxStepCard.DetailItem
label="Pair"
value={`${swap.sellAsset.symbol.toUpperCase()} → ${swap.buyAsset.symbol.toUpperCase()}`}
Expand All @@ -93,7 +108,8 @@ export function InitiateSwapUI({ toolPart }: ToolUIComponentProps<'initiateSwapT
symbol={swapOutput.summary.exchange.networkFeeSymbol}
suffix={
<>
(<Amount.Fiat value={swapOutput.summary.exchange.networkFeeUsd} />)
(
<Amount.Fiat value={swapOutput.summary.exchange.networkFeeUsd} />)
</>
}
/>
Expand All @@ -106,6 +122,19 @@ export function InitiateSwapUI({ toolPart }: ToolUIComponentProps<'initiateSwapT
</TxStepCard.Content>
)}

{awaitingAcceptance && (
<TxStepCard.Content>
<p role="status" className="text-sm mb-3">
Your quote has changed. Review the updated amount and fees above before continuing.
</p>
<div className="flex gap-2">
<Button onClick={acceptQuote}>Accept updated quote</Button>
<Button variant="outline" onClick={cancelQuote}>
Cancel swap
</Button>
</div>
</TxStepCard.Content>
)}
<Execution.Stepper>
<Execution.Step
index={SWAP_STEPS.QUOTE}
Expand Down
148 changes: 116 additions & 32 deletions apps/agentic-chat/src/components/tools/useSwapExecution.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { InitiateSwapOutput } from '@shapeshiftoss/agentic-server'
import { CHAIN_NAMESPACE, fromChainId } from '@shapeshiftoss/caip'
import type { DynamicToolUIPart } from 'ai'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'

import { Amount } from '@/components/ui/Amount'
Expand All @@ -12,12 +13,19 @@ import { analytics } from '@/lib/mixpanel'
import { switchNetworkStep } from '@/lib/steps/switchNetworkStep'
import type { StepStatus } from '@/lib/stepUtils'
import { withWalletLock } from '@/lib/walletMutex'
import { refreshSwapQuote } from '@/services/swapService'
import type { SolanaWalletSigner } from '@/utils/chains/types'
import { ensureAllowance } from '@/utils/ensureAllowance'
import { assertQuoteFresh, prepareFreshSwap } from '@/utils/prepareFreshSwap'
import { executeSwap } from '@/utils/swapExecutor'
import { waitForConfirmedReceipt } from '@/utils/waitForConfirmedReceipt'

export const SWAP_STEPS = { QUOTE: 0, NETWORK: 1, APPROVE: 2, SWAP: 3 } as const
export const SWAP_STEPS = {
QUOTE: 0,
NETWORK: 1,
APPROVE: 2,
SWAP: 3,
} as const

type SwapData = InitiateSwapOutput

Expand All @@ -33,6 +41,10 @@ interface UseSwapExecutionResult {
error?: string
approvalTxHash?: string
swapTxHash?: string
quote: SwapData | null
awaitingAcceptance: boolean
acceptQuote: () => void
cancelQuote: () => void
}

export const useSwapExecution = (
Expand All @@ -41,6 +53,22 @@ export const useSwapExecution = (
swapData: SwapData | null
): UseSwapExecutionResult => {
const ctx = useToolExecution(toolCallId, 'initiateSwapTool', {})
const [awaitingAcceptance, setAwaitingAcceptance] = useState(false)
const confirmation = useRef<((accepted: boolean) => void) | null>(null)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
confirmation.current?.(false)
confirmation.current = null
}
}, [])
const answer = (accepted: boolean) => {
confirmation.current?.(accepted)
confirmation.current = null
setAwaitingAcceptance(false)
}

useExecuteOnce(ctx, swapData, async (data, ctx) => {
await withWalletLock(async () => {
Expand All @@ -55,12 +83,25 @@ export const useSwapExecution = (
const sellAssetChainId = data.swapData.sellAsset.chainId
const { chainNamespace, chainReference } = fromChainId(sellAssetChainId)

const currentAddress =
chainNamespace === CHAIN_NAMESPACE.Evm ? ctx.refs.evmAddress.current : ctx.refs.solanaAddress.current
if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.')
if (currentAddress.toLowerCase() !== swapTx.from.toLowerCase()) {
throw new Error('Wallet address changed. Please re-initiate the swap.')
const assertWallet = () => {
if (!mounted.current) throw new Error('Swap view closed. Please start again.')
for (const [asset, account] of [
[data.swapData.sellAsset, data.swapData.sellAccount],
[data.swapData.buyAsset, data.swapData.buyAccount],
] as const) {
const address =
fromChainId(asset.chainId).chainNamespace === CHAIN_NAMESPACE.Evm
? ctx.refs.evmAddress.current
: ctx.refs.solanaAddress.current
const matches =
address &&
(asset.chainId.startsWith('eip155:')
? address.toLowerCase() === account.toLowerCase()
: address === account)
if (!matches) throw new Error('Wallet changed or disconnected. Please re-initiate the swap.')
}
}
assertWallet()

let solanaSigner: SolanaWalletSigner | undefined
if (chainNamespace === CHAIN_NAMESPACE.Solana && ctx.refs.solanaWallet.current) {
Expand All @@ -77,32 +118,62 @@ export const useSwapExecution = (
// Step 1: Network switch
await switchNetworkStep(ctx, sellAssetChainId)

// Step 2: Approve — re-check on-chain allowance to handle parallel swaps
ctx.setSubstatus('Checking allowance...')
const approvalTxHash = await ensureAllowance({
sellAssetId: data.swapData.sellAsset.assetId,
sellAssetChainId: sellAssetChainId,
sellAssetPrecision: data.swapData.sellAsset.precision,
approvalTarget: data.swapData.approvalTarget,
sellAmountCryptoPrecision: data.swapData.sellAmountCryptoPrecision,
sellAccount: data.swapData.sellAccount,
solanaSigner,
// Refresh after the wallet lock/network switch, then again after any approval.
let hadApproval = false
data = await prepareFreshSwap(data, {
assertWallet,
refresh: async quote => {
ctx.setSubstatus('Refreshing swap quote...')
return refreshSwapQuote(quote)
},
show: quote =>
ctx.setState(draft => {
draft.toolOutput = quote
}),
confirm: () => {
ctx.setSubstatus('Review updated quote')
setAwaitingAcceptance(true)
return new Promise<boolean>(resolve => {
confirmation.current = resolve
})
},
approve: async quote => {
ctx.setSubstatus('Checking allowance...')
const swap = quote.swapData
const approvalTxHash = await ensureAllowance({
sellAssetId: swap.sellAsset.assetId,
sellAssetChainId,
sellAssetPrecision: swap.sellAsset.precision,
approvalTarget: swap.approvalTarget,
sellAmountCryptoPrecision: swap.sellAmountCryptoPrecision,
sellAccount: swap.sellAccount,
solanaSigner,
beforeSign: () => {
assertWallet()
assertQuoteFresh(quote)
},
})
if (!approvalTxHash) return false
hadApproval = true
ctx.setMeta({ approvalTxHash })
if (chainNamespace === CHAIN_NAMESPACE.Evm) {
ctx.setSubstatus('Waiting for approval confirmation...')
await waitForConfirmedReceipt(Number(chainReference), approvalTxHash as `0x${string}`)
}
return true
},
})
if (hadApproval) ctx.advanceStep()
else ctx.skipStep()

if (approvalTxHash) {
ctx.setMeta({ approvalTxHash })
if (chainNamespace === CHAIN_NAMESPACE.Evm) {
ctx.setSubstatus('Waiting for confirmation...')
await waitForConfirmedReceipt(Number(chainReference), approvalTxHash as `0x${string}`)
}
ctx.advanceStep()
} else {
ctx.skipStep()
}

// Step 3: Swap
ctx.setSubstatus('Requesting signature...')
const swapTxHash = await executeSwap(swapTx, { solanaSigner })
const swapTxHash = await executeSwap(data.swapTx, {
solanaSigner,
beforeSign: () => {
assertWallet()
assertQuoteFresh(data)
},
})
ctx.setMeta({ txHash: swapTxHash })

if (chainNamespace === CHAIN_NAMESPACE.Evm) {
Expand Down Expand Up @@ -171,11 +242,24 @@ export const useSwapExecution = (

return {
state: ctx.state,
quote: (ctx.state.toolOutput as SwapData | undefined) ?? swapData,
awaitingAcceptance,
acceptQuote: () => answer(true),
cancelQuote: () => answer(false),
steps: [
{ step: SWAP_STEPS.QUOTE, status: quoteStepStatus },
{ step: SWAP_STEPS.NETWORK, status: getStepStatus(SWAP_STEPS.NETWORK, ctx.state) },
{ step: SWAP_STEPS.APPROVE, status: getStepStatus(SWAP_STEPS.APPROVE, ctx.state) },
{ step: SWAP_STEPS.SWAP, status: getStepStatus(SWAP_STEPS.SWAP, ctx.state) },
{
step: SWAP_STEPS.NETWORK,
status: getStepStatus(SWAP_STEPS.NETWORK, ctx.state),
},
{
step: SWAP_STEPS.APPROVE,
status: getStepStatus(SWAP_STEPS.APPROVE, ctx.state),
},
{
step: SWAP_STEPS.SWAP,
status: getStepStatus(SWAP_STEPS.SWAP, ctx.state),
},
],
networkName: swapData?.swapData?.sellAsset?.network,
error: ctx.state.error,
Expand Down
22 changes: 22 additions & 0 deletions apps/agentic-chat/src/services/swapService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { InitiateSwapOutput } from '@shapeshiftoss/agentic-server'

export async function refreshSwapQuote(quote: InitiateSwapOutput): Promise<InitiateSwapOutput> {
const swap = quote.swapData
const response = await fetch(`${import.meta.env.VITE_AGENTIC_SERVER_BASE_URL}/api/swap/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(30_000),
body: JSON.stringify({
sellAssetId: swap.sellAsset.assetId,
buyAssetId: swap.buyAsset.assetId,
sellAmount: swap.sellAmountCryptoPrecision,
sellAccount: swap.sellAccount,
buyAccount: swap.buyAccount,
}),
})
if (!response.ok) {
const result = (await response.json()) as { error?: string }
throw new Error(result.error || 'Unable to refresh swap quote. Please try again.')
}
return response.json() as Promise<InitiateSwapOutput>
}
Loading
Loading