feat: rework boltz swaps

This commit is contained in:
Anthony Potdevin 2026-03-15 23:36:03 -06:00
parent eeca15378a
commit efcb28d587
No known key found for this signature in database
GPG key ID: 4403F1DFBE779457
22 changed files with 1277 additions and 623 deletions

View file

@ -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!

View file

@ -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<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | 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<BoltzSwapContextType | undefined>(
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<WSManager | null>(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<string>());
const claimTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
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 (
<BoltzSwapContext.Provider value={ctx}>
{children}
<BoltzSwapDialog state={state} dispatch={dispatch} />
</BoltzSwapContext.Provider>
);
};
// --- Global Dialog ---
const BoltzSwapDialog: FC<{
state: State;
dispatch: (action: Action) => void;
}> = ({ state, dispatch }) => {
const { openSwapId, claimSwapId } = state;
const isOpen = !!openSwapId || !!claimSwapId;
return (
<Modal isOpen={isOpen} closeCallback={() => dispatch({ type: 'close' })}>
{openSwapId ? <SwapQuote /> : <SwapClaim />}
</Modal>
);
};
// --- 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]
);
};

View file

@ -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 }) => (
<NotificationProvider>
<DashProvider>
<PriceProvider>{children}</PriceProvider>
<PriceProvider>
<BoltzSwapProvider>{children}</BoltzSwapProvider>
</PriceProvider>
</DashProvider>
</NotificationProvider>
);

View file

@ -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']>
| 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
>;

View file

