492 lines
16 KiB
JavaScript
492 lines
16 KiB
JavaScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { loadSwaps } from '../app/functions/boltz/rootstock/swapDb';
|
|
import {
|
|
executeSubmarineSwap,
|
|
isSubmarineLockUnresolved,
|
|
} from '../app/functions/boltz/rootstock/submarineSwap';
|
|
import {
|
|
isRootstockSwapActive,
|
|
isRootstockSwapPendingRefund,
|
|
} from '../app/functions/boltz/rootstock/swapStatus';
|
|
import { reconcileSubmarineSwapLock } from '../app/functions/boltz/rootstock/reconcileSubmarineSwap';
|
|
import { handleRootstockSwapUpdate } from '../app/functions/boltz/rootstock/swapLifecycle';
|
|
import { refundRootstockSubmarineSwap } from '../app/functions/boltz/rootstock/claims';
|
|
import { fetchBoltzSwapStatus } from '../app/functions/boltz/rootstock/boltzStatus';
|
|
import { useKeysContext } from './keys';
|
|
import { useAppStatus } from './appStatus';
|
|
import {
|
|
getRoostockProviderEndpoints,
|
|
getRoostockProviderNetwork,
|
|
rootstockEnvironment,
|
|
} from '../app/functions/boltz/rootstock';
|
|
import { FallbackProvider, JsonRpcProvider, Wallet } from 'ethers';
|
|
import { getBoltzWsUrl } from '../app/functions/boltz/boltzEndpoitns';
|
|
import { useSparkWallet } from './sparkContext';
|
|
import { useWebView } from './webViewContext';
|
|
import { useAuthContext } from './authContext';
|
|
|
|
export const RootstockSwapContext = createContext();
|
|
|
|
// Minimum gap between on-chain reconciliation attempts for the same swap, so the
|
|
// recurring interval doesn't hammer the RPC for swaps left in broadcast_unknown.
|
|
const RECONCILE_THROTTLE_MS = 60000;
|
|
|
|
const isRootstockSwapMonitored = swap =>
|
|
isRootstockSwapActive(swap) || isRootstockSwapPendingRefund(swap);
|
|
|
|
export const RootstockSwapProvider = ({ children }) => {
|
|
const { sendWebViewRequest } = useWebView();
|
|
const { authResetkey } = useAuthContext();
|
|
const { sparkInformation } = useSparkWallet();
|
|
const { accountMnemoinc } = useKeysContext();
|
|
const { didGetToHomepage, minMaxLiquidSwapAmounts } = useAppStatus();
|
|
const subscribedIdsRef = useRef(new Set());
|
|
const activeSwapIdsRef = useRef(new Set()); // Track active swaps
|
|
const [signer, setSigner] = useState(null);
|
|
|
|
const isInitialRender = useRef(true);
|
|
const wsRef = useRef(null);
|
|
const intervalRef = useRef(null);
|
|
const timeoutRef = useRef(null);
|
|
const cleanupDebounceRef = useRef(null);
|
|
const didRunSignerCreation = useRef(null);
|
|
const reconcileThrottleRef = useRef(new Map());
|
|
const createSwapInFlightRef = useRef(false);
|
|
|
|
// Smart cleanup function that only clears if no active swaps
|
|
const cleanupRootstockListener = useCallback(() => {
|
|
console.log('Running event listeners cleanup', {
|
|
activeSwaps: Array.from(activeSwapIdsRef.current),
|
|
});
|
|
|
|
// If there are active swaps, don't cleanup
|
|
if (activeSwapIdsRef.current.size > 0) {
|
|
console.log('Skipping cleanup - active swaps in progress');
|
|
return false;
|
|
}
|
|
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
if (cleanupDebounceRef.current) {
|
|
clearTimeout(cleanupDebounceRef.current);
|
|
cleanupDebounceRef.current = null;
|
|
}
|
|
if (wsRef.current) {
|
|
// Remove event listeners to prevent the assertion error
|
|
wsRef.current.onopen = null;
|
|
wsRef.current.onmessage = null;
|
|
wsRef.current.onerror = null;
|
|
wsRef.current.onclose = null;
|
|
|
|
// Only close if not already closed/closing
|
|
if (
|
|
wsRef.current.readyState === WebSocket.OPEN ||
|
|
wsRef.current.readyState === WebSocket.CONNECTING ||
|
|
wsRef.current.readyState === WebSocket.CLOSING
|
|
) {
|
|
wsRef.current.close();
|
|
}
|
|
|
|
wsRef.current = null;
|
|
}
|
|
subscribedIdsRef.current.clear();
|
|
activeSwapIdsRef.current.clear();
|
|
return true;
|
|
}, []);
|
|
|
|
// Debounced cleanup function to prevent rapid fire cleanup attempts
|
|
const debouncedCleanup = useCallback(() => {
|
|
if (cleanupDebounceRef.current) {
|
|
clearTimeout(cleanupDebounceRef.current);
|
|
}
|
|
|
|
cleanupDebounceRef.current = setTimeout(() => {
|
|
cleanupRootstockListener();
|
|
cleanupDebounceRef.current = null;
|
|
}, 1000); // 1s debounce
|
|
}, [cleanupRootstockListener]);
|
|
|
|
const provider = useMemo(() => {
|
|
const network = getRoostockProviderNetwork(rootstockEnvironment);
|
|
const providers = getRoostockProviderEndpoints(rootstockEnvironment).map(
|
|
(endpoint, index) => ({
|
|
provider: new JsonRpcProvider(endpoint, network, {
|
|
staticNetwork: true,
|
|
}),
|
|
priority: index + 1,
|
|
weight: 1,
|
|
stallTimeout: 2000,
|
|
}),
|
|
);
|
|
|
|
return providers.length === 1
|
|
? providers[0].provider
|
|
: new FallbackProvider(providers, network, { quorum: 1 });
|
|
}, []);
|
|
|
|
const createSigner = useCallback(() => {
|
|
try {
|
|
console.log('PROCESSS WALLET', new Date().getTime());
|
|
const wallet = Wallet.fromPhrase(accountMnemoinc);
|
|
|
|
console.log('PROCESSS WALLET 2', new Date().getTime());
|
|
const connectedSigner = wallet.connect(provider);
|
|
console.log('PROCESSS WALLET 3', new Date().getTime());
|
|
setSigner(connectedSigner);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error creating wallet:', error);
|
|
setSigner(null);
|
|
return false;
|
|
}
|
|
}, [accountMnemoinc, provider]);
|
|
|
|
const subscribeToIds = ids => {
|
|
if (wsRef.current && wsRef.current.readyState === 1 && ids.length > 0) {
|
|
console.log('sending ids to websocket', ids);
|
|
ids.forEach(id => {
|
|
wsRef.current.send(
|
|
JSON.stringify({
|
|
op: 'subscribe',
|
|
channel: 'swap.update',
|
|
args: [id],
|
|
}),
|
|
);
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Resolve any swap whose lock attempt crashed/stalled before a confirmed
|
|
// broadcast. Throttled per-swap so the recurring interval can't spam the RPC.
|
|
const reconcileUnresolvedSwaps = async swaps => {
|
|
if (!signer) return;
|
|
const now = Date.now();
|
|
const throttle = reconcileThrottleRef.current;
|
|
const unresolved = swaps.filter(isSubmarineLockUnresolved);
|
|
for (const swap of unresolved) {
|
|
const lastReconciledAt = throttle.get(swap.id) || 0;
|
|
if (now - lastReconciledAt < RECONCILE_THROTTLE_MS) continue;
|
|
throttle.set(swap.id, now);
|
|
try {
|
|
await reconcileSubmarineSwapLock(swap, signer, provider);
|
|
} catch (err) {
|
|
console.log('error reconciling rootstock swap', swap.id, err);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Re-drive refunds for swaps that reached a terminal failure but whose refund
|
|
// never confirmed. Idempotent and throttled per-swap so locked RBTC is
|
|
// recovered without spamming the RPC.
|
|
const retryPendingRefunds = async swaps => {
|
|
if (!signer) return;
|
|
const now = Date.now();
|
|
const throttle = reconcileThrottleRef.current;
|
|
const pending = swaps.filter(isRootstockSwapPendingRefund);
|
|
for (const swap of pending) {
|
|
const throttleKey = `${swap.id}:refund`;
|
|
const lastAttemptAt = throttle.get(throttleKey) || 0;
|
|
if (now - lastAttemptAt < RECONCILE_THROTTLE_MS) continue;
|
|
throttle.set(throttleKey, now);
|
|
try {
|
|
await refundRootstockSubmarineSwap(swap, signer);
|
|
} catch (err) {
|
|
console.log('error retrying rootstock refund', swap.id, err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadRootstockSwaps = async () => {
|
|
try {
|
|
let swaps = (await loadSwaps()) || [];
|
|
|
|
// Heal swaps stuck mid-lock before deciding whether to create a new one.
|
|
// A discard here drops the active count to zero so a fresh swap is minted.
|
|
if (swaps.some(isSubmarineLockUnresolved)) {
|
|
await reconcileUnresolvedSwaps(swaps);
|
|
swaps = (await loadSwaps()) || [];
|
|
}
|
|
|
|
await retryPendingRefunds(swaps);
|
|
swaps = (await loadSwaps()) || [];
|
|
|
|
let activeSwaps = swaps.filter(isRootstockSwapActive);
|
|
if (activeSwaps.length) return activeSwaps;
|
|
|
|
const pendingRefunds = swaps.filter(isRootstockSwapPendingRefund);
|
|
if (pendingRefunds.length) return pendingRefunds;
|
|
|
|
// Only one creation may be in flight; concurrent callers (signer effect,
|
|
// interval, ws reconnect) bail rather than mint a second Boltz swap +
|
|
// Lightning invoice.
|
|
if (createSwapInFlightRef.current) return [];
|
|
createSwapInFlightRef.current = true;
|
|
try {
|
|
// Re-check after winning the latch — another path may have just created.
|
|
swaps = (await loadSwaps()) || [];
|
|
activeSwaps = swaps.filter(isRootstockSwapActive);
|
|
if (activeSwaps.length) return activeSwaps;
|
|
|
|
const pending = swaps.filter(isRootstockSwapPendingRefund);
|
|
if (pending.length) return pending;
|
|
|
|
const swap = await executeSubmarineSwap(
|
|
accountMnemoinc,
|
|
minMaxLiquidSwapAmounts,
|
|
provider,
|
|
signer,
|
|
sendWebViewRequest,
|
|
sparkInformation.identityPubKey,
|
|
);
|
|
return swap ? [swap] : [];
|
|
} finally {
|
|
createSwapInFlightRef.current = false;
|
|
}
|
|
} catch (err) {
|
|
console.log(err, 'error in load rootstock swaps');
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Runs after every interval or ws update
|
|
const scheduleCleanup = durationMs => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
debouncedCleanup();
|
|
|
|
// If still active, schedule again
|
|
if (activeSwapIdsRef.current.size > 0) {
|
|
scheduleCleanup(durationMs);
|
|
}
|
|
}, durationMs);
|
|
};
|
|
|
|
// Load swaps from DB and subscribe
|
|
const loadAndSubscribeSwaps = async (force = false, swaps) => {
|
|
const newSwaps = swaps;
|
|
if (!newSwaps || !newSwaps.length) {
|
|
// No new swaps to process, but existing active swaps may still be running
|
|
// Don't clear anything here - let the interval and message handlers manage state
|
|
return [];
|
|
}
|
|
|
|
// Update active swaps tracking
|
|
newSwaps.forEach(swap => {
|
|
if (isRootstockSwapMonitored(swap)) {
|
|
activeSwapIdsRef.current.add(swap.id);
|
|
}
|
|
});
|
|
|
|
const newIds = newSwaps
|
|
.map(s => s.id)
|
|
.filter(id => !subscribedIdsRef.current.has(id));
|
|
|
|
if (newIds.length > 0 || force) {
|
|
const response = subscribeToIds(newIds);
|
|
if (response) newIds.forEach(id => subscribedIdsRef.current.add(id));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!sparkInformation.identityPubKey) return;
|
|
if (!accountMnemoinc) return;
|
|
if (didRunSignerCreation.current) return;
|
|
didRunSignerCreation.current = true;
|
|
createSigner();
|
|
}, [sparkInformation.identityPubKey, accountMnemoinc]);
|
|
|
|
useEffect(() => {
|
|
if (!signer) return;
|
|
// Open websocket once
|
|
startRootstockEventListener({ durationMs: 30000, intervalMs: 15000 });
|
|
}, [signer]);
|
|
|
|
const startRootstockEventListener = useCallback(
|
|
async ({ durationMs = 60000, intervalMs = 20000 } = {}) => {
|
|
let swaps = await loadRootstockSwaps();
|
|
|
|
// Check if we should open a new WebSocket or use existing one
|
|
const hasActiveSwaps = activeSwapIdsRef.current.size > 0;
|
|
|
|
if (hasActiveSwaps && wsRef.current) {
|
|
console.log(
|
|
'WebSocket already active, adding new swaps to existing connection',
|
|
);
|
|
// Just subscribe to new swaps on existing connection
|
|
loadAndSubscribeSwaps(false, swaps);
|
|
return;
|
|
}
|
|
|
|
// Clean up any existing connections before opening new one
|
|
cleanupRootstockListener();
|
|
|
|
// Initialize active swaps tracking
|
|
swaps.forEach(swap => {
|
|
if (isRootstockSwapMonitored(swap)) {
|
|
activeSwapIdsRef.current.add(swap.id);
|
|
}
|
|
});
|
|
|
|
if (swaps.length) {
|
|
// Open websocket
|
|
const ws = new WebSocket(getBoltzWsUrl(rootstockEnvironment));
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = event => {
|
|
console.log('WebSocket opened');
|
|
loadAndSubscribeSwaps(true, swaps);
|
|
};
|
|
|
|
ws.onmessage = async event => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.event !== 'update') return;
|
|
|
|
const swapUpdate = msg.args[0];
|
|
const swapId = swapUpdate.id;
|
|
const status = swapUpdate.status;
|
|
|
|
console.log(`Swap ${swapId} updated: ${status}`);
|
|
|
|
await handleRootstockSwapUpdate({
|
|
swapId,
|
|
status,
|
|
swapUpdate,
|
|
signer,
|
|
activeSwapIds: activeSwapIdsRef.current,
|
|
});
|
|
|
|
debouncedCleanup();
|
|
} catch (error) {
|
|
console.error('Error processing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
ws.onerror = event => {
|
|
console.error('WebSocket error:', event);
|
|
// Don't try to access 'this' or other properties that might cause issues
|
|
};
|
|
|
|
ws.onclose = event => {
|
|
console.log('WebSocket closed:', event.code, event.reason);
|
|
const shouldReconnect = activeSwapIdsRef.current.size > 0;
|
|
// Clean up subscribed IDs when connection closes
|
|
subscribedIdsRef.current.clear();
|
|
// Clear active swaps since we can't monitor them without WebSocket
|
|
activeSwapIdsRef.current.clear();
|
|
wsRef.current = null;
|
|
if (shouldReconnect) {
|
|
setTimeout(() => {
|
|
startRootstockEventListener({ durationMs, intervalMs });
|
|
}, 1000);
|
|
}
|
|
};
|
|
}
|
|
|
|
const intervalFunction = async () => {
|
|
const freshSwaps = await loadRootstockSwaps();
|
|
if (freshSwaps && freshSwaps.length > 0) {
|
|
// Update active swaps based on current status
|
|
const currentMonitoredSwaps = freshSwaps.filter(swap =>
|
|
isRootstockSwapMonitored(swap),
|
|
);
|
|
const currentActiveSwaps = freshSwaps.filter(swap =>
|
|
isRootstockSwapActive(swap),
|
|
);
|
|
|
|
// Update the active swaps ref
|
|
activeSwapIdsRef.current.clear();
|
|
currentMonitoredSwaps.forEach(swap => {
|
|
activeSwapIdsRef.current.add(swap.id);
|
|
});
|
|
|
|
// Fallback for a websocket that went silent without closing: poll each
|
|
// active swap's status. The monotonic guard in handleRootstockSwapUpdate
|
|
// makes a duplicate/late poll a no-op, so WS and poll never double-apply.
|
|
for (const swap of currentActiveSwaps) {
|
|
const polled = await fetchBoltzSwapStatus(swap.id);
|
|
if (polled?.status && polled.status !== swap.data?.status) {
|
|
await handleRootstockSwapUpdate({
|
|
swapId: swap.id,
|
|
status: polled.status,
|
|
swapUpdate: polled,
|
|
signer,
|
|
activeSwapIds: activeSwapIdsRef.current,
|
|
});
|
|
}
|
|
}
|
|
|
|
loadAndSubscribeSwaps(false, freshSwaps);
|
|
} else {
|
|
console.log('No swaps found in interval check');
|
|
// Clear active swaps since no swaps exist
|
|
activeSwapIdsRef.current.clear();
|
|
}
|
|
};
|
|
|
|
// quick call to not wait for the first interval
|
|
intervalFunction();
|
|
intervalRef.current = setInterval(intervalFunction, intervalMs);
|
|
|
|
scheduleCleanup(durationMs);
|
|
},
|
|
[
|
|
cleanupRootstockListener,
|
|
debouncedCleanup,
|
|
signer,
|
|
accountMnemoinc,
|
|
minMaxLiquidSwapAmounts,
|
|
provider,
|
|
sparkInformation.identityPubKey,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isInitialRender.current) {
|
|
isInitialRender.current = false;
|
|
return;
|
|
}
|
|
cleanupRootstockListener();
|
|
didRunSignerCreation.current = false;
|
|
setSigner(null);
|
|
}, [authResetkey]);
|
|
|
|
const contextValue = useMemo(() => {
|
|
return {
|
|
provider,
|
|
signer,
|
|
createSigner,
|
|
startRootstockEventListener,
|
|
};
|
|
}, [provider, signer, createSigner, startRootstockEventListener]);
|
|
|
|
return (
|
|
<RootstockSwapContext.Provider value={contextValue}>
|
|
{children}
|
|
</RootstockSwapContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useRootstockProvider = () => {
|
|
return React.useContext(RootstockSwapContext);
|
|
};
|