diff --git a/src/app/api/stellar/fee/__tests__/route.test.ts b/src/app/api/stellar/fee/__tests__/route.test.ts new file mode 100644 index 0000000..01f47dd --- /dev/null +++ b/src/app/api/stellar/fee/__tests__/route.test.ts @@ -0,0 +1,34 @@ +/** + * @jest-environment node + */ +import { GET } from "@/app/api/stellar/fee/route"; +import { NextRequest } from "next/server"; + +jest.mock("@/lib/stellar", () => ({ + getCurrentBaseFee: jest.fn().mockResolvedValue(100), + calculatePriorityFee: jest.fn().mockReturnValue(200), +})); + +jest.mock("@/server/config", () => ({ + serverConfig: { + stellar: { maxFeeCap: 200 }, + }, +})); + +describe("GET /api/stellar/fee", () => { + it("returns current base fee and priority fee", async () => { + const request = new NextRequest("http://localhost/api/stellar/fee"); + const response = await GET(request); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ + success: true, + data: { + baseFee: 100, + priorityFee: 200, + maxFeeCap: 200, + }, + }); + }); +}); diff --git a/src/app/api/stellar/fee/route.ts b/src/app/api/stellar/fee/route.ts new file mode 100644 index 0000000..0925361 --- /dev/null +++ b/src/app/api/stellar/fee/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentBaseFee, calculatePriorityFee } from "@/lib/stellar"; +import { serverConfig } from "@/server/config"; +import type { ApiResponse } from "@/types"; + +export async function GET(_req: NextRequest) { + const baseFee = await getCurrentBaseFee(); + const priorityFee = calculatePriorityFee(baseFee); + + return NextResponse.json>({ + success: true, + data: { + baseFee, + priorityFee, + maxFeeCap: serverConfig.stellar.maxFeeCap, + }, + }); +} diff --git a/src/app/api/webhooks/paystack/__tests__/route.test.ts b/src/app/api/webhooks/paystack/__tests__/route.test.ts index 9f54e1b..4fc13c0 100644 --- a/src/app/api/webhooks/paystack/__tests__/route.test.ts +++ b/src/app/api/webhooks/paystack/__tests__/route.test.ts @@ -120,6 +120,10 @@ describe("POST /api/webhooks/paystack", () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.duplicate).toBe(true); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("INTERVAL '24 HOURS'"), + ["evt_123"] + ); expect(mockQuery).toHaveBeenCalledTimes(1); expect(mockTransaction).not.toHaveBeenCalled(); }); diff --git a/src/app/api/webhooks/paystack/route.ts b/src/app/api/webhooks/paystack/route.ts index e15e3a4..fb92950 100644 --- a/src/app/api/webhooks/paystack/route.ts +++ b/src/app/api/webhooks/paystack/route.ts @@ -38,14 +38,17 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Missing event ID" }, { status: 400 }); } - // Replay attack prevention: check if event already processed + // Replay attack prevention: check if the webhook was processed in the last 24 hours const { rows: existingEvent } = await query( - "SELECT id FROM processed_webhooks WHERE id = $1 AND provider = 'paystack'", + `SELECT id FROM processed_webhooks + WHERE id = $1 + AND provider = 'paystack' + AND created_at >= NOW() - INTERVAL '24 HOURS'`, [eventId] ); if (existingEvent.length > 0) { - logger.info({ eventId }, "Paystack webhook already processed"); + logger.info({ eventId }, "Paystack webhook already processed within deduplication window"); return NextResponse.json({ received: true, duplicate: true }); } @@ -89,7 +92,7 @@ export async function POST(req: NextRequest) { // Confirm the pending contribution matching this paystack_reference const { rowCount } = await q( `UPDATE contributions - SET status = 'confirmed', tx_hash = $1, updated_at = NOW() + SET status = 'confirmed', updated_at = NOW() WHERE paystack_reference = $1 AND status = 'pending'`, [reference] ); @@ -103,6 +106,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ received: true }); } catch (err) { + if ((err as { code?: string }).code === "23505") { + logger.info({ eventId }, "Duplicate Paystack webhook event detected during insert"); + return NextResponse.json({ received: true, duplicate: true }); + } logger.error({ err, eventId }, "Error processing Paystack webhook"); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } diff --git a/src/components/admin/PayoutsTable.tsx b/src/components/admin/PayoutsTable.tsx index 37772bc..74ca063 100644 --- a/src/components/admin/PayoutsTable.tsx +++ b/src/components/admin/PayoutsTable.tsx @@ -5,6 +5,10 @@ import { format } from "date-fns"; import { CopyableText } from "@/components/ui/CopyableText"; import styles from "../admin.module.css"; +const explorerNetwork = + process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "mainnet" : "testnet"; +const STELLAR_EXPLORER = `https://stellar.expert/explorer/${explorerNetwork}/tx`; + interface PayoutsTableProps { payouts: AdminPayoutRow[]; } diff --git a/src/components/circle/ContributeButton.tsx b/src/components/circle/ContributeButton.tsx index 41e8d74..3e6216b 100644 --- a/src/components/circle/ContributeButton.tsx +++ b/src/components/circle/ContributeButton.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/Button"; import * as vercelAnalytics from "@vercel/analytics"; import { useToast } from "@/components/ui/Toast"; @@ -18,10 +18,13 @@ export function ContributeButton({ circleId, circleName, amountNgn, cycleFrequen const [showModal, setShowModal] = useState(false); const [loading, setLoading] = useState(false); const [feeInfo, setFeeInfo] = useState<{ authorizationUrl: string; platformFee: number } | null>(null); + const [networkFee, setNetworkFee] = useState<{ baseFee: number; priorityFee: number; maxFeeCap: number } | null>(null); + const [feeError, setFeeError] = useState(null); const { toast } = useToast(); - // Stellar network fee estimate (fixed low fee) - const feeEstimate = "~0.00001 XLM"; + const feeEstimate = networkFee + ? `${networkFee.priorityFee} stroops (${(networkFee.priorityFee / 1e7).toFixed(7)} XLM)` + : "Fetching current Stellar fee…"; const handleConfirm = async () => { setLoading(true); @@ -71,6 +74,32 @@ export function ContributeButton({ circleId, circleName, amountNgn, cycleFrequen ); } + useEffect(() => { + if (!showModal || networkFee || feeError) return; + + let isMounted = true; + fetch("/api/stellar/fee") + .then((res) => res.json()) + .then((json) => { + if (!isMounted) return; + if (json.success) { + setNetworkFee(json.data); + setFeeError(null); + } else { + throw new Error(json.error || "Unable to load network fee"); + } + }) + .catch((err) => { + if (!isMounted) return; + setFeeError("Unable to fetch current Stellar fee. Using a conservative estimate."); + console.warn("[ContributeButton] fee fetch failed:", err); + }); + + return () => { + isMounted = false; + }; + }, [showModal, networkFee, feeError]); + return ( <>