Skip to content

Commit badeb7d

Browse files
authored
Feat/stellar testnet faucet (#62)
* fix(backend): repair boot-blocking dependency and file gaps from PR #49 PostgreSQL_persistence_layer merged code that requires `pg` and `@testcontainers/postgresql` without adding them to package.json, and referenced `./routes/internal` from app.js without ever committing that file — so a fresh install can't boot the app or run the test suite at all. Also bumps the Stellar SDK compat shim since installed 13.x releases renamed `SorobanRpc` to `rpc`, breaking config/stellar.js on load. Reconstructs routes/internal.js (GET /api/v1/internal/db-stats) from the existing (already-committed) test contract in internal.test.js. * feat(stellar): add testnet friendbot funding endpoint and frontend integration Adds POST /api/v1/stellar/fund so developers can fund a Testnet account via Stellar Friendbot without leaving the app: blocked on Mainnet (403), rate-limited to 1 request per IP per hour (429), and validated against malformed public keys (400). The wallet page shows a "Fund with Testnet XLM" button only when connected to Testnet with a 0 XLM balance, and refreshes the balance automatically after funding. Also adds Jest + React Testing Library to the frontend (previously had no test tooling) to cover the new button's states. Closes #33. * chore(frontend): remove package-lock.json Frontend now uses pnpm (frontend/pnpm-lock.yaml), making the npm package-lock.json redundant. Per maintainer request on the PR.
1 parent a54da22 commit badeb7d

12 files changed

Lines changed: 553 additions & 1 deletion

File tree

.github/workflows/frontend.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,8 @@ jobs:
3333
- name: Run lint
3434
run: npm run lint
3535

36+
- name: Run tests
37+
run: npm test
38+
3639
- name: Run build
3740
run: npm run build

backend/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ npm run dev
3737
| `GET` | `/api/v1/streams/:id` | Get stream by ID |
3838
| `POST` | `/api/v1/streams` | Index a stream |
3939
| `GET` | `/api/v1/stellar/account/:publicKey` | Horizon account info |
40+
| `GET` | `/api/v1/stellar/account/:publicKey/transactions` | Paginated payment history |
4041
| `GET` | `/api/v1/stellar/network` | Network config |
4142
| `GET` | `/api/v1/stellar/fee` | Fee statistics |
43+
| `POST` | `/api/v1/stellar/fund` | Fund a Testnet account via Friendbot |
4244

4345
## Query Parameters — Assets
4446

backend/src/__tests__/routes/stellar.test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
const request = require("supertest");
1111
const app = require("../../app");
1212
const { cacheClear } = require("../../services/transactionService");
13+
const stellarRouter = require("../../routes/stellar");
14+
const stellarConfig = require("../../config/stellar");
1315

1416
// ── Mock the Horizon server ───────────────────────────────────────────────────
1517

@@ -100,6 +102,8 @@ function mockHorizonSuccess(txs, ops) {
100102
beforeEach(() => {
101103
cacheClear();
102104
jest.clearAllMocks();
105+
stellarRouter.resetFundRateLimiter();
106+
stellarConfig.NETWORK = "testnet";
103107
});
104108

105109
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -431,3 +435,105 @@ describe("GET /api/v1/stellar/account/:publicKey/transactions", () => {
431435
expect(res.body.meta.hasMore).toBe(false);
432436
});
433437
});
438+
439+
describe("POST /api/v1/stellar/fund", () => {
440+
const originalFetch = global.fetch;
441+
442+
afterEach(() => {
443+
global.fetch = originalFetch;
444+
});
445+
446+
it("funds a valid Testnet account", async () => {
447+
global.fetch = jest.fn().mockResolvedValue({
448+
ok: true,
449+
json: jest.fn().mockResolvedValue({ hash: "friendbot-tx-hash" }),
450+
});
451+
452+
const res = await request(app)
453+
.post("/api/v1/stellar/fund")
454+
.send({ publicKey: VALID_KEY })
455+
.expect(200);
456+
457+
expect(res.body).toEqual({
458+
publicKey: VALID_KEY,
459+
funded: true,
460+
hash: "friendbot-tx-hash",
461+
});
462+
expect(global.fetch).toHaveBeenCalledWith(
463+
expect.stringContaining(`addr=${VALID_KEY}`)
464+
);
465+
});
466+
467+
it("returns 400 when publicKey is missing", async () => {
468+
const res = await request(app).post("/api/v1/stellar/fund").send({}).expect(400);
469+
expect(res.body.error).toMatch(/publicKey is required/i);
470+
});
471+
472+
it("returns 400 for a malformed publicKey", async () => {
473+
const res = await request(app)
474+
.post("/api/v1/stellar/fund")
475+
.send({ publicKey: "not-a-real-key" })
476+
.expect(400);
477+
expect(res.body.error).toMatch(/valid Stellar public key/i);
478+
});
479+
480+
it("returns 403 when the network is configured for Mainnet", async () => {
481+
stellarConfig.NETWORK = "mainnet";
482+
global.fetch = jest.fn();
483+
484+
const res = await request(app)
485+
.post("/api/v1/stellar/fund")
486+
.send({ publicKey: VALID_KEY })
487+
.expect(403);
488+
489+
expect(res.body).toEqual({
490+
error: "Friendbot is only available on Stellar Testnet.",
491+
});
492+
expect(global.fetch).not.toHaveBeenCalled();
493+
});
494+
495+
it("returns a structured 500 error when Friendbot fails", async () => {
496+
global.fetch = jest.fn().mockResolvedValue({
497+
ok: false,
498+
status: 400,
499+
json: jest.fn().mockResolvedValue({ detail: "createAccountAlreadyExist" }),
500+
});
501+
502+
const res = await request(app)
503+
.post("/api/v1/stellar/fund")
504+
.send({ publicKey: VALID_KEY })
505+
.expect(500);
506+
507+
expect(res.body).toHaveProperty("error");
508+
});
509+
510+
it("returns a structured 500 error when Friendbot is unreachable", async () => {
511+
global.fetch = jest.fn().mockRejectedValue(new Error("network down"));
512+
513+
const res = await request(app)
514+
.post("/api/v1/stellar/fund")
515+
.send({ publicKey: VALID_KEY })
516+
.expect(500);
517+
518+
expect(res.body).toHaveProperty("error");
519+
});
520+
521+
it("returns 429 after the per-IP limit (1 per hour) is exceeded", async () => {
522+
global.fetch = jest.fn().mockResolvedValue({
523+
ok: true,
524+
json: jest.fn().mockResolvedValue({ hash: "friendbot-tx-hash" }),
525+
});
526+
527+
await request(app)
528+
.post("/api/v1/stellar/fund")
529+
.send({ publicKey: VALID_KEY })
530+
.expect(200);
531+
532+
const res = await request(app)
533+
.post("/api/v1/stellar/fund")
534+
.send({ publicKey: VALID_KEY })
535+
.expect(429);
536+
537+
expect(res.body).toHaveProperty("error");
538+
});
539+
});

