Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/app/api/stellar/fee/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
});
});
18 changes: 18 additions & 0 deletions src/app/api/stellar/fee/route.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<{ baseFee: number; priorityFee: number; maxFeeCap: number }>>({
success: true,
data: {
baseFee,
priorityFee,
maxFeeCap: serverConfig.stellar.maxFeeCap,
},
});
}
4 changes: 4 additions & 0 deletions src/app/api/webhooks/paystack/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
15 changes: 11 additions & 4 deletions src/app/api/webhooks/paystack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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]
);
Expand All @@ -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 });
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/admin/PayoutsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
50 changes: 46 additions & 4 deletions src/components/circle/ContributeButton.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string | null>(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);
Expand Down Expand Up @@ -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 (
<>
<Button variant="accent" onClick={() => setShowModal(true)}>
Expand All @@ -97,7 +126,20 @@ export function ContributeButton({ circleId, circleName, amountNgn, cycleFrequen
</div>
<div className={styles.row}>
<dt>Network Fee</dt>
<dd>{feeEstimate}</dd>
<dd>
{networkFee ? (
<>
{networkFee.priorityFee} stroops ({(networkFee.priorityFee / 1e7).toFixed(7)} XLM)
<span style={{ display: "block", color: "var(--color-text-muted)", fontSize: "0.8rem" }}>
Current base fee {networkFee.baseFee} stroops; capped at {networkFee.maxFeeCap} stroops.
</span>
</>
) : feeError ? (
feeError
) : (
feeEstimate
)}
</dd>
</div>
</dl>

Expand Down
4 changes: 3 additions & 1 deletion src/components/circle/PayoutHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type { PayoutHistoryRow } from "@/app/api/circles/[id]/payouts/route";
import { format } from "date-fns";
import styles from "./PayoutHistory.module.css";

const STELLAR_EXPLORER = "https://stellar.expert/explorer/testnet/tx";
const explorerNetwork =
process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "mainnet" : "testnet";
const STELLAR_EXPLORER = `https://stellar.expert/explorer/${explorerNetwork}/tx`;

interface Props {
circleId: string;
Expand Down
30 changes: 30 additions & 0 deletions src/lib/__tests__/stellar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
horizonServer,
sendUsdcPayment,
validateStellarRecipient,
getCurrentBaseFee,
calculatePriorityFee,
} from "../stellar";
import logger from "../logger";

Expand Down Expand Up @@ -223,4 +225,32 @@ describe("USDC trustline checks", () => {
);
expect(horizonServer.loadAccount).not.toHaveBeenCalled();
});

it("calculates a capped priority fee correctly", () => {
expect(calculatePriorityFee(100)).toBe(200);
expect(calculatePriorityFee(150)).toBe(300);
});

it("uses the configured cap when lower than priority fee", () => {
const { serverConfig } = require("@/server/config");
const originalCap = serverConfig.stellar.maxFeeCap;
serverConfig.stellar.maxFeeCap = 120;

expect(calculatePriorityFee(100)).toBe(120);

serverConfig.stellar.maxFeeCap = originalCap;
});

it("fetches base fee from Horizon fee stats", async () => {
(horizonServer.feeStats as jest.Mock) = jest.fn().mockResolvedValue({
fee_charged: { mode: "123", min: "100", p50: "110" },
});

await expect(getCurrentBaseFee()).resolves.toBe(123);
});

it("falls back to BASE_FEE on fee stats failures", async () => {
(horizonServer.feeStats as jest.Mock) = jest.fn().mockRejectedValue(new Error("Horizon down"));
await expect(getCurrentBaseFee()).resolves.toBe(100);
});
});
30 changes: 28 additions & 2 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ export function hasUsdcTrustline(account: StellarAccountWithBalances): boolean {
);
}

export async function getCurrentBaseFee(): Promise<number> {
try {
const fees = await server.feeStats();
const candidate = Number(
fees.fee_charged?.mode ?? fees.fee_charged?.min ?? fees.fee_charged?.p50 ?? BASE_FEE
);
if (Number.isFinite(candidate) && candidate > 0) {
return candidate;
}
} catch (err) {
logger.warn({ err }, "[stellar] failed to fetch current base fee from Horizon; using default");
}
return Number(BASE_FEE);
}

export function calculatePriorityFee(baseFee: number): number {
const cap = Number.isFinite(serverConfig.stellar.maxFeeCap)
? serverConfig.stellar.maxFeeCap
: Number.MAX_SAFE_INTEGER;
const desired = baseFee * 2;
const fee = Math.min(desired, cap);
return fee < baseFee ? baseFee : fee;
}

/** Error codes that are safe to retry (transient). */
function isRetryable(err: any): boolean {
// 1. Check Horizon response status codes
Expand Down Expand Up @@ -109,7 +133,7 @@ export async function sendUsdcPayment(destination: string, amount: string): Prom
try {
const account = await withFallback((s) => s.loadAccount(keypair.publicKey()));

const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
const tx = new TransactionBuilder(account, { fee, networkPassphrase })
.addOperation(Operation.payment({ destination, asset: USDC, amount }))
.setTimeout(30)
.build();
Expand All @@ -118,7 +142,9 @@ export async function sendUsdcPayment(destination: string, amount: string): Prom
const result = await withFallback((s) => s.submitTransaction(tx));

if (attempt > 1) {
logger.info({ attempt, destination, hash: result.hash }, "[stellar] sendUsdcPayment succeeded after retry");
logger.info({ attempt, destination, hash: result.hash, baseFee, fee }, "[stellar] sendUsdcPayment succeeded after retry");
} else {
logger.info({ destination, hash: result.hash, baseFee, fee }, "[stellar] sendUsdcPayment succeeded");
}

return result.hash;
Expand Down
1 change: 1 addition & 0 deletions src/server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const serverConfig = {
sorobanRpcUrl:
process.env.STELLAR_SOROBAN_RPC_URL ??
"https://soroban-testnet.stellar.org",
maxFeeCap: Number.parseInt(process.env.STELLAR_MAX_FEE_CAP ?? "200", 10) || 200,
},
usdc: {
issuer: process.env.USDC_ISSUER ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
Expand Down