|
| 1 | +import { NextRequest, NextResponse } from "next/server"; |
| 2 | +import { StrKey } from "@stellar/stellar-sdk"; |
| 3 | +import { assertCsrf } from "@/lib/middleware/csrfMiddleware"; |
| 4 | + |
| 5 | +const HORIZON_URL = |
| 6 | + process.env.NEXT_PUBLIC_HORIZON_URL ?? |
| 7 | + (process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" |
| 8 | + ? "https://horizon.stellar.org" |
| 9 | + : "https://horizon-testnet.stellar.org"); |
| 10 | + |
| 11 | +const FRIENDBOT_URL = "https://friendbot.stellar.org"; |
| 12 | + |
| 13 | +/** |
| 14 | + * Dev-only proxy for the Stellar testnet Friendbot. Never available in |
| 15 | + * production — guarded both by NODE_ENV and by the app only rendering the |
| 16 | + * widget that calls this route on testnet. |
| 17 | + * |
| 18 | + * POST /api/dev/faucet { publicKey: string } |
| 19 | + */ |
| 20 | +export async function POST(request: NextRequest) { |
| 21 | + if (process.env.NODE_ENV === "production" || process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet") { |
| 22 | + return NextResponse.json({ error: "Faucet is not available in this environment" }, { status: 403 }); |
| 23 | + } |
| 24 | + |
| 25 | + const csrfError = await assertCsrf(request); |
| 26 | + if (csrfError) return csrfError; |
| 27 | + |
| 28 | + let publicKey: unknown; |
| 29 | + try { |
| 30 | + ({ publicKey } = await request.json()); |
| 31 | + } catch { |
| 32 | + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); |
| 33 | + } |
| 34 | + |
| 35 | + if (typeof publicKey !== "string" || !StrKey.isValidEd25519PublicKey(publicKey)) { |
| 36 | + return NextResponse.json({ error: "A valid Stellar public key is required" }, { status: 400 }); |
| 37 | + } |
| 38 | + |
| 39 | + const friendbotResponse = await fetch(`${FRIENDBOT_URL}?addr=${encodeURIComponent(publicKey)}`); |
| 40 | + |
| 41 | + if (!friendbotResponse.ok) { |
| 42 | + let detail: string | undefined; |
| 43 | + try { |
| 44 | + const body = await friendbotResponse.json(); |
| 45 | + detail = body?.detail; |
| 46 | + } catch { |
| 47 | + // Friendbot didn't return JSON — fall through with no detail. |
| 48 | + } |
| 49 | + |
| 50 | + if (friendbotResponse.status === 400) { |
| 51 | + // Friendbot returns 400 when the account already exists / is already funded. |
| 52 | + return NextResponse.json( |
| 53 | + { alreadyFunded: true, message: detail ?? "This account is already funded." }, |
| 54 | + { status: 200 } |
| 55 | + ); |
| 56 | + } |
| 57 | + |
| 58 | + return NextResponse.json( |
| 59 | + { error: detail ?? "Friendbot request failed" }, |
| 60 | + { status: 502 } |
| 61 | + ); |
| 62 | + } |
| 63 | + |
| 64 | + const account = await fetch(`${HORIZON_URL}/accounts/${publicKey}`).then((res) => |
| 65 | + res.ok ? res.json() : null |
| 66 | + ); |
| 67 | + |
| 68 | + const nativeBalance = account?.balances?.find((b: any) => b.asset_type === "native"); |
| 69 | + const xlm = nativeBalance ? (parseFloat(nativeBalance.balance) || 0).toFixed(7) : null; |
| 70 | + |
| 71 | + return NextResponse.json({ funded: true, xlm }); |
| 72 | +} |
0 commit comments