@ -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
}
}
}
}
`;

View file

@ -119,25 +119,6 @@ export type BoltzInfoType = {
min: Scalars['Float']['output'];
};
export type BoltzSwap = {
__typename?: 'BoltzSwap';
boltz?: Maybe<BoltzSwapStatus>;
id?: Maybe<Scalars['String']['output']>;
};
export type BoltzSwapStatus = {
__typename?: 'BoltzSwapStatus';
status: Scalars['String']['output'];
transaction?: Maybe<BoltzSwapTransaction>;
};
export type BoltzSwapTransaction = {
__typename?: 'BoltzSwapTransaction';
eta?: Maybe<Scalars['Float']['output']>;
hex?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['String']['output']>;
};
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<AmbossUser>;
@ -894,7 +874,6 @@ export type Query = {
getBitcoinFees: BitcoinFee;
getBitcoinPrice: Scalars['String']['output'];
getBoltzInfo: BoltzInfoType;
getBoltzSwapStatus: Array<BoltzSwap>;
getChainTransactions: Array<ChainTransaction>;
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<Scalars['String']['input']>;
};
export type QueryGetBoltzSwapStatusArgs = {
ids: Array<Scalars['String']['input']>;
};
export type QueryGetChannelArgs = {
id: Scalars['String']['input'];
};

View file

@ -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 = () => {
<div className="sticky top-[77px] flex flex-col max-h-[calc(100vh-77px)]">
<div className="w-[320px] shrink-0">
<BalancesContent />
<SidebarSwap />
</div>
<div className="flex-1 min-h-0 w-[320px] flex flex-col">
<EventLog />

View file

@ -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<number>(min);
const [isEdit, setIsEdit] = useState(false);
const [quickPay, setQuickPay] = useState(true);
const [step, setStep] = useState<SwapStep>('idle');
const [errorMsg, setErrorMsg] = useState<string>();
const [pendingSwapId, setPendingSwapId] = useState<string | null>(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 (
<div className="space-y-2.5">
{effectiveStep === 'idle' || effectiveStep === 'error' ? (
<>
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
Amount
</span>
<span className="text-xs font-medium tabular-nums">
<Price amount={amount} />
</span>
</div>
<div className="flex items-center gap-1.5">
{isEdit ? (
<Input
className="flex-1 h-7 text-xs"
value={amount}
type="number"
placeholder="Satoshis"
onChange={e => setAmount(Number(e.target.value))}
/>
) : (
<div className="flex-1">
<Slider
value={amount}
max={max}
min={min}
onChange={setAmount}
/>
</div>
)}
<Button
variant={isEdit ? 'default' : 'outline'}
size="icon"
className="shrink-0 h-7 w-7"
onClick={() => setIsEdit(p => !p)}
>
{isEdit ? <X size={12} /> : <Edit2 size={12} />}
</Button>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={quickPay}
onCheckedChange={v => setQuickPay(v === true)}
className="h-3.5 w-3.5"
/>
<span className="text-[11px] text-muted-foreground select-none">
Auto pay invoice
</span>
</label>
{effectiveStep === 'error' && errorMsg && (
<div className="flex items-center gap-1.5 text-[11px] text-red-500">
<AlertTriangle size={11} />
<span>{errorMsg}</span>
<button
className="ml-auto text-muted-foreground hover:text-foreground underline"
onClick={resetFlow}
>
Dismiss
</button>
</div>
)}
<Button
variant={'secondary'}
disabled={!amount}
onClick={handleSwap}
className="w-full h-8 text-xs"
size="sm"
>
<Zap size={12} className="mr-1" />
{quickPay ? 'Swap & Pay' : 'Get Quote'}
<ChevronRight size={14} className="ml-1" />
</Button>
</>
) : (
<>
<SwapProgressStepper currentStep={effectiveStep} error={errorMsg} />
<div className="flex items-center justify-between pt-1">
<span className="text-[11px] text-muted-foreground">
{statusText}
</span>
<span className="text-xs font-medium tabular-nums">
<Price amount={amount} />
</span>
</div>
{effectiveStep === 'done' && (
<div className="flex items-center gap-1.5 text-[11px] text-emerald-500">
<Check size={11} />
<span>Funds sent to your Bitcoin address</span>
</div>
)}
{isActive && (
<div className="h-1 w-full bg-muted rounded-full overflow-hidden">
<div className="h-full bg-blue-500/60 rounded-full animate-pulse w-full" />
</div>
)}
</>
)}
</div>
);
};
// --- Outer wrapper ---
export const SidebarSwap = () => {
const { data, loading, error } = useGetBoltzInfoQuery({
onError: error => toast.error(getErrorContent(error)),
});
if (loading) {
return (
<div className="p-2 border-t border-border/60">
<div className="flex items-center gap-1.5 mb-2">
<Shuffle size={13} className="text-emerald-500" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Quick Swap
</span>
</div>
<div className="flex items-center justify-center py-4">
<Loader2
className="animate-spin text-muted-foreground/40"
size={16}
/>
</div>
</div>
);
}
if (error || !data?.getBoltzInfo) {
return null;
}
const { max, min, feePercent } = data.getBoltzInfo;
return (
<div className="p-2 border-t border-border/60">
<div className="flex items-center justify-between mb-2.5">
<div className="flex items-center gap-1.5">
<Shuffle size={13} className="text-emerald-500" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Quick Swap
</span>
</div>
<span className="text-[10px] text-muted-foreground/50">
{feePercent}% fee
</span>
</div>
<SwapWidget max={max} min={min} />
<div className="mt-2 text-center">
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/60 transition-colors"
>
Powered by Boltz
</a>
</div>
</div>
);
};

View file

@ -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<PayProps> = ({ predefinedRequest, payCallback }) => {
export const Pay: FC<PayProps> = ({
predefinedRequest,
payCallback,
defaultFee = 10,
defaultPaths = 10,
}) => {
const [request, setRequest] = useState<string>(predefinedRequest || '');
const [peers, setPeers] = useState<string[]>([]);
const [fee, setFee] = useState<number>(10);
const [paths, setPaths] = useState<number>(1);
const [fee, setFee] = useState<number>(defaultFee);
const [paths, setPaths] = useState<number>(defaultPaths);
const [confirming, setConfirming] = useState(false);
const [pay, { loading }] = usePayMutation({
@ -165,6 +172,7 @@ export const Pay: FC<PayProps> = ({ predefinedRequest, payCallback }) => {
disabled={loading || !request}
className="w-full"
onClick={() => setConfirming(true)}
autoFocus
>
Pay
</Button>

View file

@ -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<boolean>(false);
const [address, setAddress] = useState<string>();
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 (
<div>

View file

@ -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 = () => (
<div className="flex items-center justify-center p-6 text-sm text-muted-foreground">
@ -77,11 +77,10 @@ export const SwapClaim = () => {
</div>
);
if (typeof claim !== 'number') {
if (!claimSwapId || !claimingSwap) {
return <Missing />;
}
const claimingSwap = swaps[claim];
const {
redeemScript,
preimage,
@ -147,6 +146,7 @@ export const SwapClaim = () => {
Fee Amount
</label>
<span className="text-sm font-medium tabular-nums">
{/* 111 vbytes ≈ typical claim tx size */}
<Price amount={fee * 111} />
</span>
</div>

View file

@ -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<State | undefined>(undefined);
export const DispatchContext = createContext<Dispatch | undefined>(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 (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</DispatchContext.Provider>
);
};
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 };

View file

@ -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})`;
};

