feat: instant payment detection via Freedom.st SSE on backend and frontend
This commit is contained in:
@@ -127,6 +127,7 @@
|
||||
|
||||
<script>
|
||||
const PAY_API = '<%= it.payApi || "" %>';
|
||||
const SOCK_API = 'https://sock-v1.freedom.st';
|
||||
|
||||
let selectedPlan = null;
|
||||
let selectedCoin = null;
|
||||
@@ -135,6 +136,7 @@
|
||||
let paymentId = null;
|
||||
let pollInterval = null;
|
||||
let countdownInterval = null;
|
||||
let sseAbort = null;
|
||||
|
||||
// Fetch available coins on load
|
||||
(async () => {
|
||||
@@ -234,8 +236,65 @@
|
||||
updateCountdown(expiresAt);
|
||||
countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000);
|
||||
|
||||
// Start polling
|
||||
pollInterval = setInterval(() => pollStatus(), 5000);
|
||||
// Start SSE stream for instant tx detection on the client
|
||||
watchAddress(data.coin, data.address);
|
||||
|
||||
// Poll as fallback (for confirmations and in case SSE drops)
|
||||
pollInterval = setInterval(() => pollStatus(), 10000);
|
||||
}
|
||||
|
||||
/** Subscribe to Freedom.st SSE filtered by our payment address for instant detection. */
|
||||
function watchAddress(coin, address) {
|
||||
if (sseAbort) sseAbort.abort();
|
||||
sseAbort = new AbortController();
|
||||
|
||||
const query = { chain: coin, "data.out.script.address": address };
|
||||
const q = btoa(JSON.stringify(query));
|
||||
const url = `${SOCK_API}/sse?q=${q}`;
|
||||
|
||||
(async function connect() {
|
||||
while (!sseAbort.signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: sseAbort.signal });
|
||||
if (!res.ok || !res.body) { await new Promise(r => setTimeout(r, 3000)); continue; }
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (!sseAbort.signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
onTxDetected(event);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (sseAbort.signal.aborted) return;
|
||||
}
|
||||
if (!sseAbort.signal.aborted) await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function onTxDetected(event) {
|
||||
// Transaction matched our address filter — payment detected instantly
|
||||
console.log('TX detected via SSE:', event);
|
||||
const statusEl = document.getElementById('pay-status');
|
||||
statusEl.innerHTML = `
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
|
||||
`;
|
||||
// Immediately poll the pay API to get authoritative status update
|
||||
pollStatus();
|
||||
}
|
||||
|
||||
function updateCountdown(expiresAt) {
|
||||
@@ -261,12 +320,14 @@
|
||||
} else if (data.status === 'paid') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(countdownInterval);
|
||||
if (sseAbort) { sseAbort.abort(); sseAbort = null; }
|
||||
document.getElementById('pay-status-section').classList.add('hidden');
|
||||
document.getElementById('pay-success').classList.remove('hidden');
|
||||
setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000);
|
||||
} else if (data.status === 'expired') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(countdownInterval);
|
||||
if (sseAbort) { sseAbort.abort(); sseAbort = null; }
|
||||
document.getElementById('pay-status-section').classList.add('hidden');
|
||||
document.getElementById('pay-expired').classList.remove('hidden');
|
||||
}
|
||||
@@ -282,6 +343,9 @@
|
||||
}
|
||||
|
||||
function resetCheckout() {
|
||||
if (sseAbort) { sseAbort.abort(); sseAbort = null; }
|
||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
||||
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||
document.getElementById('step-select').classList.remove('hidden');
|
||||
document.getElementById('step-pay').classList.add('hidden');
|
||||
document.getElementById('pay-success').classList.add('hidden');
|
||||
|
||||
Reference in New Issue
Block a user