Stellar Tags is a payment platform that combines a Soroban smart contract, a Node.js server, and a React dashboard. It is structured as a small mono-repo so each piece can be developed and deployed independently while still working together as a single product.
payment-dashboard/- React + Vite frontend dashboard.stellar-payment-platform/- Node.js server for API and business logic.payment_router/- Rust/Soroban contract.
- Desired specific username
- Fast transfer
- Secured payment flows
The following diagram maps exactly how data flows between the user, Vercel, Railway, and the Stellar network.
[ User / Browser ]
|
| (Vite App hosted on Vercel)
v
[ payment-dashboard ]
(src/App.jsx: Wallet connections & UI)
|
| HTTP API Calls (via VITE_API_BASE)
v
[ stellar-payment-platform ] <---> [ PostgreSQL Database ]
(server.js: Server router on Railway) (via Prisma ORM: User/payment layout)
|
| Stellar Network / RPC
v
[ payment_router ]
(src/lib.rs: Soroban smart contract routing logic)
Data Flow:
- User accesses the
payment-dashboardand connects their Stellar wallet. - The dashboard queries the
stellar-payment-platformserver for user registrations and payment routing information. - The server interacts with its PostgreSQL database (via the Prisma ORM) to resolve usernames to addresses using the endpoints documented below.
- When a payment is initiated, it's routed through the
payment_routerSoroban contract on the Stellar network.
.
├── payment-dashboard/
│ ├── .env # Frontend environment variables
│ └── src/
│ └── App.jsx # Wallet connections and React UI
├── payment_router/
│ └── src/
│ └── lib.rs # Soroban smart contract logic
└── stellar-payment-platform/
├── server.js # Server router (Express API endpoints)
└── prisma/
└── schema.prisma # Prisma schema for the PostgreSQL database
These steps are split by module so you can run only what you need.
cd payment-dashboard
npm install
npm run devThe server uses PostgreSQL as its database, accessed through the Prisma ORM. You need a running Postgres instance (local install, Docker, or a hosted provider) before starting the server.
cd stellar-payment-platform
npm install
# 1. Create your local env file and point DATABASE_URL at your Postgres DB
cp .env.example .env
# then edit .env (see "Database setup" below)
# 2. Apply the schema to your database
npm run prisma:migrate
# 3. Start the server
npm run devThe connection string lives in stellar-payment-platform/.env as DATABASE_URL.
Copy .env.example to .env and set it to your own Postgres database:
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public"For a typical local install that becomes, for example:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/stellar_tags?schema=public"The quickest way to get a local database is Docker:
docker run --name stellar-postgres -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=stellar_tags -p 5432:5432 -d postgres:16Useful Prisma commands (run from stellar-payment-platform/):
| Command | Description |
|---|---|
npm run prisma:migrate |
Create/apply migrations against your dev database |
npm run prisma:deploy |
Apply existing migrations (CI / production) |
npm run prisma:generate |
Regenerate the Prisma Client after schema changes |
npm run prisma:studio |
Open Prisma Studio to browse the data |
.envis gitignored — never commit real credentials. Each contributor keeps their own localDATABASE_URL.
cd payment_router
cargo build# frontend
cd payment-dashboard
npm test
# server
cd ../stellar-payment-platform
npm test
# contract
cd ../payment_router
cargo testTo ensure a seamless local developer installation requiring zero guesswork, please configure the following environment variables in their respective directories:
VITE_API_BASE- The base URL where the frontend expects the Node.js server API to be running (e.g.,http://localhost:5000).
DATABASE_URL- (Required) PostgreSQL connection string used by Prisma (see Database setup).PORT- (Optional) The port for the Node.js server to listen on. Defaults to5000.HORIZON_NETWORK- (Optional) Stellar network for the payment listener:testnet(default) orpublic.STELLAR_TAG_DOMAIN- (Optional) Extra origin to add to the CORS allow-list.LOG_DIR- (Optional) Directory for the rotating log files. Defaults tostellar-payment-platform/logs.LOG_LEVEL- (Optional) Minimum level to record. Defaults toinfoin production anddebugelsewhere.LOG_MAX_SIZE- (Optional) Size at which the active log file rotates. Defaults to20m.LOG_MAX_FILES- (Optional) Retention for rotated files, as a count (30) or an age (14d). Defaults to14d.
The server logs through a shared Winston logger
(stellar-payment-platform/src/logger.js). Import it instead of calling console directly:
const { logger } = require('./src/logger');
logger.info('Registered tag', { correlationId: req.correlationId, tag });
logger.error(err);Log records are written as JSON to stellar-payment-platform/logs/:
application-YYYY-MM-DD.log- everything atLOG_LEVELand above.error-YYYY-MM-DD.log- errors only, so incidents are easy to find.
Both files rotate daily and whenever they pass 20MB, older files are gzipped, and
anything beyond the retention window is deleted, so logs cannot exhaust the disk. A
human-readable copy is also printed to the console (silenced when NODE_ENV=test, which
also disables file output so test runs leave no logs behind).
Every API error leaves the server in one shape, produced by a single terminal
handler (stellar-payment-platform/src/middleware/errorHandler.js):
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid request body",
"details": [{ "field": "username", "message": "username is required" }]
},
"correlation_id": "3f2a…",
"reference_id": "9b41…"
}error.code is the stable part of the contract — branch on it rather than on
the status or the message text, which may be reworded. details appears only
when the failure is field-level. correlation_id is on every error;
reference_id is added on 5xx and matches the logged stack.
| Code | Status | Raised when |
|---|---|---|
INVALID_INPUT |
400 | Malformed query, JSON, or a rejected value |
UNAUTHENTICATED |
401 | Missing or failed signature verification |
FORBIDDEN |
403 | Reserved name, blocked address |
NOT_FOUND |
404 | No such tag, address, or route |
METHOD_NOT_ALLOWED |
405 | Wrong verb on a known path |
CONFLICT |
409 | Username or address already registered |
PAYLOAD_TOO_LARGE |
413 | Body over the 10kb cap |
UNSUPPORTED_MEDIA_TYPE |
415 | Non-JSON body on a JSON endpoint |
VALIDATION_FAILED |
422 | Body failed its schema |
RATE_LIMITED |
429 | Rate limit exhausted |
INTERNAL_ERROR |
500 | Unhandled failure |
UPSTREAM_ERROR |
502 | Horizon or another upstream failed |
SERVICE_UNAVAILABLE |
503 | Database or Redis unreachable, request timeout |
To raise one, throw or pass an ApiError — the handler is the only place that
turns an error into a response:
const { ApiError } = require('./src/errors');
return next(new ApiError('CONFLICT', 'Address already registered'));A 5xx from an unexpected throw always reports the generic message so
internals are never leaked; the real error goes to the log under
reference_id. A message passed deliberately to ApiError is sent as written.
GET /health is exempt: it reports component status ({ status, database, redis }) rather than an API error.
Incoming request bodies and query strings are validated by
zod schemas before any route handler runs. Schemas live in
stellar-payment-platform/src/schemas/index.js, and
src/middleware/validateSchema.js turns them into route middleware:
const { validateSchema } = require('./src/middleware/validateSchema');
const { registerBodySchema, usersQuerySchema } = require('./src/schemas');
app.post('/register', validateSchema({ body: registerBodySchema }), handler);
app.get('/users', validateSchema({ query: usersQuerySchema }), handler);The validated part is replaced with the parsed result, so handlers receive
values that are already trimmed and coerced — req.query.limit is a number,
not a string — and never re-check types themselves.
Failures short-circuit before the handler and respond with the field-level errors, using the status that matches where the bad input came from:
| Failure | Status | Body |
|---|---|---|
Invalid req.body |
422 Unprocessable Entity |
{ success: false, errors: [{ field, message }] } |
Invalid req.query |
400 Bad Request |
{ success: false, errors: [{ field, message }] } |
Two rules are deliberately not in the schemas, because the handlers own them
and answer 400 with their own domain-specific messages: Stellar address
format (checked with StrKey) and memo pairing/format (checked with
validateMemo). page and limit clamp to their bounds rather than being
rejected, so ?limit=1000 still returns the maximum page size.
The Node.js server (stellar-payment-platform/server.js) exposes the following endpoints for username and payment lookups:
Resolves a given username tag to a Stellar address.
- Query Parameter:
q(string) - The username tag to lookup (e.g.,alice*localhost). - Returns: A JSON object with
stellar_address,account_id,memo_type, andmemo. - Status Codes:
200 OK: Address found.400 Bad Request: Missingqparameter.404 Not Found: Name tag not found.500 Internal Server Error: Database lookup failed.
Registers a new username and associates it with a Stellar address.
- Body Parameters (JSON):
username(string) - The desired username.address(string) - The user's Stellar address.
- Returns: A JSON object with registration details
{ ok: true, username, address }. - Status Codes:
200 OK: Registration successful.400 Bad Request: Missingusernameoraddress.409 Conflict: Address or username already registered.500 Internal Server Error: Database lookup or insertion failed.
Resolves a given Stellar address to its registered username.
- Query Parameter:
address(string) - The Stellar address to lookup. - Returns: A JSON object with
usernameandaddress. - Status Codes:
200 OK: Username found.400 Bad Request: Missingaddressparameter.404 Not Found: Username not found for this address.500 Internal Server Error: Database lookup failed.
A simple health check endpoint.
- Returns:
{ status: 'ok' } - Status Codes:
200 OK.
Streams the account's payment history as a CSV download.
- Query Parameters:
address(required) - Stellar public key.order(optional) -desc(default) orasc. - Returns:
text/csvwith aContent-Dispositionattachment header. Columns:id,created_at,type,from,to,amount,asset_type,asset_code,asset_issuer,transaction_hash. - Status Codes:
200 OK: Stream started. Sent chunked, so there is noContent-Length.400 Bad Request: Missing or invalidaddress.404 Not Found: Account not found on Horizon.502 Bad Gateway: Horizon request failed.
Pages of 200 records are fetched from Horizon with its cursor, converted, and
flushed as they arrive, so neither the full result set nor the full CSV is held
in memory: heap use plateaus around 18MB whether the export is 5,000 rows or
100,000. Writes respect socket backpressure, and EXPORT_MAX_PAGES
(default 500) bounds a single export — a truncated export is logged as a
warning. Because the response is committed once streaming starts, a mid-stream
failure can only be logged and the connection cut, since the JSON error
envelope needs unsent headers.
The /payments collection mixes operation types that name the same concepts
differently, so participant and amount columns are normalised per type: a
create_account reports funder/account/starting_balance and an
account_merge reports account/into.
Prometheus scrape endpoint, served in the Prometheus text format. Exempt from the rate limiter so a scraper on a fixed interval is never throttled.
- Returns: all metrics below, prefixed
stellar_tags_. - Status Codes:
200 OK.
| Metric | Type | Description |
|---|---|---|
process_resident_memory_bytes, nodejs_heap_size_used_bytes, ... |
gauge | Memory usage |
process_cpu_user_seconds_total, process_cpu_system_seconds_total |
counter | CPU usage |
http_requests_total |
counter | Requests by method, route, status_code |
http_request_duration_seconds |
histogram | Request latency by method, route, status_code |
db_pool_connections_open |
gauge | Connections open in the Prisma pool |
db_pool_connections_busy |
gauge | Connections executing a query |
db_pool_connections_idle |
gauge | Connections open but unused |
db_pool_queries_waiting |
gauge | Queries queued waiting for a connection |
redis_connections_active |
gauge | 1 while Redis is ready for commands, else 0 |
Memory and CPU come from prom-client's default collectors. The pool gauges read
Prisma's $metrics (which requires the metrics preview feature in
schema.prisma) and report 0 when it is unavailable.
- The React dashboard runs on
http://localhost:3000in dev (Vite) and provides the UI. - The dashboard calls the Node.js API at
http://localhost:5000viaVITE_API_BASEand a/apiproxy. - The Soroban contract handles on-chain payment routing logic.
See LICENSE.