View file

@ -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 (
<div className="relative">
{/* Connector lines — positioned between circle edges */}
<div className="absolute top-2.5 left-0 right-0" style={{ height: 1 }}>
{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 (
<div
key={i}
className={`absolute top-0 h-px ${
currentIdx > i ? 'bg-emerald-500/40' : 'bg-border'
}`}
style={{
left: `calc(${startPct}% + 10px)`,
right: `calc(${100 - endPct}% + 10px)`,
}}
/>
);
})}
</div>
{/* Step circles + labels */}
<div className="relative flex items-start w-full">
{steps.map((step, i) => {
const isComplete = currentIdx > i;
const isActive = currentIdx === i;
const isError = currentStep === 'error' && i === currentIdx;
return (
<div
key={step.key}
className="flex flex-col items-center gap-0.5 flex-1 min-w-0"
>
<div
className={`flex items-center justify-center h-5 w-5 rounded-full text-[9px] transition-colors ${
isError
? 'bg-red-500/20 text-red-500'
: isComplete
? 'bg-emerald-500/20 text-emerald-500'
: isActive
? 'bg-blue-500/20 text-blue-500'
: 'bg-muted text-muted-foreground/40'
}`}
>
{isError ? (
<AlertTriangle size={10} />
) : isComplete ? (
<Check size={10} />
) : isActive ? (
<Loader2 size={10} className="animate-spin" />
) : (
<CircleDot size={8} />
)}
</div>
<span
className={`text-[9px] leading-none ${
isActive || isComplete
? 'text-foreground font-medium'
: 'text-muted-foreground/40'
}`}
>
{step.label}
</span>
</div>
);
})}
</div>
{error && (
<div className="text-[9px] text-red-500 mt-1 text-center">{error}</div>
)}
</div>
);
};

View file

