Skip to content

Commit 2e39aab

Browse files
author
Stack-Rocks
committed
Initial commit: StackPay stackpay-backend scaffold
0 parents  commit 2e39aab

12 files changed

Lines changed: 370 additions & 0 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
PORT=4000
2+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/stackpay
3+
REDIS_URL=redis://localhost:6379
4+
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
5+
STELLAR_NETWORK=testnet
6+
CONTRACT_PAYMENT=
7+
START_LEDGER=0

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: CI
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
jobs:
7+
test:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4
11+
- uses: actions/setup-node@v4
12+
with: { node-version: 20 }
13+
- run: npm ci
14+
- run: npm run lint
15+
- run: npm run build
16+
- run: npm run test

Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM node:20-alpine AS build
2+
WORKDIR /app
3+
COPY package*.json ./
4+
RUN npm ci
5+
COPY . .
6+
RUN npm run build
7+
FROM node:20-alpine
8+
WORKDIR /app
9+
COPY --from=build /app/node_modules ./node_modules
10+
COPY --from=build /app/dist ./dist
11+
COPY package*.json ./
12+
EXPOSE 4000
13+
CMD ["node", "dist/index.js"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Stack-Rocks
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# stackpay-backend 🪨
2+
3+
> Indexer + REST/WebSocket API for **StackPay** — turns Soroban payment events into queryable requests, balances, and webhooks.
4+
5+
[![CI](https://github.com/Stack-Rocks/stackpay-backend/actions/workflows/ci.yml/badge.svg)](https://github.com/Stack-Rocks/stackpay-backend/actions)
6+
![TypeScript](https://img.shields.io/badge/lang-TypeScript-blue)
7+
![Node](https://img.shields.io/badge/runtime-Node%2020-339933)
8+
![License: MIT](https://img.shields.io/badge/license-MIT-green)
9+
10+
The **off-chain brain** of StackPay. The contract ([`stackpay-contracts`](https://github.com/Stack-Rocks/stackpay-contracts)) is the source of truth; this service keeps a *materialized view* so the dApp and integrators can query requests, statuses, and history without replaying ledgers.
11+
12+
---
13+
14+
## Table of contents
15+
- [What it does](#what-it-does)
16+
- [Architecture](#architecture)
17+
- [Tech stack](#tech-stack)
18+
- [Getting started](#getting-started)
19+
- [Configuration](#configuration)
20+
- [API reference](#api-reference)
21+
- [WebSocket events](#websocket-events)
22+
- [Indexer internals](#indexer-internals)
23+
- [Docker](#docker)
24+
- [Testing](#testing)
25+
- [Deployment](#deployment)
26+
- [Contributing](#contributing)
27+
- [License](#license)
28+
29+
---
30+
31+
## What it does
32+
1. **Indexes** `request_created`, `request_paid`, `request_cancelled` events from Soroban RPC.
33+
2. **Materializes** request state into PostgreSQL.
34+
3. **Serves** a REST API for the dApp and partners.
35+
4. **Streams** live updates over WebSocket.
36+
5. **Notifies** via webhooks on payment.
37+
38+
## Architecture
39+
```
40+
Stellar RPC / Horizon
41+
| poll ledger
42+
v
43+
+------------+ events +--------------+
44+
| Indexer | -------> | PostgreSQL |
45+
| (worker) | +------+-------+
46+
+------------+ |
47+
+-----v------+
48+
| REST API |<-- dApp / partners
49+
| (Express) |
50+
+-----+------+
51+
| publish
52+
+-----v------+
53+
| WebSocket |
54+
| (Socket.IO)|
55+
+------------+
56+
```
57+
58+
## Tech stack
59+
- Node.js 20 + TypeScript, Express, Socket.IO
60+
- PostgreSQL 16 (Prisma), Redis 7 (BullMQ)
61+
- `@stellar/stellar-sdk` for RPC/ledger access
62+
63+
## Getting started
64+
```bash
65+
git clone https://github.com/Stack-Rocks/stackpay-backend.git
66+
cd stackpay-backend
67+
cp .env.example .env
68+
npm install
69+
npx prisma migrate dev
70+
npm run dev
71+
```
72+
API at `http://localhost:4000`.
73+
74+
## Configuration
75+
See [`.env.example`](./.env.example):
76+
77+
| Variable | Description |
78+
| --- | --- |
79+
| `PORT` | HTTP port (default `4000`). |
80+
| `DATABASE_URL` | PostgreSQL connection string. |
81+
| `REDIS_URL` | Redis connection string. |
82+
| `STELLAR_RPC_URL` | Soroban RPC endpoint. |
83+
| `STELLAR_NETWORK` | `testnet` \| `mainnet`. |
84+
| `CONTRACT_PAYMENT` | Deployed PaymentRequest contract address. |
85+
| `START_LEDGER` | Ledger to begin indexing from. |
86+
87+
## API reference
88+
Base: `http://localhost:4000/api/v1`
89+
90+
### Requests
91+
| Method | Path | Description |
92+
| --- | --- | --- |
93+
| `GET` | `/requests` | List requests (filter `payee`, `payer`, `status`, `asset`). |
94+
| `GET` | `/requests/:id` | Get a request + on-chain status. |
95+
| `GET` | `/requests/:id/history` | Event history. |
96+
| `GET` | `/accounts/:address/requests` | Requests involving an address. |
97+
| `GET` | `/accounts/:address/received` | Total received by an address. |
98+
99+
### Webhooks
100+
| Method | Path | Description |
101+
| --- | --- | --- |
102+
| `POST` | `/webhooks` | Register a URL to notify on `request_paid`. |
103+
104+
Example:
105+
```bash
106+
curl http://localhost:4000/api/v1/requests/7
107+
```
108+
```json
109+
{ "id": 7, "payee": "GABC...", "asset": "C...", "amount": "5000000",
110+
"memo": "Invoice #12", "status": "Paid" }
111+
```
112+
113+
## WebSocket events
114+
Connect to `ws://localhost:4000`. Subscribe to a request room:
115+
```ts
116+
socket.emit("subscribe", { requestId: 7 });
117+
socket.on("request:paid", (p) => console.log(p));
118+
```
119+
Events: `request:created`, `request:paid`, `request:cancelled`.
120+
121+
## Indexer internals
122+
- Polls `getEvents` in contiguous ledger windows; resumable via a stored `last_ledger` cursor.
123+
- Maps events to DB upserts keyed by `contract + request_id`.
124+
- Idempotent — safe to run a single worker replica.
125+
126+
## Docker
127+
```bash
128+
docker compose up --build
129+
```
130+
131+
## Testing
132+
```bash
133+
npm run test # vitest (PG via testcontainers)
134+
npm run test:e2e # against local soroban sandbox
135+
```
136+
137+
## Deployment
138+
Stateless API behind any Node host; one long-running indexer worker; managed Postgres + Redis.
139+
140+
## Contributing
141+
Part of the **Stellar Wave Program** on Drips. Look for `Stellar Wave` / `Good first issue`. Run `npm run lint && npm run test` before a PR.
142+
143+
## License
144+
[MIT](./LICENSE).

docker-compose.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
services:
2+
postgres:
3+
image: postgres:16
4+
environment:
5+
POSTGRES_USER: postgres
6+
POSTGRES_PASSWORD: postgres
7+
POSTGRES_DB: stackpay
8+
ports: ["5432:5432"]
9+
redis:
10+
image: redis:7
11+
ports: ["6379:6379"]
12+
api:
13+
build: .
14+
depends_on: [postgres, redis]
15+
env_file: .env
16+
ports: ["4000:4000"]
17+
indexer:
18+
build: .
19+
command: npm run indexer
20+
depends_on: [postgres, redis]
21+
env_file: .env

package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "stackpay-backend",
3+
"version": "0.1.0",
4+
"description": "Indexer & REST/WebSocket API for StackPay.",
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "tsx watch src/index.ts",
9+
"start": "node --import tsx src/index.ts",
10+
"indexer": "node --import tsx src/indexer.ts",
11+
"build": "tsc -p tsconfig.json",
12+
"test": "vitest run",
13+
"lint": "eslint . --ext .ts",
14+
"prisma:generate": "prisma generate",
15+
"prisma:migrate": "prisma migrate dev"
16+
},
17+
"dependencies": {
18+
"@stellar/stellar-sdk": "^12.0.0",
19+
"express": "^4.19.2",
20+
"socket.io": "^4.7.5",
21+
"@prisma/client": "^5.16.0",
22+
"bullmq": "^5.7.0",
23+
"ioredis": "^5.4.1",
24+
"dotenv": "^16.4.5",
25+
"zod": "^3.23.8"
26+
},
27+
"devDependencies": {
28+
"@types/express": "^4.17.21",
29+
"@types/node": "^20.14.0",
30+
"tsx": "^4.16.0",
31+
"typescript": "^5.5.0",
32+
"vitest": "^2.0.0",
33+
"eslint": "^9.5.0",
34+
"prisma": "^5.16.0"
35+
}
36+
}

src/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import express from "express";
2+
import { createServer } from "http";
3+
import { Server } from "socket.io";
4+
import { PrismaClient } from "@prisma/client";
5+
6+
const prisma = new PrismaClient();
7+
const app = express();
8+
app.use(express.json());
9+
10+
app.get("/health", (_req, res) => res.json({ ok: true }));
11+
12+
app.get("/api/v1/requests/:id", async (req, res) => {
13+
const id = Number(req.params.id);
14+
const r = await prisma.paymentRequest.findUnique({ where: { id } });
15+
if (!r) return res.status(404).json({ error: "not found" });
16+
res.json(r);
17+
});
18+
19+
app.get("/api/v1/accounts/:address/received", async (req, res) => {
20+
const addr = req.params.address;
21+
const total = await prisma.paymentRequest.aggregate({
22+
where: { payee: addr, status: "Paid" },
23+
_sum: { amount: true },
24+
});
25+
res.json({ received: total._sum.amount ?? "0" });
26+
});
27+
28+
const http = createServer(app);
29+
const io = new Server(http, { cors: { origin: "*" } });
30+
const PORT = Number(process.env.PORT ?? 4000);
31+
http.listen(PORT, () => console.log(`StackPay API on :${PORT}`));

src/indexer.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { server } from "./rpc.js";
2+
import { PrismaClient } from "@prisma/client";
3+
4+
const prisma = new PrismaClient();
5+
const WINDOW = 1000;
6+
7+
async function lastLedger(): Promise<number> {
8+
const row = await prisma.cursor.findFirst({ where: { id: "indexer" } });
9+
return row?.ledger ?? Number(process.env.START_LEDGER ?? 0);
10+
}
11+
async function saveLedger(l: number) {
12+
await prisma.cursor.upsert({ where: { id: "indexer" }, create: { id: "indexer", ledger: l }, update: { ledger: l } });
13+
}
14+
15+
export async function indexOnce() {
16+
const from = await lastLedger();
17+
const to = Math.min(from + WINDOW, (await server.getLatestLedger()).sequence);
18+
if (from >= to) return;
19+
const events = await server.getEvents({
20+
startLedger: from,
21+
endLedger: to,
22+
filters: [{ type: "contract", contractIds: [process.env.CONTRACT_PAYMENT!] }],
23+
});
24+
for (const ev of events.events) {
25+
// map ev to PaymentRequest upsert (see README "Indexer internals")
26+
console.log("event", ev.type, ev.id);
27+
}
28+
await saveLedger(to);
29+
console.log(`indexed ${from}..${to}`);
30+
}
31+
32+
if (import.meta.url === `file://${process.argv[1]}`) {
33+
setInterval(indexOnce, 5000);
34+
}

src/rpc.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { SorobanRpc, Contract } from "@stellar/stellar-sdk";
2+
import dotenv from "dotenv";
3+
dotenv.config();
4+
5+
const rpcUrl = process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org";
6+
export const server = new SorobanRpc.Server(rpcUrl, { allowHttp: true });
7+
export const network = process.env.STELLAR_NETWORK ?? "testnet";
8+
9+
export function paymentContract(): Contract {
10+
const addr = process.env.CONTRACT_PAYMENT;
11+
if (!addr) throw new Error("CONTRACT_PAYMENT not configured");
12+
return new Contract(addr);
13+
}

0 commit comments

Comments
 (0)