forked from karagozemin/OverSync
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrefund-watchdog.ts
More file actions
143 lines (127 loc) · 5.16 KB
/
Copy pathrefund-watchdog.ts
File metadata and controls
143 lines (127 loc) · 5.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
/**
* Background watchdog that rescues XLM→ETH orders the relayer failed to
* complete (typically because the user closed the page after sending
* XLM, or the ETH RPC hiccupped past the in-request retry budget).
*
* Every `intervalMs` we walk `activeOrders`, find any `xlm_to_eth` order
* that has been awaiting ETH for longer than `staleAfterMs`, and trigger
* a refund using the same code path as the inline handler. Refunded
* orders are stamped `refunded` (and `refundTxHash`) so subsequent ticks
* don't double-pay.
*
* The watchdog is best-effort: failures are logged but never thrown so
* one bad order can't take down the entire timer.
*/
import { refundXlmToUser, type RefundNetworkMode } from './xlm-refund.js';
const DEFAULT_INTERVAL_MS = 60_000; // 1 minute
const DEFAULT_STALE_AFTER_MS = 5 * 60_000; // 5 minutes
interface WatchdogOrder {
orderId?: string;
direction?: string;
status?: string;
stellarAddress?: string;
stellarTxHash?: string;
xlmReceivedAt?: number | string;
created?: number | string;
amount?: number | string;
networkMode?: RefundNetworkMode | string;
refundTxHash?: string;
refundedAt?: number;
watchdogFailedAt?: number;
watchdogFailureReason?: string;
[k: string]: unknown;
}
export interface WatchdogConfig {
/** How often to scan, in ms. Defaults to 60s. */
intervalMs?: number;
/**
* How long an order can sit without ETH being sent before the
* watchdog refunds it. Defaults to 5 minutes.
*/
staleAfterMs?: number;
/** Horizon URL for the active Stellar network (mainnet or testnet). */
horizonUrl: string;
/** Stellar secret the relayer will sign refunds with. */
refundSecret: string;
/** Network mode used to choose the right passphrase. */
networkMode: RefundNetworkMode;
/**
* Reference to the in-memory order map maintained by the relayer.
* The watchdog mutates entries in-place to mark them refunded.
*/
activeOrders: Map<string, WatchdogOrder>;
}
function toMillis(value: WatchdogOrder['xlmReceivedAt'] | WatchdogOrder['created']): number | null {
if (value == null) return null;
if (typeof value === 'number') return value > 1e12 ? value : value * 1000;
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? parsed : null;
}
function isXlmToEthAwaitingEth(order: WatchdogOrder): boolean {
if (order.direction !== 'xlm_to_eth') return false;
if (!order.stellarTxHash) return false; // XLM never received → nothing to refund
if (order.refundTxHash || order.refundedAt) return false; // already refunded
if (order.status === 'eth_tx_sent' || order.status === 'completed') return false;
if (order.status === 'refunded') return false;
return true;
}
export function startRefundWatchdog(config: WatchdogConfig): { stop: () => void } {
const intervalMs = config.intervalMs ?? DEFAULT_INTERVAL_MS;
const staleAfterMs = config.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
console.log(
`[refund-watchdog] starting · scan every ${Math.round(intervalMs / 1000)}s · refund after ${Math.round(staleAfterMs / 1000)}s · network=${config.networkMode}`
);
const tick = async () => {
const now = Date.now();
for (const [orderId, order] of config.activeOrders.entries()) {
try {
if (!isXlmToEthAwaitingEth(order)) continue;
if (order.watchdogFailedAt && now - order.watchdogFailedAt < 10 * 60_000) {
// back off for 10 minutes after a failed attempt
continue;
}
const startedAt = toMillis(order.xlmReceivedAt) ?? toMillis(order.created);
if (!startedAt) continue;
const age = now - startedAt;
if (age < staleAfterMs) continue;
const stellarAddress = order.stellarAddress;
if (!stellarAddress) {
console.warn(`[refund-watchdog] order ${orderId} stuck but missing stellarAddress; skipping`);
continue;
}
console.log(
`[refund-watchdog] refunding ${orderId} — pending for ${Math.round(age / 1000)}s, stellarTx=${order.stellarTxHash}`
);
const refund = await refundXlmToUser({
orderId,
stellarAddress,
stellarTxHash: order.stellarTxHash,
networkMode: config.networkMode,
horizonUrl: config.horizonUrl,
refundSecret: config.refundSecret,
fallbackXlmAmount: order.amount ? String(order.amount) : undefined,
});
order.status = 'refunded';
order.refundTxHash = refund.hash;
order.refundedAt = Date.now();
console.log(
`[refund-watchdog] ✅ refunded ${refund.amount} XLM → ${stellarAddress} (tx=${refund.hash})`
);
} catch (err: any) {
order.watchdogFailedAt = Date.now();
order.watchdogFailureReason = err?.message ?? String(err);
console.error(`[refund-watchdog] ❌ failed to refund ${orderId}:`, err?.message ?? err);
}
}
};
// Fire-and-forget first scan after a short warm-up so the watchdog
// doesn't race with relayer startup logic.
const warmup = setTimeout(() => { void tick(); }, 15_000);
const handle = setInterval(() => { void tick(); }, intervalMs);
return {
stop() {
clearTimeout(warmup);
clearInterval(handle);
},
};
}