backend/src/config/stellar.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const horizonUrl =
3737
HORIZON_URLS[NETWORK] ||
3838
HORIZON_URLS.testnet;
3939

40-
const rpcServer = new SorobanRpc.Server(rpcUrl, { allowHttp: false });
40+
const rpcServer = new SorobanRpcNs.Server(rpcUrl, { allowHttp: false });
4141
const horizonServer = new Horizon.Server(horizonUrl);
4242

4343
const CONTRACT_IDS = {

backend/src/routes/stellar.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { Router } = require("express");
22
const { body, query, param } = require("express-validator");
33
const rateLimit = require("express-rate-limit");
4+
const { MemoryStore } = rateLimit;
45
const validate = require("../middleware/validate");
56
const { horizonServer, NETWORK, CONTRACT_IDS } = require("../config/stellar");
67
const {
@@ -13,10 +14,24 @@ const {
1314
submitSignedTx,
1415
} = require("../services/listingService");
1516
const { getAccountTransactions } = require("../services/transactionService");
17+
const { fundAccount } = require("../services/friendbotService");
1618
const { isValidStellarAddress } = require("../utils/stellar");
1719

1820
const router = Router();
1921

22+
// ── Per-IP rate limiter for the Friendbot funding endpoint ────────────────────
23+
// 1 request per IP per 60-minute window. The store is kept as a standalone
24+
// reference so tests can reset it between cases.
25+
const fundLimiterStore = new MemoryStore();
26+
const fundRateLimiter = rateLimit({
27+
windowMs: 60 * 60 * 1000,
28+
max: 1,
29+
store: fundLimiterStore,
30+
standardHeaders: true,
31+
legacyHeaders: false,
32+
message: { error: "Only one Friendbot request is allowed per IP per hour. Please try again later." },
33+
});
34+
2035
// ── Per-key rate limiter for the transactions endpoint ────────────────────────
2136
// Keyed on the publicKey path param so each Stellar address gets its own quota.
2237
// 30 requests per 60-second window per key.
@@ -224,3 +239,6 @@ router.post(
224239
);
225240

226241
module.exports = router;
242+
243+
// Exposed so tests can clear the funding rate limiter between cases.
244+
module.exports.resetFundRateLimiter = () => fundLimiterStore.resetAll();
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* friendbotService.js
3+
*
4+
* Funds a Stellar Testnet account via Stellar Friendbot, the public faucet
5+
* service for the test network. Only ever call this against Testnet — the
6+
* caller (the /fund route) is responsible for the Mainnet restriction.
7+
*/
8+
9+
const FRIENDBOT_URL = "https://friendbot.stellar.org";
10+
11+
/**
12+
* Request Friendbot funding for a Testnet account.
13+
*
14+
* @param {string} publicKey - Stellar G-address to fund
15+
* @returns {Promise<{ publicKey: string, funded: boolean, hash: string|null }>}
16+
*/
17+
async function fundAccount(publicKey) {
18+
let response;
19+
try {
20+
response = await fetch(`${FRIENDBOT_URL}?addr=${encodeURIComponent(publicKey)}`);
21+
} catch {
22+
throw new Error("Unable to reach Stellar Friendbot. Please try again later.");
23+
}
24+
25+
let body = null;
26+
try {
27+
body = await response.json();
28+
} catch {
29+
// Friendbot did not return a JSON body; fall through with a generic message.
30+
}
31+
32+
if (!response.ok) {
33+
const detail = body?.detail || body?.title || "Friendbot funding request failed.";
34+
throw new Error(detail);
35+
}
36+
37+
return {
38+
publicKey,
39+
funded: true,
40+
hash: body?.hash ?? null,
41+
};
42+
}
43+
44+
module.exports = { fundAccount, FRIENDBOT_URL };

frontend/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ http://localhost:3000
6666
| `npm run build` | Create a production build |
6767
| `npm run start` | Start the production server |
6868
| `npm run lint` | Run ESLint |
69+
| `npm test` | Run component tests (Jest + React Testing Library) |
6970

7071
---
7172

frontend/jest.config.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const nextJest = require("next/jest");
2+
3+
const createJestConfig = nextJest({ dir: "./" });
4+
5+
/** @type {import('jest').Config} */
6+
const customJestConfig = {
7+
testEnvironment: "jest-environment-jsdom",
8+
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
9+
moduleNameMapper: {
10+
"^@/(.*)$": "<rootDir>/src/$1",
11+
},
12+
testMatch: ["**/__tests__/**/*.test.[jt]s?(x)"],
13+
};
14+
15+
module.exports = createJestConfig(customJestConfig);

frontend/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "@testing-library/jest-dom";

frontend/src/app/wallet/page.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import WalletFundingButton from "@/components/WalletFundingButton";
5+
6+
interface Balance {
7+
asset_type: string;
8+
balance: string;
9+
}
10+
11+
export default function WalletPage() {
12+
const [publicKeyInput, setPublicKeyInput] = useState("");
13+
const [publicKey, setPublicKey] = useState("");
14+
const [network, setNetwork] = useState("");
15+
const [balanceXLM, setBalanceXLM] = useState<number | null>(null);
16+
const [loading, setLoading] = useState(false);
17+
const [notFound, setNotFound] = useState(false);
18+
19+
useEffect(() => {
20+
fetch("http://localhost:4000/api/v1/stellar/network")
21+
.then((res) => res.json())
22+
.then((data) => setNetwork(data.network))
23+
.catch(() => setNetwork(""));
24+
}, []);
25+
26+
useEffect(() => {
27+
if (publicKey) fetchBalance(publicKey);
28+
}, [publicKey]);
29+
30+
async function fetchBalance(key: string) {
31+
if (!key) return;
32+
setLoading(true);
33+
setNotFound(false);
34+
35+
try {
36+
const res = await fetch(`http://localhost:4000/api/v1/stellar/account/${key}`);
37+
38+
if (res.status === 404) {
39+
// Unfunded accounts don't exist on the network yet — treat as 0 XLM.
40+
setNotFound(true);
41+
setBalanceXLM(0);
42+
return;
43+
}
44+
45+
if (!res.ok) {
46+
setBalanceXLM(null);
47+
return;
48+
}
49+
50+
const data = await res.json();
51+
const native = (data.balances || []).find(
52+
(b: Balance) => b.asset_type === "native"
53+
);
54+
setBalanceXLM(native ? parseFloat(native.balance) : 0);
55+
} catch {
56+
setBalanceXLM(null);
57+
} finally {
58+
setLoading(false);
59+
}
60+
}
61+
62+
return (
63+
<main className="min-h-screen bg-black text-white pt-12 px-6 pb-12">
64+
<div className="max-w-xl mx-auto space-y-8">
65+
<div>
66+
<h1 className="text-3xl font-bold mb-2">Wallet</h1>
67+
<p className="text-zinc-400 text-sm">
68+
View your account balance and fund it on Testnet for development.
69+
</p>
70+
</div>
71+
72+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-4">
73+
<div>
74+
<label className="block text-sm font-semibold mb-2">
75+
Stellar Public Key
76+
</label>
77+
<div className="flex gap-2">
78+
<input
79+
type="text"
80+
value={publicKeyInput}
81+
onChange={(e) => setPublicKeyInput(e.target.value)}
82+
placeholder="G..."
83+
className="flex-1 px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:border-purple-500 text-sm"
84+
/>
85+
<button
86+
onClick={() => setPublicKey(publicKeyInput.trim())}
87+
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg text-sm font-semibold transition-colors"
88+
>
89+
Connect
90+
</button>
91+
</div>
92+
</div>
93+
94+
{publicKey && (
95+
<div className="pt-4 border-t border-zinc-800 space-y-4">
96+
<div className="flex justify-between items-center">
97+
<span className="text-zinc-400 text-sm">Balance</span>
98+
<span className="font-semibold">
99+
{loading
100+
? "Loading…"
101+
: balanceXLM !== null
102+
? `${balanceXLM} XLM`
103+
: "Unable to load balance"}
104+
</span>
105+
</div>
106+
107+
{notFound && (
108+
<p className="text-xs text-zinc-500">
109+
This account doesn&apos;t exist on the network yet — fund it
110+
to activate it.
111+
</p>
112+
)}
113+
114+
{balanceXLM !== null && (
115+
<WalletFundingButton
116+
publicKey={publicKey}
117+
network={network}
118+
balanceXLM={balanceXLM}
119+
onFunded={() => fetchBalance(publicKey)}
120+
/>
121+
)}
122+
</div>
123+
)}
124+
</div>
125+
</div>
126+
</main>
127+
);
128+
}

0 commit comments

Comments
 (0)