@ -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 (
<div className="flex items-center justify-center p-6 text-sm text-muted-foreground">
<Info className="mr-2" size={14} />
@ -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 (
<div className="space-y-4">
@ -43,6 +48,8 @@ export const SwapQuote = () => {
</p>
</div>
<SwapProgressStepper currentStep={step} />
<div className="space-y-1 text-sm">
{renderLine(
'Sending to',
@ -93,12 +100,11 @@ export const SwapQuote = () => {
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Pay Invoice
</h4>
<Pay predefinedRequest={invoice} payCallback={handlePaid} />
</div>
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-center text-xs text-amber-600 dark:text-amber-400">
It is ok to close this modal after 5 seconds of having paid even if it
still shows as loading.
<Pay
predefinedRequest={invoice}
defaultFee={maxFee}
defaultPaths={10}
/>
</div>
</div>
);

View file

@ -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 }) => (
</div>
);
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 (
<button
className={clickableRow}
onClick={() => dispatch({ type: 'open', open: index })}
>
<div className={rowContent}>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-medium font-mono truncate">
{swap.id}
</span>
{time && (
<span className="text-[10px] text-muted-foreground">{time}</span>
)}
</div>
<StatusBadge variant="success" icon={Clock}>
Ready to Pay
</StatusBadge>
return (
<button className={clickableRow} onClick={() => actions.openSwap(swap.id)}>
<div className={rowContent}>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-medium font-mono truncate">
{swap.id}
</span>
{time && (
<span className="text-[10px] text-muted-foreground">{time}</span>
)}
</div>
<RowAction label="Pay" />
</button>
);
};
<StatusBadge variant="success" icon={Clock}>
Ready to Pay
</StatusBadge>
</div>
<RowAction label="Pay" />
</button>
);
};
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 (
<div className={rowBase}>
<div className={rowContent}>
@ -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 }) => {
</div>
);
case CREATED:
return <ReadyComponent />;
return <ReadyRow swap={swap} />;
case MEMPOOL:
return (
<button
className={clickableRow}
onClick={() =>
dispatch({ type: 'claim', claim: index, claimType: MEMPOOL })
}
onClick={() => actions.openClaim(swap.id, MEMPOOL)}
>
<div className={rowContent}>
<div className="flex flex-col gap-0.5 min-w-0">
@ -172,9 +172,7 @@ const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => {
return (
<button
className={clickableRow}
onClick={() =>
dispatch({ type: 'claim', claim: index, claimType: CONFIRMED })
}
onClick={() => actions.openClaim(swap.id, CONFIRMED)}
>
<div className={rowContent}>
<div className="flex flex-col gap-0.5 min-w-0">
@ -196,9 +194,7 @@ const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => {
return (
<button
className={clickableRow}
onClick={() =>
dispatch({ type: 'claim', claim: index, claimType: CONFIRMED })
}
onClick={() => actions.openClaim(swap.id, CONFIRMED)}
>
<div className={rowContent}>
<div className="flex flex-col gap-0.5 min-w-0">
@ -228,7 +224,7 @@ const SwapRow = ({ swap, index }: { swap: EnrichedSwap; index: number }) => {
{getAddressLink(swap.receivingAddress)}
</span>
</div>
<StatusBadge variant="warning">{swap.boltz.status}</StatusBadge>
<StatusBadge variant="warning">{status}</StatusBadge>
</div>
</div>
);
@ -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<EnrichedSwap[]>([]);
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 = () => {
)}
</h2>
{swaps.length > 0 && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={loading}
onClick={() => refetch()}
>
<RefreshCw
size={14}
className={loading ? 'animate-spin' : ''}
/>
</Button>
<div data-tip data-for={`cleanup`}>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={loading}
onClick={handleCleanup}
onClick={() => actions.cleanup()}
>
<Trash size={14} />
</Button>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
Cleanup expired, refunded and completed swaps.
</TooltipContent>
</Tooltip>
)}
</div>
<Card>
<CardContent>
{loading && (
<p className="text-xs text-muted-foreground py-4 text-center">
Loading swap statuses...
</p>
)}
{!loading && (!swaps.length || !data?.getBoltzSwapStatus) && (
{!swaps.length && (
<p className="text-xs text-muted-foreground py-4 text-center">
No swaps yet. Create one above to get started.
</p>
)}
{!loading && enriched.length > 0 && (
{swaps.length > 0 && (
<div className="divide-y divide-border">
{enriched.map((swap, index) => (
<Fragment key={`${swap?.id}-${index}`}>
<SwapRow swap={swap} index={index} />
{swaps.map(swap => (
<Fragment key={swap.id}>
<SwapRow swap={swap} />
</Fragment>
))}
</div>
@ -347,17 +286,6 @@ export const SwapStatus = () => {
</CardContent>
</Card>
</div>
<ReactTooltip id={`cleanup`}>
Cleanup expired, refunded and completed swaps.
</ReactTooltip>
<Modal
isOpen={typeof open === 'number' || typeof claim === 'number'}
closeCallback={() => dispatch({ type: 'close' })}
>
{typeof open === 'number' ? <SwapQuote /> : <SwapClaim />}
</Modal>
</>
);
};

View file

@ -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<string, string> = {
[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<string> = 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<string> = new Set([
BOLTZ_STATUS.TX_MEMPOOL,
BOLTZ_STATUS.TX_CONFIRMED,
]);
export const isClaimableStatus = (status: string | null) =>
!!status && claimableStatuses.has(status);

View file

@ -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 (
<SwapsProvider>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Reverse Swap</h2>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Reverse Swap</h2>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
Fee {feePercent}%
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
Fee {feePercent}%
</Badge>
<Badge variant="secondary" className="gap-1.5">
Min <Price amount={min} />
</Badge>
<Badge variant="secondary" className="gap-1.5">
Max <Price amount={max} />
</Badge>
<Link href={'https://boltz.exchange/'} newTab>
<Badge variant="outline" className="gap-1.5 rounded-full px-3 py-1">
<Zap size={10} />
Boltz
</Badge>
<Badge variant="secondary" className="gap-1.5">
Min <Price amount={min} />
</Badge>
<Badge variant="secondary" className="gap-1.5">
Max <Price amount={max} />
</Badge>
<Link href={'https://boltz.exchange/'} newTab>
<Badge
variant="outline"
className="gap-1.5 rounded-full px-3 py-1"
>
<Zap size={10} />
Boltz
</Badge>
</Link>
</div>
</Link>
</div>
<Card>
<CardContent>
<StartSwap max={max} min={min} />
</CardContent>
</Card>
<SwapStatus />
</div>
</SwapsProvider>
<Card>
<CardContent>
<StartSwap max={max} min={min} />
</CardContent>
</Card>
<SwapStatus />
</div>
);
};

View file

@ -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<BoltzSwapStatus, 'status' | 'transaction'> | null;
} & CreateBoltzReverseSwap;

View file

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

View file

@ -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<SwapStatus>(
this.fetchService.fetchWithProxy(
`${this.configService.get('urls.boltz')}/v2/swap/${id}`
),
10_000
);
}
async createReverseSwap(
invoiceAmount: number,
preimageHash: string,

View file

@ -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;
};