From 7a74de24166f7d8a7c3cdf8cd036025dd7b3b24b Mon Sep 17 00:00:00 2001 From: Noah Yakubu Date: Wed, 29 Jul 2026 01:02:50 +0100 Subject: [PATCH] feat(invoice): blockchain confirmation block counter widget Closes #515 - Add GET /api/tx/[hash]/route.ts: queries Horizon for tx ledger and current ledger to compute confirmations - Add useTransactionConfirmations hook: polls every 5s, pauses on hidden tab, stops at FINALITY_THRESHOLD - Add ConfirmationCounter component: circular SVG arc filling to threshold, shows 'Confirmed' + checkmark when done --- src/app/api/tx/[hash]/route.ts | 49 +++++++ .../invoice/ConfirmationCounter.tsx | 138 ++++++++++++++++++ src/hooks/useTransactionConfirmations.ts | 86 +++++++++++ 3 files changed, 273 insertions(+) create mode 100644 src/app/api/tx/[hash]/route.ts create mode 100644 src/components/invoice/ConfirmationCounter.tsx create mode 100644 src/hooks/useTransactionConfirmations.ts diff --git a/src/app/api/tx/[hash]/route.ts b/src/app/api/tx/[hash]/route.ts new file mode 100644 index 0000000..a91607d --- /dev/null +++ b/src/app/api/tx/[hash]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; + +const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon.stellar.org"; + +interface ConfirmationsResponse { + confirmations: number; + confirmed: boolean; + ledger: number; + currentLedger: number; +} + +export async function GET( + _req: NextRequest, + { params }: { params: { hash: string } } +): Promise> { + const { hash } = params; + + if (!hash || !/^[a-fA-F0-9]{64}$/.test(hash)) { + return NextResponse.json({ error: "Invalid transaction hash" }, { status: 400 }); + } + + try { + // Fetch the transaction and the latest ledger in parallel + const [txRes, ledgersRes] = await Promise.all([ + fetch(`${HORIZON_URL}/transactions/${hash}`, { next: { revalidate: 0 } }), + fetch(`${HORIZON_URL}/ledgers?order=desc&limit=1`, { next: { revalidate: 0 } }), + ]); + + if (!txRes.ok) { + const status = txRes.status === 404 ? 404 : 502; + return NextResponse.json({ error: "Transaction not found" }, { status }); + } + + const [txData, ledgersData] = await Promise.all([txRes.json(), ledgersRes.json()]); + + const txLedger: number = txData.ledger; + const currentLedger: number = ledgersData._embedded?.records?.[0]?.sequence ?? txLedger; + const confirmations = Math.max(0, currentLedger - txLedger); + + return NextResponse.json({ + confirmations, + confirmed: confirmations >= 3, + ledger: txLedger, + currentLedger, + }); + } catch { + return NextResponse.json({ error: "Failed to fetch transaction data" }, { status: 502 }); + } +} diff --git a/src/components/invoice/ConfirmationCounter.tsx b/src/components/invoice/ConfirmationCounter.tsx new file mode 100644 index 0000000..c075753 --- /dev/null +++ b/src/components/invoice/ConfirmationCounter.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useTransactionConfirmations, FINALITY_THRESHOLD } from "@/hooks/useTransactionConfirmations"; + +interface Props { + txHash: string; +} + +const RADIUS = 22; +const STROKE = 4; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +/** + * ConfirmationCounter — shows a circular SVG progress arc that fills as + * Stellar ledger confirmations increase toward FINALITY_THRESHOLD. + * Polling stops when the threshold is reached. + */ +export default function ConfirmationCounter({ txHash }: Props) { + const { confirmations, confirmed, loading, error } = useTransactionConfirmations(txHash); + + const progress = Math.min(confirmations / FINALITY_THRESHOLD, 1); + const dashOffset = CIRCUMFERENCE * (1 - progress); + + if (loading && confirmations === 0) { + return ( +
+
+ ); + } + + if (error) { + return ( +
+ Confirmation check failed: {error} +
+ ); + } + + if (confirmed) { + return ( +
+ {/* Green checkmark circle */} + + Confirmed +
+ ); + } + + return ( +
+ {/* Circular SVG progress arc */} + + + + {confirmations} / {FINALITY_THRESHOLD} confirmations + +
+ ); +} diff --git a/src/hooks/useTransactionConfirmations.ts b/src/hooks/useTransactionConfirmations.ts new file mode 100644 index 0000000..27c4295 --- /dev/null +++ b/src/hooks/useTransactionConfirmations.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; + +export const FINALITY_THRESHOLD = 3; +const POLL_INTERVAL_MS = 5_000; + +export interface TransactionConfirmationsState { + confirmations: number; + confirmed: boolean; + loading: boolean; + error: string | null; +} + +/** + * Polls /api/tx/[hash] every 5 seconds to track Stellar ledger confirmations. + * Pauses polling when the browser tab is hidden (Page Visibility API). + * Stops automatically once FINALITY_THRESHOLD confirmations are reached. + */ +export function useTransactionConfirmations( + txHash: string | null | undefined +): TransactionConfirmationsState { + const [state, setState] = useState({ + confirmations: 0, + confirmed: false, + loading: false, + error: null, + }); + + const stopPolling = useRef(false); + const intervalRef = useRef | null>(null); + + const poll = useCallback(async () => { + if (!txHash || stopPolling.current || document.hidden) return; + + try { + const res = await fetch(`/api/tx/${txHash}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setState((prev) => ({ ...prev, loading: false, error: data.error ?? "Fetch error" })); + return; + } + const data: { confirmations: number; confirmed: boolean } = await res.json(); + setState({ + confirmations: data.confirmations, + confirmed: data.confirmed, + loading: false, + error: null, + }); + if (data.confirmed) { + stopPolling.current = true; + if (intervalRef.current) clearInterval(intervalRef.current); + } + } catch { + setState((prev) => ({ ...prev, loading: false, error: "Network error" })); + } + }, [txHash]); + + useEffect(() => { + if (!txHash) return; + + stopPolling.current = false; + setState({ confirmations: 0, confirmed: false, loading: true, error: null }); + + // Initial fetch + poll(); + + // Set up polling interval + intervalRef.current = setInterval(poll, POLL_INTERVAL_MS); + + // Pause/resume on visibility change + const handleVisibility = () => { + if (!document.hidden && !stopPolling.current) { + poll(); + } + }; + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [txHash, poll]); + + return state; +}