mirror of
https://github.com/apotdevin/thunderhub.git
synced 2026-08-13 12:33:08 +02:00
feat: rework boltz swaps
This commit is contained in:
parent
eeca15378a
commit
efcb28d587
22 changed files with 1277 additions and 623 deletions
17
schema.gql
17
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!
|
||||
|
|
|
|||
610
src/client/src/context/BoltzSwapContext.tsx
Normal file
610
src/client/src/context/BoltzSwapContext.tsx
Normal 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]
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -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'];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
311
src/client/src/layouts/sidebar/SidebarSwap.tsx
Normal file
311
src/client/src/layouts/sidebar/SidebarSwap.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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})`;
|
||||
};
|
||||
|
|
|
|||
136
src/client/src/views/swap/SwapProgress.tsx
Normal file
136
src/client/src/views/swap/SwapProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
51
src/client/src/views/swap/boltzStatus.ts
Normal file
51
src/client/src/views/swap/boltzStatus.ts
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue