From efcb28d587b8ea7e0d36e501cce7fb5dd6fde2c4 Mon Sep 17 00:00:00 2001 From: Anthony Potdevin Date: Sun, 15 Mar 2026 23:36:03 -0600 Subject: [PATCH] feat: rework boltz swaps --- schema.gql | 17 - src/client/src/context/BoltzSwapContext.tsx | 610 ++++++++++++++++++ src/client/src/context/ContextProvider.tsx | 5 +- .../getBoltzSwapStatus.generated.tsx | 140 ---- .../src/graphql/queries/getBoltzSwapStatus.ts | 17 - src/client/src/graphql/types.ts | 29 - .../src/layouts/sidebar/RightSidebar.tsx | 2 + .../src/layouts/sidebar/SidebarSwap.tsx | 311 +++++++++ src/client/src/views/home/account/pay/Pay.tsx | 14 +- src/client/src/views/swap/StartSwap.tsx | 14 +- src/client/src/views/swap/SwapClaim.tsx | 38 +- src/client/src/views/swap/SwapContext.tsx | 115 ---- src/client/src/views/swap/SwapExpire.tsx | 31 +- src/client/src/views/swap/SwapProgress.tsx | 136 ++++ src/client/src/views/swap/SwapQuote.tsx | 38 +- src/client/src/views/swap/SwapStatus.tsx | 188 ++---- src/client/src/views/swap/boltzStatus.ts | 51 ++ src/client/src/views/swap/index.tsx | 58 +- src/client/src/views/swap/types.ts | 10 +- .../modules/api/boltz/boltz.resolver.ts | 32 +- src/server/modules/api/boltz/boltz.service.ts | 10 - src/server/modules/api/boltz/boltz.types.ts | 34 - 22 files changed, 1277 insertions(+), 623 deletions(-) create mode 100644 src/client/src/context/BoltzSwapContext.tsx delete mode 100644 src/client/src/graphql/queries/__generated__/getBoltzSwapStatus.generated.tsx delete mode 100644 src/client/src/graphql/queries/getBoltzSwapStatus.ts create mode 100644 src/client/src/layouts/sidebar/SidebarSwap.tsx delete mode 100644 src/client/src/views/swap/SwapContext.tsx create mode 100644 src/client/src/views/swap/SwapProgress.tsx create mode 100644 src/client/src/views/swap/boltzStatus.ts diff --git a/schema.gql b/schema.gql index d42c1df4..9e41a3e0 100644 --- a/schema.gql +++ b/schema.gql @@ -83,22 +83,6 @@ type BoltzInfoType { min: Float! } -type BoltzSwap { - boltz: BoltzSwapStatus - id: String -} - -type BoltzSwapStatus { - status: String! - transaction: BoltzSwapTransaction -} - -type BoltzSwapTransaction { - eta: Float - hex: String - id: String -} - type ChainAddressSend { confirmationCount: Float! id: String! @@ -664,7 +648,6 @@ type Query { getBitcoinFees: BitcoinFee! getBitcoinPrice: String! getBoltzInfo: BoltzInfoType! - getBoltzSwapStatus(ids: [String!]!): [BoltzSwap!]! getChainTransactions: [ChainTransaction!]! getChannel(id: String!): SingleChannel! getChannelReport: ChannelReport! diff --git a/src/client/src/context/BoltzSwapContext.tsx b/src/client/src/context/BoltzSwapContext.tsx new file mode 100644 index 00000000..247140ea --- /dev/null +++ b/src/client/src/context/BoltzSwapContext.tsx @@ -0,0 +1,610 @@ +import { + FC, + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} from 'react'; +import toast from 'react-hot-toast'; +import { useClaimBoltzTransactionMutation } from '../graphql/mutations/__generated__/claimBoltzTransaction.generated'; +import { useBitcoinFees } from '../hooks/UseBitcoinFees'; +import { isClaimableStatus } from '../views/swap/boltzStatus'; +import { CreateBoltzReverseSwap } from '../views/swap/types'; +import Modal from '../components/modal/ReactModal'; +import { SwapQuote } from '../views/swap/SwapQuote'; +import { SwapClaim } from '../views/swap/SwapClaim'; + +// --- Types --- + +type LiveStatus = { + status: string; + transaction?: { id?: string | null; hex?: string | null }; +} | null; + +export type SwapEntry = CreateBoltzReverseSwap & { + liveStatus: LiveStatus; + claimState: 'idle' | 'claiming' | 'claimed' | 'failed'; + claimRetries: number; +}; + +type State = { + swaps: SwapEntry[]; + openSwapId: string | null; + claimSwapId: string | null; + claimType: string | null; +}; + +type Action = + | { type: 'init'; swaps: SwapEntry[] } + | { type: 'add'; swap: CreateBoltzReverseSwap } + | { + type: 'updateStatus'; + id: string; + status: string; + transaction?: { id?: string | null; hex?: string | null }; + } + | { + type: 'setClaimState'; + id: string; + claimState: SwapEntry['claimState']; + retries?: number; + } + | { type: 'complete'; id: string; transactionId: string } + | { type: 'open'; id: string } + | { type: 'claim'; id: string; claimType: string } + | { type: 'close' } + | { type: 'cleanup' }; + +// --- Terminal statuses (WS won't update further) --- + +const TERMINAL_STATUSES = new Set([ + 'swap.expired', + 'invoice.expired', + 'invoice.failedToPay', + 'transaction.claimed', + 'transaction.refunded', +]); + +const CLEANUP_STATUSES = new Set([ + 'swap.expired', + 'invoice.expired', + 'transaction.refunded', + 'transaction.claimed', + 'invoice.settled', +]); + +// --- localStorage --- + +const STORAGE_KEY = 'boltz_swaps'; +const OLD_SWAPS_KEY = 'swaps'; + +const toSwapEntry = (swap: CreateBoltzReverseSwap): SwapEntry => ({ + ...swap, + liveStatus: null, + claimState: 'idle', + claimRetries: 0, +}); + +const loadSwaps = (): SwapEntry[] => { + // Try new key first + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed: SwapEntry[] = JSON.parse(raw); + return parsed.map(s => ({ + ...s, + liveStatus: null, + claimState: + s.claimState === 'claiming' ? 'idle' : (s.claimState ?? 'idle'), + claimRetries: 0, + })); + } + } catch { + /* ignore */ + } + + // Migrate from old 'swaps' key + try { + const oldSwaps: CreateBoltzReverseSwap[] = JSON.parse( + localStorage.getItem(OLD_SWAPS_KEY) || '[]' + ); + if (oldSwaps.length > 0) { + const entries = oldSwaps.filter(s => s?.id).map(toSwapEntry); + localStorage.removeItem(OLD_SWAPS_KEY); + if (entries.length > 0) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } + return entries; + } + } catch { + /* ignore */ + } + + return []; +}; + +const persistSwaps = (swaps: SwapEntry[]) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(swaps)); +}; + +// --- Reducer --- + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'init': + return { ...state, swaps: action.swaps }; + + case 'add': { + const entry = toSwapEntry(action.swap); + const swaps = [...state.swaps, entry]; + persistSwaps(swaps); + return { ...state, swaps }; + } + + case 'updateStatus': { + const swaps = state.swaps.map(s => + s.id === action.id + ? { + ...s, + liveStatus: { + status: action.status, + transaction: action.transaction, + }, + } + : s + ); + return { ...state, swaps }; + } + + case 'setClaimState': { + const swaps = state.swaps.map(s => + s.id === action.id + ? { + ...s, + claimState: action.claimState, + claimRetries: action.retries ?? s.claimRetries, + } + : s + ); + persistSwaps(swaps); + return { ...state, swaps }; + } + + case 'complete': { + const swaps = state.swaps.map(s => + s.id === action.id + ? { + ...s, + claimTransaction: action.transactionId, + claimState: 'claimed' as const, + claimRetries: 0, + } + : s + ); + persistSwaps(swaps); + return { + ...state, + swaps, + openSwapId: null, + claimSwapId: null, + claimType: null, + }; + } + + case 'open': + return { + ...state, + openSwapId: action.id, + claimSwapId: null, + claimType: null, + }; + + case 'claim': + return { + ...state, + claimSwapId: action.id, + claimType: action.claimType, + openSwapId: null, + }; + + case 'close': + return { ...state, openSwapId: null, claimSwapId: null, claimType: null }; + + case 'cleanup': { + const swaps = state.swaps.filter(s => { + if (s.claimState === 'claiming') return true; + const status = s.liveStatus?.status; + if (!status) return true; + return !CLEANUP_STATUSES.has(status); + }); + persistSwaps(swaps); + return { ...state, swaps }; + } + + default: + return state; + } +}; + +const initialState: State = { + swaps: [], + openSwapId: null, + claimSwapId: null, + claimType: null, +}; + +// --- WebSocket Manager --- + +const BOLTZ_WS_URL = 'wss://api.boltz.exchange/v2/ws'; +const PING_INTERVAL = 30000; +const RECONNECT_DELAY = 5000; + +type WSManager = { + connect: () => void; + disconnect: () => void; + updateIds: (ids: string[]) => void; +}; + +const createWSManager = ( + onUpdate: ( + id: string, + status: string, + transaction?: { id?: string | null; hex?: string | null } + ) => void +): WSManager => { + let ws: WebSocket | null = null; + let pingTimer: ReturnType | null = null; + let reconnectTimer: ReturnType | null = null; + let activeIds: string[] = []; + let manualClose = false; + + const clearTimers = () => { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + const sendSubscribe = (socket: WebSocket, ids: string[]) => { + if (ids.length === 0 || socket.readyState !== WebSocket.OPEN) return; + socket.send( + JSON.stringify({ op: 'subscribe', channel: 'swap.update', args: ids }) + ); + }; + + const connect = () => { + if (ws && ws.readyState === WebSocket.OPEN) return; + manualClose = false; + clearTimers(); + + const socket = new WebSocket(BOLTZ_WS_URL); + ws = socket; + + socket.onopen = () => { + sendSubscribe(socket, activeIds); + pingTimer = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ op: 'ping' })); + } + }, PING_INTERVAL); + }; + + socket.onmessage = event => { + try { + const data = JSON.parse(event.data); + if (data.event === 'pong' || data.event === 'ping') return; + if (data.event === 'update' && data.channel === 'swap.update') { + const updates: { + id: string; + status: string; + transaction?: { id?: string | null; hex?: string | null }; + }[] = data.args; + for (const u of updates) { + onUpdate(u.id, u.status, u.transaction); + } + } + } catch { + /* ignore */ + } + }; + + socket.onerror = () => { + /* silent */ + }; + + socket.onclose = () => { + clearTimers(); + ws = null; + if (!manualClose && activeIds.length > 0) { + reconnectTimer = setTimeout(connect, RECONNECT_DELAY); + } + }; + }; + + const disconnect = () => { + manualClose = true; + clearTimers(); + if (ws) { + ws.close(); + ws = null; + } + }; + + const updateIds = (ids: string[]) => { + const newIds = ids.filter(id => !activeIds.includes(id)); + activeIds = ids; + + if (ids.length === 0) { + disconnect(); + return; + } + + if (!ws || ws.readyState !== WebSocket.OPEN) { + connect(); + } else if (newIds.length > 0) { + sendSubscribe(ws, newIds); + } + }; + + return { connect, disconnect, updateIds }; +}; + +// --- Context --- + +type BoltzSwapContextType = { + state: State; + dispatch: (action: Action) => void; +}; + +const BoltzSwapContext = createContext( + undefined +); + +// --- Provider --- + +export const BoltzSwapProvider: FC<{ children?: ReactNode }> = ({ + children, +}) => { + const [state, dispatch] = useReducer(reducer, initialState); + const { fast } = useBitcoinFees(); + const fastRef = useRef(fast); + fastRef.current = fast; + + // Load from localStorage on mount + useEffect(() => { + const swaps = loadSwaps(); + if (swaps.length > 0) { + dispatch({ type: 'init', swaps }); + } + }, []); + + // WS manager + const wsManagerRef = useRef(null); + + useEffect(() => { + const manager = createWSManager((id, status, transaction) => { + dispatch({ type: 'updateStatus', id, status, transaction }); + }); + wsManagerRef.current = manager; + return () => { + manager.disconnect(); + // Clear any pending auto-claim timers + for (const id of claimTimersRef.current) { + clearTimeout(id); + } + claimTimersRef.current.clear(); + }; + }, []); + + // Keep WS subscribed to active (non-terminal) swap IDs + const activeIds = useMemo( + () => + state.swaps + .filter(s => { + const status = s.liveStatus?.status; + if (!status) return true; // not yet known + return !TERMINAL_STATUSES.has(status); + }) + .map(s => s.id) + .filter(Boolean), + [state.swaps] + ); + + useEffect(() => { + wsManagerRef.current?.updateIds(activeIds); + }, [activeIds]); + + // Tab visibility: reconnect immediately when tab becomes visible + useEffect(() => { + const handler = () => { + if (document.visibilityState === 'visible' && activeIds.length > 0) { + wsManagerRef.current?.disconnect(); + wsManagerRef.current?.connect(); + } + }; + document.addEventListener('visibilitychange', handler); + return () => document.removeEventListener('visibilitychange', handler); + }, [activeIds]); + + // Auto-claim mutation + const [claimTransaction] = useClaimBoltzTransactionMutation(); + const claimingIdsRef = useRef(new Set()); + const claimTimersRef = useRef(new Set>()); + + const attemptAutoClaim = useCallback( + (swap: SwapEntry) => { + if (claimingIdsRef.current.has(swap.id)) return; + if ( + !swap.preimage || + !swap.lockupAddress || + !swap.privateKey || + !swap.redeemScript + ) + return; + + claimingIdsRef.current.add(swap.id); + dispatch({ + type: 'setClaimState', + id: swap.id, + claimState: 'claiming', + retries: 0, + }); + + const scheduleTimeout = (fn: () => void, ms: number) => { + const id = setTimeout(() => { + claimTimersRef.current.delete(id); + fn(); + }, ms); + claimTimersRef.current.add(id); + }; + + const doAttempt = (retries: number) => { + claimTransaction({ + variables: { + id: swap.id, + redeem: swap.redeemScript!, + lockupAddress: swap.lockupAddress!, + preimage: swap.preimage!, + privateKey: swap.privateKey!, + destination: swap.receivingAddress, + fee: fastRef.current || 2, + }, + }) + .then(result => { + const txId = result.data?.claimBoltzTransaction; + if (txId) { + dispatch({ type: 'complete', id: swap.id, transactionId: txId }); + toast.success('Swap claimed successfully!'); + } else { + throw new Error('No transaction returned'); + } + claimingIdsRef.current.delete(swap.id); + }) + .catch(() => { + if (retries < 4) { + scheduleTimeout(() => { + dispatch({ + type: 'setClaimState', + id: swap.id, + claimState: 'claiming', + retries: retries + 1, + }); + doAttempt(retries + 1); + }, 5000); + } else { + dispatch({ + type: 'setClaimState', + id: swap.id, + claimState: 'failed', + retries: 5, + }); + claimingIdsRef.current.delete(swap.id); + toast.error( + 'Auto-claim failed. Claim manually from the Swap page.' + ); + } + }); + }; + + // Initial delay before first attempt + scheduleTimeout(() => doAttempt(0), 5000); + }, + [claimTransaction] + ); + + // Watch for claimable swaps + useEffect(() => { + for (const swap of state.swaps) { + if ( + swap.claimState === 'idle' && + swap.liveStatus && + isClaimableStatus(swap.liveStatus.status) + ) { + attemptAutoClaim(swap); + } + } + }, [state.swaps, attemptAutoClaim]); + + const ctx = useMemo(() => ({ state, dispatch }), [state]); + + return ( + + {children} + + + ); +}; + +// --- Global Dialog --- + +const BoltzSwapDialog: FC<{ + state: State; + dispatch: (action: Action) => void; +}> = ({ state, dispatch }) => { + const { openSwapId, claimSwapId } = state; + const isOpen = !!openSwapId || !!claimSwapId; + + return ( + dispatch({ type: 'close' })}> + {openSwapId ? : } + + ); +}; + +// --- Hooks --- + +const useBoltzSwapContext = () => { + const ctx = useContext(BoltzSwapContext); + if (!ctx) + throw new Error( + 'useBoltzSwapContext must be used within BoltzSwapProvider' + ); + return ctx; +}; + +export const useBoltzSwaps = () => { + const { state } = useBoltzSwapContext(); + return state; +}; + +export const useBoltzSwapById = (id: string | null) => { + const { state } = useBoltzSwapContext(); + return useMemo( + () => (id ? (state.swaps.find(s => s.id === id) ?? null) : null), + [state.swaps, id] + ); +}; + +export const useBoltzSwapActions = () => { + const { dispatch } = useBoltzSwapContext(); + + return useMemo( + () => ({ + addSwap: (swap: CreateBoltzReverseSwap) => + dispatch({ type: 'add', swap }), + openSwap: (id: string) => dispatch({ type: 'open', id }), + openClaim: (id: string, claimType: string) => + dispatch({ type: 'claim', id, claimType }), + close: () => dispatch({ type: 'close' }), + cleanup: () => dispatch({ type: 'cleanup' }), + updateStatus: ( + id: string, + status: string, + transaction?: { id?: string | null; hex?: string | null } + ) => dispatch({ type: 'updateStatus', id, status, transaction }), + setClaimState: (id: string, claimState: SwapEntry['claimState']) => + dispatch({ type: 'setClaimState', id, claimState }), + completeSwap: (id: string, transactionId: string) => + dispatch({ type: 'complete', id, transactionId }), + }), + [dispatch] + ); +}; diff --git a/src/client/src/context/ContextProvider.tsx b/src/client/src/context/ContextProvider.tsx index df4f3c1d..43948b13 100644 --- a/src/client/src/context/ContextProvider.tsx +++ b/src/client/src/context/ContextProvider.tsx @@ -2,11 +2,14 @@ import { FC, ReactNode } from 'react'; import { PriceProvider } from './PriceContext'; import { DashProvider } from './DashContext'; import { NotificationProvider } from './NotificationContext'; +import { BoltzSwapProvider } from './BoltzSwapContext'; export const ContextProvider: FC<{ children?: ReactNode }> = ({ children }) => ( - {children} + + {children} + ); diff --git a/src/client/src/graphql/queries/__generated__/getBoltzSwapStatus.generated.tsx b/src/client/src/graphql/queries/__generated__/getBoltzSwapStatus.generated.tsx deleted file mode 100644 index e21cf6e1..00000000 --- a/src/client/src/graphql/queries/__generated__/getBoltzSwapStatus.generated.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as Types from '../../types'; - -import { gql } from '@apollo/client'; -import * as Apollo from '@apollo/client'; -const defaultOptions = {} as const; -export type GetBoltzSwapStatusQueryVariables = Types.Exact<{ - ids: - | Array - | Types.Scalars['String']['input']; -}>; - -export type GetBoltzSwapStatusQuery = { - __typename?: 'Query'; - getBoltzSwapStatus: Array<{ - __typename?: 'BoltzSwap'; - id?: string | null; - boltz?: { - __typename?: 'BoltzSwapStatus'; - status: string; - transaction?: { - __typename?: 'BoltzSwapTransaction'; - id?: string | null; - hex?: string | null; - eta?: number | null; - } | null; - } | null; - }>; -}; - -export const GetBoltzSwapStatusDocument = gql` - query GetBoltzSwapStatus($ids: [String!]!) { - getBoltzSwapStatus(ids: $ids) { - id - boltz { - status - transaction { - id - hex - eta - } - } - } - } -`; - -/** - * __useGetBoltzSwapStatusQuery__ - * - * To run a query within a React component, call `useGetBoltzSwapStatusQuery` and pass it any options that fit your needs. - * When your component renders, `useGetBoltzSwapStatusQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useGetBoltzSwapStatusQuery({ - * variables: { - * ids: // value for 'ids' - * }, - * }); - */ -export function useGetBoltzSwapStatusQuery( - baseOptions: Apollo.QueryHookOptions< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - > & - ( - | { variables: GetBoltzSwapStatusQueryVariables; skip?: boolean } - | { skip: boolean } - ) -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useQuery< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - >(GetBoltzSwapStatusDocument, options); -} -export function useGetBoltzSwapStatusLazyQuery( - baseOptions?: Apollo.LazyQueryHookOptions< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - > -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useLazyQuery< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - >(GetBoltzSwapStatusDocument, options); -} -// @ts-ignore -export function useGetBoltzSwapStatusSuspenseQuery( - baseOptions?: Apollo.SuspenseQueryHookOptions< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - > -): Apollo.UseSuspenseQueryResult< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables ->; -export function useGetBoltzSwapStatusSuspenseQuery( - baseOptions?: - | Apollo.SkipToken - | Apollo.SuspenseQueryHookOptions< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - > -): Apollo.UseSuspenseQueryResult< - GetBoltzSwapStatusQuery | undefined, - GetBoltzSwapStatusQueryVariables ->; -export function useGetBoltzSwapStatusSuspenseQuery( - baseOptions?: - | Apollo.SkipToken - | Apollo.SuspenseQueryHookOptions< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - > -) { - const options = - baseOptions === Apollo.skipToken - ? baseOptions - : { ...defaultOptions, ...baseOptions }; - return Apollo.useSuspenseQuery< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables - >(GetBoltzSwapStatusDocument, options); -} -export type GetBoltzSwapStatusQueryHookResult = ReturnType< - typeof useGetBoltzSwapStatusQuery ->; -export type GetBoltzSwapStatusLazyQueryHookResult = ReturnType< - typeof useGetBoltzSwapStatusLazyQuery ->; -export type GetBoltzSwapStatusSuspenseQueryHookResult = ReturnType< - typeof useGetBoltzSwapStatusSuspenseQuery ->; -export type GetBoltzSwapStatusQueryResult = Apollo.QueryResult< - GetBoltzSwapStatusQuery, - GetBoltzSwapStatusQueryVariables ->; diff --git a/src/client/src/graphql/queries/getBoltzSwapStatus.ts b/src/client/src/graphql/queries/getBoltzSwapStatus.ts deleted file mode 100644 index 0a850f9b..00000000 --- a/src/client/src/graphql/queries/getBoltzSwapStatus.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { gql } from '@apollo/client'; - -export const GET_BOLTZ_SWAP_STATUS = gql` - query GetBoltzSwapStatus($ids: [String!]!) { - getBoltzSwapStatus(ids: $ids) { - id - boltz { - status - transaction { - id - hex - eta - } - } - } - } -`; diff --git a/src/client/src/graphql/types.ts b/src/client/src/graphql/types.ts index 07ca5ec8..3164fd50 100644 --- a/src/client/src/graphql/types.ts +++ b/src/client/src/graphql/types.ts @@ -119,25 +119,6 @@ export type BoltzInfoType = { min: Scalars['Float']['output']; }; -export type BoltzSwap = { - __typename?: 'BoltzSwap'; - boltz?: Maybe; - id?: Maybe; -}; - -export type BoltzSwapStatus = { - __typename?: 'BoltzSwapStatus'; - status: Scalars['String']['output']; - transaction?: Maybe; -}; - -export type BoltzSwapTransaction = { - __typename?: 'BoltzSwapTransaction'; - eta?: Maybe; - hex?: Maybe; - id?: Maybe; -}; - export type ChainAddressSend = { __typename?: 'ChainAddressSend'; confirmationCount: Scalars['Float']['output']; @@ -886,7 +867,6 @@ export type Policy = { export type Query = { __typename?: 'Query'; - decodeRequest: DecodeInvoice; getAccount: ServerAccount; getAmbossLoginToken: Scalars['String']['output']; getAmbossUser?: Maybe; @@ -894,7 +874,6 @@ export type Query = { getBitcoinFees: BitcoinFee; getBitcoinPrice: Scalars['String']['output']; getBoltzInfo: BoltzInfoType; - getBoltzSwapStatus: Array; getChainTransactions: Array; getChannel: SingleChannel; getChannelReport: ChannelReport; @@ -930,18 +909,10 @@ export type Query = { verifyMessage: Scalars['String']['output']; }; -export type QueryDecodeRequestArgs = { - request: Scalars['String']['input']; -}; - export type QueryGetAmbossLoginTokenArgs = { redirect_url?: InputMaybe; }; -export type QueryGetBoltzSwapStatusArgs = { - ids: Array; -}; - export type QueryGetChannelArgs = { id: Scalars['String']['input']; }; diff --git a/src/client/src/layouts/sidebar/RightSidebar.tsx b/src/client/src/layouts/sidebar/RightSidebar.tsx index 988981c5..c0e07eae 100644 --- a/src/client/src/layouts/sidebar/RightSidebar.tsx +++ b/src/client/src/layouts/sidebar/RightSidebar.tsx @@ -1,5 +1,6 @@ import { useConfigState } from '../../context/ConfigContext'; import { BalancesContent } from './BalancesContent'; +import { SidebarSwap } from './SidebarSwap'; import { EventLog } from './EventLog'; export const RightSidebar = () => { @@ -12,6 +13,7 @@ export const RightSidebar = () => {
+
diff --git a/src/client/src/layouts/sidebar/SidebarSwap.tsx b/src/client/src/layouts/sidebar/SidebarSwap.tsx new file mode 100644 index 00000000..8ff62b1c --- /dev/null +++ b/src/client/src/layouts/sidebar/SidebarSwap.tsx @@ -0,0 +1,311 @@ +import { useCallback, useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; +import { + Loader2, + Edit2, + X, + ChevronRight, + Zap, + Shuffle, + Check, + AlertTriangle, +} from 'lucide-react'; +import { useGetBoltzInfoQuery } from '../../graphql/queries/__generated__/getBoltzInfo.generated'; +import { useCreateBoltzReverseSwapMutation } from '../../graphql/mutations/__generated__/createBoltzReverseSwap.generated'; +import { usePayMutation } from '../../graphql/mutations/__generated__/pay.generated'; +import { getErrorContent } from '../../utils/error'; +import { + useBoltzSwapActions, + useBoltzSwapById, +} from '../../context/BoltzSwapContext'; +import { boltzStatusLabel } from '../../views/swap/boltzStatus'; +import { + SwapProgressStepper, + getSwapStep, +} from '../../views/swap/SwapProgress'; +import { Price } from '../../components/price/Price'; +import { Slider } from '../../components/slider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; + +// --- State machine for auto-pay flow --- + +type SwapStep = 'idle' | 'quote' | 'paying' | 'claiming' | 'done' | 'error'; + +const SwapWidget = ({ max, min }: { max: number; min: number }) => { + const actions = useBoltzSwapActions(); + + const [amount, setAmount] = useState(min); + const [isEdit, setIsEdit] = useState(false); + const [quickPay, setQuickPay] = useState(true); + const [step, setStep] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(); + const [pendingSwapId, setPendingSwapId] = useState(null); + + const pendingSwap = useBoltzSwapById(pendingSwapId); + + // Derive effective step from global swap state when we have a pending swap + const derivedStep = pendingSwap ? getSwapStep(pendingSwap) : null; + const effectiveStep: SwapStep = + step === 'idle' || step === 'error' || !derivedStep ? step : derivedStep; + + // Watch for terminal states from global context + const claimState = pendingSwap?.claimState; + const boltzStatus = pendingSwap?.liveStatus?.status ?? null; + + useEffect(() => { + if (pendingSwap && claimState === 'claimed' && step !== 'idle') { + setStep('idle'); + setPendingSwapId(null); + } + if ( + pendingSwap && + claimState === 'failed' && + step !== 'idle' && + step !== 'error' + ) { + setStep('error'); + setErrorMsg('Auto-claim failed'); + setPendingSwapId(null); + } + }, [pendingSwap, claimState, step]); + + const resetFlow = useCallback(() => { + setStep('idle'); + setErrorMsg(undefined); + setPendingSwapId(null); + }, []); + + const [pay] = usePayMutation({ + onError: error => { + setStep('error'); + setErrorMsg('Payment failed'); + toast.error(getErrorContent(error)); + setPendingSwapId(null); + }, + }); + + const [getQuote] = useCreateBoltzReverseSwapMutation({ + onError: error => { + setStep('error'); + setErrorMsg('Quote failed'); + toast.error(getErrorContent(error)); + }, + onCompleted: result => { + const swap = result.createBoltzReverseSwap; + actions.addSwap(swap); + + if (quickPay && swap.invoice) { + setPendingSwapId(swap.id); + setStep('paying'); + + const maxFee = Math.min( + 10000, + Math.max(100, Math.round(amount * 0.005)) + ); + pay({ + variables: { + max_fee: maxFee, + max_paths: 10, + request: swap.invoice, + }, + }); + } else { + actions.openSwap(swap.id); + } + }, + }); + + const handleSwap = () => { + setStep('quote'); + setErrorMsg(undefined); + getQuote({ variables: { amount } }); + }; + + const isActive = + effectiveStep !== 'idle' && + effectiveStep !== 'done' && + effectiveStep !== 'error'; + + // Build status description from Boltz WS or fallback to step + const statusText = (() => { + if (effectiveStep === 'done') return 'Swap complete!'; + const live = boltzStatusLabel(boltzStatus); + if (live) return live; + if (effectiveStep === 'quote') return 'Creating swap...'; + if (effectiveStep === 'paying') return 'Paying invoice...'; + if (effectiveStep === 'claiming') return 'Claiming to on-chain...'; + return ''; + })(); + + return ( +
+ {effectiveStep === 'idle' || effectiveStep === 'error' ? ( + <> +
+ + Amount + + + + +
+ +
+ {isEdit ? ( + setAmount(Number(e.target.value))} + /> + ) : ( +
+ +
+ )} + +
+ + + + {effectiveStep === 'error' && errorMsg && ( +
+ + {errorMsg} + +
+ )} + + + + ) : ( + <> + + +
+ + {statusText} + + + + +
+ + {effectiveStep === 'done' && ( +
+ + Funds sent to your Bitcoin address +
+ )} + + {isActive && ( +
+
+
+ )} + + )} +
+ ); +}; + +// --- Outer wrapper --- + +export const SidebarSwap = () => { + const { data, loading, error } = useGetBoltzInfoQuery({ + onError: error => toast.error(getErrorContent(error)), + }); + + if (loading) { + return ( +
+
+ + + Quick Swap + +
+
+ +
+
+ ); + } + + if (error || !data?.getBoltzInfo) { + return null; + } + + const { max, min, feePercent } = data.getBoltzInfo; + + return ( +
+
+
+ + + Quick Swap + +
+ + {feePercent}% fee + +
+ + + + +
+ ); +}; diff --git a/src/client/src/views/home/account/pay/Pay.tsx b/src/client/src/views/home/account/pay/Pay.tsx index 8613ab83..ee4b745a 100644 --- a/src/client/src/views/home/account/pay/Pay.tsx +++ b/src/client/src/views/home/account/pay/Pay.tsx @@ -13,6 +13,8 @@ import { decode } from 'light-bolt11-decoder'; interface PayProps { predefinedRequest?: string; payCallback?: () => void; + defaultFee?: number; + defaultPaths?: number; } const getDecodedInvoice = (invoice: string | undefined | null) => { @@ -68,11 +70,16 @@ const DecodeInvoice: FC<{ invoice: string | undefined | null }> = ({ ); }; -export const Pay: FC = ({ predefinedRequest, payCallback }) => { +export const Pay: FC = ({ + predefinedRequest, + payCallback, + defaultFee = 10, + defaultPaths = 10, +}) => { const [request, setRequest] = useState(predefinedRequest || ''); const [peers, setPeers] = useState([]); - const [fee, setFee] = useState(10); - const [paths, setPaths] = useState(1); + const [fee, setFee] = useState(defaultFee); + const [paths, setPaths] = useState(defaultPaths); const [confirming, setConfirming] = useState(false); const [pay, { loading }] = usePayMutation({ @@ -165,6 +172,7 @@ export const Pay: FC = ({ predefinedRequest, payCallback }) => { disabled={loading || !request} className="w-full" onClick={() => setConfirming(true)} + autoFocus > Pay diff --git a/src/client/src/views/swap/StartSwap.tsx b/src/client/src/views/swap/StartSwap.tsx index 54476d7b..daf7dbd6 100644 --- a/src/client/src/views/swap/StartSwap.tsx +++ b/src/client/src/views/swap/StartSwap.tsx @@ -9,7 +9,7 @@ import toast from 'react-hot-toast'; import { Price } from '../../components/price/Price'; import { getErrorContent } from '../../utils/error'; import { useMutationResultWithReset } from '../../hooks/UseMutationWithReset'; -import { useSwapsDispatch } from './SwapContext'; +import { useBoltzSwapActions } from '../../context/BoltzSwapContext'; type StartSwapProps = { max: number; @@ -22,7 +22,7 @@ export const StartSwap = ({ max, min }: StartSwapProps) => { const [isEdit, setIsEdit] = useState(false); const [address, setAddress] = useState(); - const dispatch = useSwapsDispatch(); + const actions = useBoltzSwapActions(); const [getQuote, { data: _data, loading }] = useCreateBoltzReverseSwapMutation({ @@ -32,13 +32,11 @@ export const StartSwap = ({ max, min }: StartSwapProps) => { useEffect(() => { if (!data?.createBoltzReverseSwap) return; - dispatch({ - type: 'add', - swap: data.createBoltzReverseSwap, - }); - + const swap = data.createBoltzReverseSwap; + actions.addSwap(swap); + actions.openSwap(swap.id); resetMutation(); - }, [data, dispatch, resetMutation]); + }, [data, actions, resetMutation]); return (
diff --git a/src/client/src/views/swap/SwapClaim.tsx b/src/client/src/views/swap/SwapClaim.tsx index adefa046..7e20410b 100644 --- a/src/client/src/views/swap/SwapClaim.tsx +++ b/src/client/src/views/swap/SwapClaim.tsx @@ -10,7 +10,11 @@ import { useConfigState } from '../../context/ConfigContext'; import { useClaimBoltzTransactionMutation } from '../../graphql/mutations/__generated__/claimBoltzTransaction.generated'; import { useBitcoinFees } from '../../hooks/UseBitcoinFees'; import { getErrorContent } from '../../utils/error'; -import { useSwapsDispatch, useSwapsState } from './SwapContext'; +import { + useBoltzSwaps, + useBoltzSwapById, + useBoltzSwapActions, +} from '../../context/BoltzSwapContext'; import { MEMPOOL } from './SwapStatus'; export const SwapClaim = () => { @@ -52,23 +56,19 @@ export const SwapClaim = () => { return options; })(); - const { swaps, claim, claimType } = useSwapsState(); - const dispatch = useSwapsDispatch(); + const { claimSwapId, claimType } = useBoltzSwaps(); + const claimingSwap = useBoltzSwapById(claimSwapId); + const actions = useBoltzSwapActions(); - const [claimTransaction, { data, loading }] = - useClaimBoltzTransactionMutation({ - onError: error => toast.error(getErrorContent(error)), - }); - - useEffect(() => { - if (!data?.claimBoltzTransaction || typeof claim !== 'number') return; - dispatch({ - type: 'complete', - index: claim, - transactionId: data.claimBoltzTransaction, - }); - toast.success('Transaction Claimed'); - }, [data, dispatch, claim]); + const [claimTransaction, { loading }] = useClaimBoltzTransactionMutation({ + onError: error => toast.error(getErrorContent(error)), + onCompleted: data => { + if (data?.claimBoltzTransaction && claimSwapId) { + actions.completeSwap(claimSwapId, data.claimBoltzTransaction); + toast.success('Transaction Claimed'); + } + }, + }); const Missing = () => (
@@ -77,11 +77,10 @@ export const SwapClaim = () => {
); - if (typeof claim !== 'number') { + if (!claimSwapId || !claimingSwap) { return ; } - const claimingSwap = swaps[claim]; const { redeemScript, preimage, @@ -147,6 +146,7 @@ export const SwapClaim = () => { Fee Amount + {/* 111 vbytes ≈ typical claim tx size */}
diff --git a/src/client/src/views/swap/SwapContext.tsx b/src/client/src/views/swap/SwapContext.tsx deleted file mode 100644 index 781d19cc..00000000 --- a/src/client/src/views/swap/SwapContext.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { - FC, - createContext, - useContext, - useReducer, - useEffect, - ReactNode, -} from 'react'; -import toast from 'react-hot-toast'; -import { CreateBoltzReverseSwap } from './types'; - -type State = { - swaps: CreateBoltzReverseSwap[]; - open: number | null; - claim: number | null; - claimType: string | null; -}; - -type ActionType = - | { - type: 'add'; - swap: CreateBoltzReverseSwap; - } - | { type: 'init'; swaps: CreateBoltzReverseSwap[] } - | { type: 'open'; open: number } - | { - type: 'claim'; - claim: number; - claimType: string; - } - | { type: 'cleanup'; swaps: CreateBoltzReverseSwap[] } - | { type: 'complete'; index: number; transactionId: string } - | { type: 'close' }; - -type Dispatch = (action: ActionType) => void; - -export const StateContext = createContext(undefined); -export const DispatchContext = createContext(undefined); - -const initialState: State = { - swaps: [], - open: null, - claim: null, - claimType: null, -}; - -const stateReducer = (state: State, action: ActionType): State => { - switch (action.type) { - case 'init': - return { ...state, swaps: action.swaps }; - case 'add': - localStorage.setItem( - 'swaps', - JSON.stringify([...state.swaps, action.swap]) - ); - return { ...state, swaps: [...state.swaps, action.swap] }; - case 'open': - return { ...state, open: action.open }; - case 'claim': - return { - ...state, - claim: action.claim, - claimType: action.claimType, - }; - case 'complete': { - state.swaps[action.index].claimTransaction = action.transactionId; - localStorage.setItem('swaps', JSON.stringify(state.swaps)); - return { ...state, open: null, claim: null }; - } - case 'cleanup': - localStorage.setItem('swaps', JSON.stringify(action.swaps)); - return { ...state, swaps: action.swaps }; - case 'close': - return { ...state, open: null, claim: null }; - default: - return state; - } -}; - -const SwapsProvider: FC<{ children?: ReactNode }> = ({ children }) => { - const [state, dispatch] = useReducer(stateReducer, initialState); - - useEffect(() => { - try { - const swaps = JSON.parse(localStorage.getItem('swaps') || '[]'); - dispatch({ type: 'init', swaps }); - } catch { - toast.error('Invalid swaps stored in browser'); - } - }, []); - - return ( - - {children} - - ); -}; - -const useSwapsState = () => { - const context = useContext(StateContext); - if (context === undefined) { - throw new Error('useSwapsState must be used within a SwapsProvider'); - } - return context; -}; - -const useSwapsDispatch = () => { - const context = useContext(DispatchContext); - if (context === undefined) { - throw new Error('useSwapsDispatch must be used within a SwapsProvider'); - } - return context; -}; - -export { SwapsProvider, useSwapsState, useSwapsDispatch }; diff --git a/src/client/src/views/swap/SwapExpire.tsx b/src/client/src/views/swap/SwapExpire.tsx index c72295c8..31503d6c 100644 --- a/src/client/src/views/swap/SwapExpire.tsx +++ b/src/client/src/views/swap/SwapExpire.tsx @@ -1,20 +1,27 @@ import { useState, useEffect } from 'react'; -import { formatDistanceToNowStrict } from 'date-fns'; export const useSwapExpire = (date?: string) => { - const [, setCount] = useState(0); + const [now, setNow] = useState(() => Date.now()); useEffect(() => { - const myInterval = setInterval(() => { - setCount(p => p + 1); - }, 1000); - return () => { - clearInterval(myInterval); - }; - }); + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, []); if (!date) return ''; - return `(Expires in ${formatDistanceToNowStrict(new Date(date), { - unit: 'second', - })})`; + + const diff = Math.max(0, Math.floor((new Date(date).getTime() - now) / 1000)); + + if (diff <= 0) return '(Expired)'; + + const hours = Math.floor(diff / 3600); + const mins = Math.floor((diff % 3600) / 60); + const secs = diff % 60; + + const time = + hours > 0 + ? `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + : `${mins}:${secs.toString().padStart(2, '0')}`; + + return `(Expires in ${time})`; }; diff --git a/src/client/src/views/swap/SwapProgress.tsx b/src/client/src/views/swap/SwapProgress.tsx new file mode 100644 index 00000000..b2614316 --- /dev/null +++ b/src/client/src/views/swap/SwapProgress.tsx @@ -0,0 +1,136 @@ +import { Loader2, Check, CircleDot, AlertTriangle } from 'lucide-react'; +import type { SwapEntry } from '../../context/BoltzSwapContext'; +import { BOLTZ_STATUS, isFailedStatus } from './boltzStatus'; + +export type SwapStep = 'quote' | 'paying' | 'claiming' | 'done' | 'error'; + +/** Derive a progress step from the swap's live status and claim state. */ +export const getSwapStep = (swap: SwapEntry): SwapStep => { + const status = swap.liveStatus?.status ?? null; + const { claimState } = swap; + + if (claimState === 'claimed' || status === BOLTZ_STATUS.TX_CLAIMED) { + return 'done'; + } + if (claimState === 'failed' || isFailedStatus(status)) { + return 'error'; + } + if (claimState === 'claiming') { + return 'claiming'; + } + if ( + status === BOLTZ_STATUS.TX_MEMPOOL || + status === BOLTZ_STATUS.TX_CONFIRMED || + status === BOLTZ_STATUS.INVOICE_SETTLED + ) { + return 'claiming'; + } + if ( + status === BOLTZ_STATUS.INVOICE_PENDING || + status === BOLTZ_STATUS.INVOICE_PAID || + status === BOLTZ_STATUS.INVOICE_SET + ) { + return 'paying'; + } + if (status === BOLTZ_STATUS.SWAP_CREATED || !status) { + return 'quote'; + } + return 'paying'; +}; + +const steps: { key: SwapStep; label: string }[] = [ + { key: 'quote', label: 'Quote' }, + { key: 'paying', label: 'Pay' }, + { key: 'claiming', label: 'Claim' }, + { key: 'done', label: 'Done' }, +]; + +const stepIndex = (step: SwapStep) => steps.findIndex(s => s.key === step); + +export const SwapProgressStepper = ({ + currentStep, + error, +}: { + currentStep: SwapStep; + error?: string; +}) => { + const currentIdx = stepIndex(currentStep); + + // Each step is flex-1, so circle centers are at 12.5%, 37.5%, 62.5%, 87.5%. + // Lines connect between circle edges: offset by half the circle width (10px) from each center. + const segmentCount = steps.length - 1; + + return ( +
+ {/* Connector lines — positioned between circle edges */} +
+ {Array.from({ length: segmentCount }).map((_, i) => { + const startPct = ((2 * i + 1) / (2 * steps.length)) * 100; + const endPct = ((2 * i + 3) / (2 * steps.length)) * 100; + // 10px = half of h-5 circle + return ( +
i ? 'bg-emerald-500/40' : 'bg-border' + }`} + style={{ + left: `calc(${startPct}% + 10px)`, + right: `calc(${100 - endPct}% + 10px)`, + }} + /> + ); + })} +
+ {/* Step circles + labels */} +
+ {steps.map((step, i) => { + const isComplete = currentIdx > i; + const isActive = currentIdx === i; + const isError = currentStep === 'error' && i === currentIdx; + + return ( +
+
+ {isError ? ( + + ) : isComplete ? ( + + ) : isActive ? ( + + ) : ( + + )} +
+ + {step.label} + +
+ ); + })} +
+ {error && ( +
{error}
+ )} +
+ ); +}; diff --git a/src/client/src/views/swap/SwapQuote.tsx b/src/client/src/views/swap/SwapQuote.tsx index 8aa74b21..853f23c4 100644 --- a/src/client/src/views/swap/SwapQuote.tsx +++ b/src/client/src/views/swap/SwapQuote.tsx @@ -6,20 +6,22 @@ import { import { Separator } from '@/components/ui/separator'; import { Price } from '../../components/price/Price'; import { Pay } from '../home/account/pay/Pay'; -import { useSwapsDispatch, useSwapsState } from './SwapContext'; +import { + useBoltzSwaps, + useBoltzSwapById, +} from '../../context/BoltzSwapContext'; +import { SwapProgressStepper, getSwapStep } from './SwapProgress'; import { Info, ArrowDown } from 'lucide-react'; export const SwapQuote = () => { - const { swaps, open } = useSwapsState(); - const dispatch = useSwapsDispatch(); + const { openSwapId } = useBoltzSwaps(); + const openSwap = useBoltzSwapById(openSwapId); - if (typeof open !== 'number') { + if (!openSwapId || !openSwap) { return null; } - const openSwap = swaps[open]; - - if (!openSwap?.decodedInvoice) { + if (!openSwap.decodedInvoice) { return (
@@ -30,9 +32,12 @@ export const SwapQuote = () => { const { decodedInvoice, onchainAmount, receivingAddress, invoice } = openSwap; - const handlePaid = () => { - dispatch({ type: 'close' }); - }; + const maxFee = Math.min( + 10000, + Math.max(100, Math.round(decodedInvoice.tokens * 0.005)) + ); + + const step = getSwapStep(openSwap); return (
@@ -43,6 +48,8 @@ export const SwapQuote = () => {

+ +
{renderLine( 'Sending to', @@ -93,12 +100,11 @@ export const SwapQuote = () => {

Pay Invoice

- -
- -
- It is ok to close this modal after 5 seconds of having paid even if it - still shows as loading. +
); diff --git a/src/client/src/views/swap/SwapStatus.tsx b/src/client/src/views/swap/SwapStatus.tsx index 5793fad9..aa525f70 100644 --- a/src/client/src/views/swap/SwapStatus.tsx +++ b/src/client/src/views/swap/SwapStatus.tsx @@ -1,6 +1,5 @@ -import { Fragment, useEffect, useState } from 'react'; +import { Fragment } from 'react'; import { - RefreshCw, Trash, ChevronRight, Clock, @@ -8,18 +7,21 @@ import { AlertTriangle, XCircle, } from 'lucide-react'; -import { Tooltip as ReactTooltip } from 'react-tooltip'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { getAddressLink } from '../../components/generic/helpers'; -import Modal from '../../components/modal/ReactModal'; -import { useGetBoltzSwapStatusQuery } from '../../graphql/queries/__generated__/getBoltzSwapStatus.generated'; -import { SwapClaim } from './SwapClaim'; -import { useSwapsDispatch, useSwapsState } from './SwapContext'; import { useSwapExpire } from './SwapExpire'; -import { SwapQuote } from './SwapQuote'; -import { EnrichedSwap } from './types'; +import { + useBoltzSwaps, + useBoltzSwapActions, + SwapEntry, +} from '../../context/BoltzSwapContext'; const CREATED = 'swap.created'; export const MEMPOOL = 'transaction.mempool'; @@ -69,37 +71,37 @@ const RowAction = ({ label }: { label: string }) => (
); -const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => { - const dispatch = useSwapsDispatch(); +const ReadyRow = ({ swap }: { swap: SwapEntry }) => { + const actions = useBoltzSwapActions(); + const time = useSwapExpire(swap.decodedInvoice?.expires_at); - const ReadyComponent = () => { - const time = useSwapExpire(swap.decodedInvoice?.expires_at); - return ( - - ); - }; + + Ready to Pay + +
+ + + ); +}; + +const SwapRow = ({ swap }: { swap: SwapEntry }) => { + const actions = useBoltzSwapActions(); + const status = swap.liveStatus?.status; if (!swap?.id) return null; - if (!swap.boltz?.status) { + if (!status) { return (
@@ -114,7 +116,7 @@ const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => { ); } - switch (swap.boltz.status) { + switch (status) { case INVOICE_EXPIRED: case EXPIRED: return ( @@ -143,14 +145,12 @@ const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => {
); case CREATED: - return ; + return ; case MEMPOOL: return (
); @@ -236,49 +232,8 @@ const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => { }; export const SwapStatus = () => { - const { swaps, open, claim } = useSwapsState(); - const dispatch = useSwapsDispatch(); - - const [enriched, setEnriched] = useState([]); - - const { data, refetch, networkStatus } = useGetBoltzSwapStatusQuery({ - notifyOnNetworkStatusChange: true, - variables: { ids: swaps.map((s: { id: string }) => s.id).filter(Boolean) }, - fetchPolicy: 'network-only', - skip: !swaps.length, - }); - - const loading = [1, 2, 3, 4, 6].includes(networkStatus); - - useEffect(() => { - if (loading || !data?.getBoltzSwapStatus) return; - - const swapsWithState: EnrichedSwap[] = swaps.map(swap => { - const status = data.getBoltzSwapStatus.find(s => s?.id === swap.id); - const enriched = { ...swap, boltz: status?.boltz }; - return enriched; - }); - - setEnriched(swapsWithState); - }, [data, loading, swaps]); - - const handleCleanup = () => { - const cleaned = enriched.filter(s => { - if (!s.boltz?.status) return true; - const status = s.boltz.status; - if ( - status === SETTLED || - status === REFUNDED || - status === EXPIRED || - status === INVOICE_EXPIRED - ) { - return false; - } - return true; - }); - - dispatch({ type: 'cleanup', swaps: cleaned }); - }; + const { swaps } = useBoltzSwaps(); + const actions = useBoltzSwapActions(); return ( <> @@ -293,53 +248,37 @@ export const SwapStatus = () => { )} {swaps.length > 0 && ( -
- -
+ + -
-
+ + + Cleanup expired, refunded and completed swaps. + + )}
- {loading && ( -

- Loading swap statuses... -

- )} - - {!loading && (!swaps.length || !data?.getBoltzSwapStatus) && ( + {!swaps.length && (

No swaps yet. Create one above to get started.

)} - {!loading && enriched.length > 0 && ( + {swaps.length > 0 && (
- {enriched.map((swap, index) => ( - - + {swaps.map(swap => ( + + ))}
@@ -347,17 +286,6 @@ export const SwapStatus = () => {
- - - Cleanup expired, refunded and completed swaps. - - - dispatch({ type: 'close' })} - > - {typeof open === 'number' ? : } - ); }; diff --git a/src/client/src/views/swap/boltzStatus.ts b/src/client/src/views/swap/boltzStatus.ts new file mode 100644 index 00000000..58852765 --- /dev/null +++ b/src/client/src/views/swap/boltzStatus.ts @@ -0,0 +1,51 @@ +export const BOLTZ_STATUS = { + SWAP_CREATED: 'swap.created', + INVOICE_SET: 'invoice.set', + INVOICE_PENDING: 'invoice.pending', + INVOICE_PAID: 'invoice.paid', + INVOICE_SETTLED: 'invoice.settled', + INVOICE_EXPIRED: 'invoice.expired', + INVOICE_FAILED: 'invoice.failedToPay', + TX_MEMPOOL: 'transaction.mempool', + TX_CONFIRMED: 'transaction.confirmed', + TX_CLAIMED: 'transaction.claimed', + TX_REFUNDED: 'transaction.refunded', + SWAP_EXPIRED: 'swap.expired', +} as const; + +const statusLabels: Record = { + [BOLTZ_STATUS.SWAP_CREATED]: 'Swap created', + [BOLTZ_STATUS.INVOICE_SET]: 'Invoice set', + [BOLTZ_STATUS.INVOICE_PENDING]: 'Invoice pending...', + [BOLTZ_STATUS.INVOICE_PAID]: 'Invoice paid', + [BOLTZ_STATUS.INVOICE_SETTLED]: 'Invoice settled', + [BOLTZ_STATUS.TX_MEMPOOL]: 'Transaction in mempool', + [BOLTZ_STATUS.TX_CONFIRMED]: 'Transaction confirmed', + [BOLTZ_STATUS.TX_CLAIMED]: 'Transaction claimed', + [BOLTZ_STATUS.TX_REFUNDED]: 'Transaction refunded', + [BOLTZ_STATUS.SWAP_EXPIRED]: 'Swap expired', + [BOLTZ_STATUS.INVOICE_EXPIRED]: 'Swap expired', + [BOLTZ_STATUS.INVOICE_FAILED]: 'Failed to pay', +}; + +export const boltzStatusLabel = (status: string | null): string | null => { + if (!status) return null; + return statusLabels[status] ?? null; +}; + +const failedStatuses: Set = new Set([ + BOLTZ_STATUS.SWAP_EXPIRED, + BOLTZ_STATUS.INVOICE_EXPIRED, + BOLTZ_STATUS.INVOICE_FAILED, +]); + +export const isFailedStatus = (status: string | null) => + !!status && failedStatuses.has(status); + +const claimableStatuses: Set = new Set([ + BOLTZ_STATUS.TX_MEMPOOL, + BOLTZ_STATUS.TX_CONFIRMED, +]); + +export const isClaimableStatus = (status: string | null) => + !!status && claimableStatuses.has(status); diff --git a/src/client/src/views/swap/index.tsx b/src/client/src/views/swap/index.tsx index 455e6bec..4b639a92 100644 --- a/src/client/src/views/swap/index.tsx +++ b/src/client/src/views/swap/index.tsx @@ -3,7 +3,6 @@ import { LoadingCard } from '../../components/loading/LoadingCard'; import { Price } from '../../components/price/Price'; import { useGetBoltzInfoQuery } from '../../graphql/queries/__generated__/getBoltzInfo.generated'; import { getErrorContent } from '../../utils/error'; -import { SwapsProvider } from './SwapContext'; import { StartSwap } from './StartSwap'; import { SwapStatus } from './SwapStatus'; import { Info, Zap } from 'lucide-react'; @@ -32,40 +31,35 @@ export const SwapView = () => { const { max, min, feePercent } = data.getBoltzInfo; return ( - -
-
-

Reverse Swap

+
+
+

Reverse Swap

-
- - Fee {feePercent}% +
+ + Fee {feePercent}% + + + Min + + + Max + + + + + Boltz - - Min - - - Max - - - - - Boltz - - -
+
- - - - - - -
- + + + + + + + +
); }; diff --git a/src/client/src/views/swap/types.ts b/src/client/src/views/swap/types.ts index 00c39b46..77bfe695 100644 --- a/src/client/src/views/swap/types.ts +++ b/src/client/src/views/swap/types.ts @@ -1,8 +1,4 @@ -import { - BoltzSwapStatus, - CreateBoltzReverseSwapType, - DecodeInvoice, -} from '../../graphql/types'; +import { CreateBoltzReverseSwapType, DecodeInvoice } from '../../graphql/types'; export type CreateBoltzReverseSwap = Pick< CreateBoltzReverseSwapType, @@ -28,7 +24,3 @@ export type CreateBoltzReverseSwap = Pick< | 'destination_node' > | null; } & { claimTransaction?: string }; - -export type EnrichedSwap = { - boltz?: Pick | null; -} & CreateBoltzReverseSwap; diff --git a/src/server/modules/api/boltz/boltz.resolver.ts b/src/server/modules/api/boltz/boltz.resolver.ts index b5cf0aec..a7a77f40 100644 --- a/src/server/modules/api/boltz/boltz.resolver.ts +++ b/src/server/modules/api/boltz/boltz.resolver.ts @@ -30,7 +30,6 @@ import { GraphQLError } from 'graphql'; import { address, initEccLib, networks, Transaction } from 'bitcoinjs-lib'; import { BoltzInfoType, - BoltzSwap, BroadcastAuto, BroadcastResult, CreateBoltzReverseSwapType, @@ -42,7 +41,7 @@ import { UserId } from '../../security/security.types'; import { toWithError } from 'src/server/utils/async'; import { ECPairAPI, ECPairFactory } from 'ecpair'; import * as ecc from 'tiny-secp256k1'; -import { auto, mapSeries } from 'async'; +import { auto } from 'async'; import { MempoolService } from '../../mempool/mempool.service'; import { BlockstreamService } from '../../blockstream/blockstream.service'; @@ -105,35 +104,6 @@ export class BoltzResolver { return { max, min, feePercent }; } - @Query(() => [BoltzSwap]) - async getBoltzSwapStatus( - @Args('ids', { type: () => [String] }) ids: string[] - ) { - return mapSeries(ids, async (id: string) => { - const [info, error] = await toWithError( - this.boltzService.getSwapStatus(id) - ); - - if (error || isBoltzError(info)) { - this.logger.error(`Error getting status for swap with id: ${id}`, { - error, - boltzError: info, - }); - return { id }; - } - - if (!info.status) { - this.logger.debug( - `No status in Boltz response for swap with id: ${id}`, - { info } - ); - return { id }; - } - - return { id, boltz: info }; - }); - } - @Mutation(() => String) async claimBoltzTransaction( @Args('id') id: string, diff --git a/src/server/modules/api/boltz/boltz.service.ts b/src/server/modules/api/boltz/boltz.service.ts index 3912c06c..ba1f920e 100644 --- a/src/server/modules/api/boltz/boltz.service.ts +++ b/src/server/modules/api/boltz/boltz.service.ts @@ -8,7 +8,6 @@ import { BroadcastTransaction, CreateReverseSwap, ReverseSwapPair, - SwapStatus, } from './boltz.types'; @Injectable() @@ -37,15 +36,6 @@ export class BoltzService { ); } - async getSwapStatus(id: string) { - return wrapFetch( - this.fetchService.fetchWithProxy( - `${this.configService.get('urls.boltz')}/v2/swap/${id}` - ), - 10_000 - ); - } - async createReverseSwap( invoiceAmount: number, preimageHash: string, diff --git a/src/server/modules/api/boltz/boltz.types.ts b/src/server/modules/api/boltz/boltz.types.ts index 477d2623..f19c77ba 100644 --- a/src/server/modules/api/boltz/boltz.types.ts +++ b/src/server/modules/api/boltz/boltz.types.ts @@ -11,32 +11,6 @@ export class BoltzInfoType { feePercent: number; } -@ObjectType() -export class BoltzSwapTransaction { - @Field({ nullable: true }) - id: string; - @Field({ nullable: true }) - hex: string; - @Field({ nullable: true }) - eta: number; -} - -@ObjectType() -export class BoltzSwapStatus { - @Field() - status: string; - @Field(() => BoltzSwapTransaction, { nullable: true }) - transaction: BoltzSwapTransaction; -} - -@ObjectType() -export class BoltzSwap { - @Field({ nullable: true }) - id: string; - @Field(() => BoltzSwapStatus, { nullable: true }) - boltz: BoltzSwapStatus; -} - @ObjectType() export class CreateBoltzReverseSwapType { @Field() @@ -110,14 +84,6 @@ export type ReverseSwapPair = } | BoltzError; -export type SwapStatus = - | { - status: string; - zeroConfRejected: true; - transaction: { id: string; hex: string }; - } - | BoltzError; - export const isBoltzError = (obj: unknown): obj is BoltzError => { return !!(obj as BoltzError).error; };