Building the POS network for Stellar in Africa.
Aframp brings Stellar-powered payments into everyday physical commerce, starting in Nigeria. The idea is simple: Nigerians already understand the POS terminal — tap, transfer, withdraw. Aframp adds another familiar option on top of that muscle memory: scan and pay, settled on Stellar, without the merchant or customer ever needing to think about wallets, addresses, or blockchains.
Customer → Scan → Pay → Confirmed
From the merchant's point of view, that's the whole product. Stellar is the settlement layer underneath; Aframp is the experience on top.
Cross-border commerce in Africa is fragmented across national payment systems — multiple currencies, high fees, slow settlement, and merchant onboarding that doesn't travel across borders. Stablecoins and blockchain rails already move value globally, fast and cheap. What's missing is the everyday, physical-commerce bridge between the two. Aframp aims to be that bridge: onboard merchants first, and let consumer demand for Stellar wallets follow.
Longer-term shape of the platform (not all built yet — see Status below):
| Layer | Purpose |
|---|---|
| Aframp Pay | Merchant-facing payments: requests, QR codes, receive Stellar payments, receipts, revenue tracking |
| Aframp Wallet | Consumer wallet built for spending, not just holding |
| Aframp Business | Merchant dashboard: analytics, reconciliation, multi-location, invoices |
| Aframp API | Infrastructure layer for other African fintechs to integrate Stellar payments |
This repository is the backend underneath Aframp Pay.
This section is deliberately literal: everything marked ✅ has been exercised end-to-end against a real Postgres database and, where it touches Stellar, a real testnet transaction — not asserted from reading the code. Everything marked 🚧 is a genuine gap, described honestly rather than smoothed over.
| Capability | Status | Proof |
|---|---|---|
| Merchant signup / login | ✅ Done | Argon2-hashed passwords, real Postgres rows, real HMAC-signed JWTs |
| Every money/data endpoint requires auth | ✅ Done | /wallet, /balance, /transactions, /withdraw* all return 401 with no token or a garbage token — enforced at the type level via Axum's AuthUser extractor, not an ad hoc check |
| Stellar wallet generation | ✅ Done | Each /wallet/create call generates a real ed25519 keypair, encoded as a genuine Stellar G... address — not a placeholder string. Verified against Horizon directly. |
| Wallet key custody | ✅ Done | The private key (S... seed) is AES-256-GCM encrypted before it ever touches the database, keyed by WALLET_ENCRYPTION_KEY. Confirmed the API response never includes it, and the DB column holds ciphertext, not plaintext. |
| Stellar deposit detection | ✅ Done | Background worker polls Horizon's payments feed per merchant wallet, on a timer, handling both create_account (how a brand-new wallet is always first funded on Stellar) and regular payment ops. Proven with a real testnet transaction: funded a generated wallet via Stellar's friendbot, and watched the exact transaction hash and amount land in /transactions and /balance within one poll cycle. |
| Balance ledger | ✅ Done | /balance reflects real detected deposits, not a stub — because the above is real, this is real too. |
| Merchant transaction history | ✅ Done | /transactions lists real detected payments |
| Payment request generation | ✅ Done | POST /payment-requests creates a request with a unique correlation memo and expiry; GET /payment-requests/{id} is deliberately public (no auth) so a customer's wallet can read it before paying |
| QR-based payment (XLM) | ✅ Done | Every XLM payment request includes a SEP-0007 web+stellar:pay URI. Verified live: correct destination address, exact amount conversion (25000000 stroops → 2.5000000 in the URI). Detection now correlates a specific incoming payment to its request via Stellar memo (Horizon queried with join=transactions), not just "something arrived" |
| Withdrawal request + ledger accounting | ✅ Done | /withdraw atomically debits available balance and records the request; insufficient-balance and validation checks are enforced in the same DB transaction |
Paystack Transfers wired into /withdraw |
✅ Done | Real calls to Paystack's Transfers API (resolve account → create recipient → initiate transfer), live-tested against three distinct real failure modes (bad account, amount below minimum, insufficient platform balance) — every one correctly triggers a refund + failed audit-trail row, never a silent loss of the ledger record |
| Gap | What's actually there today |
|---|---|
| QR-based payment (cNGN) | cNGN payment requests get a sep7_uri naming the issuer once CNGNX_ISSUER_ADDRESS is set; until then it's null, because a guessed issuer would silently misdirect a customer's payment. The real issuer address still has to be sourced from the cNGN issuer / Stellar Asset List and configured per deployment |
| Real payout funding ("Stage A") | Paystack Transfers are wired and code-correct (see above), but Paystack's own business-account balance is ₦0 — source: "balance" transfers have nothing to draw from. Confirmed live: a real bank account + valid amount still failed with "Your balance is not enough to fulfil this request." Nothing pays out until there's a real crypto→fiat funding pipeline (e.g. cNGN issuer redemption) |
| Settlement/sweep wallet | Each merchant's Stellar secret is held (encrypted) by the platform, but nothing yet sweeps funds from individual merchant wallets into a platform settlement wallet. STELLAR_SYSTEM_WALLET_ADDRESS is still validated at startup and reserved for this, but isn't used by anything yet |
| TLS | The server speaks plain HTTP by design and must run behind a TLS-terminating reverse proxy. Deployed without one, passwords cross the network in cleartext and no amount of hashing helps — the attacker sees the password before it is hashed. See Deploying behind TLS |
| Login rate limiting | Nothing throttles password guessing against /login yet |
| Token revocation | POST /logout clears the browser cookie, but a JWT already copied elsewhere stays valid for its full 24h. No revocation list, no refresh rotation |
src/stellar/mod.rs |
Vestigial stub from an earlier, abandoned design (single system wallet + memo-based correlation). Not compiled into the binary's active module tree in any meaningful way, superseded by the per-wallet design in src/blockchain/. Left in place as known cleanup debt rather than silently deleted — tracked for removal in #970, and marked #[deprecated] in the source so the debt is visible at the call site rather than only here. |
src/stellar/mod.rs |
Vestigial stub from an earlier, abandoned design (single system wallet + memo-based correlation). Not compiled into the binary's active module tree in any meaningful way, superseded by the per-wallet design in src/blockchain/. Left in place as known cleanup debt rather than silently deleted. |
See PRD.md for the full open-decisions list (payout provider choice, cNGN issuer sourcing, confirmation policy) and roadmap.
STELLAR_SYSTEM_WALLET_ADDRESS is a required environment variable that is validated at startup, but no code path uses it yet. This section explains what it is for so contributors do not remove it thinking it is dead code.
Today every merchant has their own Stellar wallet. Deposits land directly into that wallet and are reflected in the merchant's balance. Withdrawals are sent from the platform's Paystack account. There is currently no automated bridge between the two rails — the crypto side and the fiat side are separate.
The STELLAR_SYSTEM_WALLET_ADDRESS is the address of a platform-owned Stellar wallet that will act as the settlement/sweep point. When the sweep feature is built, funds will move:
merchant Stellar wallet → platform sweep wallet → Paystack → merchant bank account
- The deposit-detection worker detects a confirmed deposit into a merchant wallet.
- A sweep transaction moves the funds (XLM or cNGN) from the merchant wallet to the platform sweep wallet, signed with the merchant wallet's encrypted secret key.
- The platform now holds the funds on its own Stellar address.
- The fiat conversion pipeline (cNGN issuer redemption or XLM → NGN via an exchange) converts the Stellar balance to NGN.
- Paystack's balance is topped up with the NGN equivalent.
- Merchant withdrawal requests draw from Paystack's funded balance.
Step 2 onwards is not implemented. STELLAR_SYSTEM_WALLET_ADDRESS is parsed and validated at startup (a startup-time check that the configured value is a valid Stellar G... address) so a misconfigured key is caught early rather than silently ignored. It is stored in AppState via config.stellar_system_wallet but no service reads it.
Do not remove this variable or the startup validation. It is an intentional placeholder for a known upcoming feature, not dead code.
- A merchant signs up (
/signup) and creates a wallet (/wallet/create), which generates a real Stellar keypair. The public address is returned; the private key is encrypted and stored server-side — this is a custodial design, not "bring your own wallet." - A merchant creates a payment request for an amount (
/payment-requests) and gets back a destination, a correlation memo, and — for XLM — a scannable QR payload. - A background worker polls Horizon for every merchant wallet's address on a timer (
STELLAR_POLL_INTERVAL_SECS), detects incoming payments (with the transaction memo joined in), and moves them through Postgres into that merchant's balance — marking the matching payment requestpaidif the memo correlates to one. - Merchants can withdraw their available balance (
/withdraw) to a Nigerian bank account. The Paystack integration itself is real and tested; what's missing is money to actually send — see gaps above.
The originally-planned memo-based correlation model (one shared system wallet, deposits routed by transaction memo) was replaced with real per-merchant wallets during development — simpler to reason about and matches how a QR-code-per-merchant product actually needs to work.
How data flows from merchant signup to payment detection and payout (#1240).
sequenceDiagram
autonumber
actor M as Merchant
participant API as Aframp API (Axum)
participant DB as Postgres
participant SMS as OTP provider (Termii / mock)
M->>API: POST /signup (email, password, phone)
API->>DB: INSERT user + merchant, store HMAC(OTP)
API->>SMS: send_sms(phone, code)
SMS-->>M: SMS with 6-digit code
M->>API: POST /verify-otp (phone, code)
API->>DB: compare HMAC, mark phone verified
API-->>M: JWT (cookie / bearer)
M->>API: POST /wallet/create
API->>API: generate Stellar keypair
API->>DB: INSERT wallet (public address, AES-256-GCM encrypted seed)
API-->>M: wallet address
flowchart LR
subgraph Merchant side
M[Merchant] -->|POST /payment-requests| API
API -->|destination + memo + QR| M
end
C[Customer] -->|scans QR, sends XLM| ST[(Stellar network)]
subgraph Background worker
W[Deposit worker<br/>every STELLAR_POLL_INTERVAL_SECS]
end
W -->|poll payments per wallet| HZ[Horizon API]
HZ --> ST
W -->|INSERT payment, credit pending| DB[(Postgres<br/>payments / balances)]
W -->|after STELLAR_MIN_CONFIRMATIONS:<br/>pending → available,<br/>mark request paid| DB
M -->|GET /balance| API[Aframp API]
API --> DB
M -->|POST /withdraw| API
API -->|debit available WHERE available >= amount| DB
API -->|create transfer| PS[Paystack]
PS -->|NGN payout| BANK[Merchant bank account]
PS -->|webhook: success / failed| API
API -->|on failure: refund via apply_delta| DB
- Rust + Axum — HTTP API
- PostgreSQL via sqlx — runtime-checked queries
- Stellar (Horizon) — settlement network; deposit detection via reqwest
- ed25519-dalek + stellar-strkey — real Stellar keypair generation
- aes-gcm — encrypts wallet private keys at rest
- JWT (jsonwebtoken) + Argon2 — auth and password hashing
- Tokio — async runtime, including the background Stellar polling worker
- Paystack Transfers API — Nigerian bank payouts (
src/payments/paystack.rs)
- Rust (stable, 2021 edition)
- PostgreSQL — either a local instance, or Docker (see below; this is the path actually verified during development on a machine where the local Postgres cluster wasn't running)
cp .env.example .envFill in .env:
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
yes | — | Postgres connection string |
APP_ENV |
no | development |
Set to production in production deployments. When production, startup refuses to run with COOKIE_SECURE=false — see Deploying behind TLS |
APP_BIND_ADDR |
no | 127.0.0.1:3000 |
Address the HTTP server binds to |
JWT_SECRET |
yes | — | Secret used to sign merchant session tokens. Generate with openssl rand -hex 32 |
WEBHOOK_SECRET |
yes | — | Secret used to verify inbound provider webhooks. Generate with openssl rand -hex 32 |
WALLET_ENCRYPTION_KEY |
yes | — | AES-256-GCM key encrypting Stellar wallet secrets at rest. Generate with openssl rand -hex 32 (must decode to exactly 32 bytes) |
WALLET_ENCRYPTION_KEY_NEW |
only for --rotate-key |
— | The key to rotate WALLET_ENCRYPTION_KEY to. See "Rotating WALLET_ENCRYPTION_KEY" below |
STELLAR_SYSTEM_WALLET_ADDRESS |
yes | — | Reserved for a future platform settlement/sweep wallet. Validated at startup but not used by deposit detection today (see Status) |
STELLAR_HORIZON_URL |
no | https://horizon-testnet.stellar.org |
Horizon endpoint to poll |
STELLAR_POLL_INTERVAL_SECS |
no | 60 |
How often the deposit-detection worker polls Horizon, per wallet |
STELLAR_MIN_CONFIRMATIONS |
no | 1 |
Poll passes a deposit must be seen in before it moves from pending to available (verified → confirmed) |
CNGNX_ISSUER_ADDRESS |
no | — | Stellar account ID (G...) that issues cNGN. Source it from the cNGN issuer's own stellar.toml / the Stellar Asset List entry for cNGN — never guess it. When set, cNGN payment requests get a sep7_uri with asset_code=cNGN&asset_issuer=...; when unset, their sep7_uri is null. Startup fails if the value isn't a valid account ID |
PAYSTACK_SECRET_KEY |
yes | — | Paystack Dashboard → Settings → API Keys & Webhooks. sk_test_... for dev, sk_live_... only once the business is verified/activated for Transfers (see PRD.md §9.1) |
OTP_HMAC_SECRET |
yes | — | HMAC secret used to store OTP codes securely. A bare hash of a 6-digit code is trivially reversible — this secret makes it infeasible. Generate with openssl rand -hex 32. Never reuse this value for anything else |
OTP_PROVIDER |
no | termii |
mock logs the OTP to stdout instead of sending it (read it from cargo run output — no Termii account needed). termii sends a real SMS. Always use mock in local dev |
TERMII_API_KEY |
no | — | Termii Dashboard → API Keys. Required only when OTP_PROVIDER=termii |
TERMII_SENDER_ID |
no | Aframp |
Sender name shown on the SMS. Must match an ID registered and approved in the Termii dashboard. Ignored when OTP_PROVIDER=mock |
CORS_ALLOWED_ORIGINS |
no | http://localhost:3001 |
Comma-separated browser origins allowed to call the API. Never mirrored back — an unlisted origin fails preflight |
CORS_ALLOWED_ORIGINS |
no | http://localhost:3001 |
Comma-separated browser origins allowed to call the API. Never mirrored back — an unlisted origin fails preflight. Each value is validated at startup (scheme http/https, host, no path/query, no * wildcard) — the app refuses to start rather than accept a malformed origin |
COOKIE_SECURE |
no | true |
Whether the session cookie carries Secure. Leave on: browsers treat localhost as a secure context, so the default works in dev too. Only turn it off for a non-localhost plain-HTTP setup, which you should not have |
COOKIE_SAME_SITE |
no | lax |
lax or none. none (which forces Secure) is only for a frontend on a different origin, and lets the session ride cross-site requests — prefer serving the frontend same-origin |
# 1. Generate the new key
openssl rand -hex 32
# 2. Re-encrypt every wallet's secret from the old key to the new one
WALLET_ENCRYPTION_KEY=<current-key> \
WALLET_ENCRYPTION_KEY_NEW=<new-key> \
DATABASE_URL=<target-database> \
cargo run -- --rotate-key
# 3. On success, update the deployment's WALLET_ENCRYPTION_KEY to <new-key>
# and restart. Discard the old key.No downtime is required for this today: nothing in the running server decrypts a
wallet secret (withdrawals settle through Paystack, not by signing with the
wallet's own key), so there is no live reader for the rotation to race
against. See the doc comment on src/rotate_key.rs for what would need to
change once that's no longer true — e.g. once a sweep-wallet signer exists.
docker run -d --name aframp-postgres \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=aframp \
-p 5432:5432 postgres:16
for f in migrations/*.sql; do
docker exec -i aframp-postgres psql -U postgres -d aframp < "$f"
done
cargo runcargo install sqlx-cli --no-default-features --features rustls,postgres
sqlx database create
sqlx migrate run
cargo runThe server starts on APP_BIND_ADDR (default http://127.0.0.1:3000) and spawns the Stellar deposit-detection worker in the background.
See command.txt for a copy-paste reference of every command used to run, test, and interact with this backend — curl calls for every endpoint, secret generation, and DB lifecycle commands.
Deploying to production? See DEPLOY.md for the full guide: prerequisites, secrets, the environment-variable checklist, the single-instance constraint, the cron keepalive, Caddy, and post-deploy verification. The summary below is kept for quick reference.
This repository includes a Cloudflare Container deployment scaffold. It runs the existing Axum binary in a single named container, with the aframp-backend Worker acting as the public HTTPS proxy. The single instance is intentional: multiple instances would each run the Stellar polling loop and duplicate work.
Before deploying:
-
In
wrangler.jsonc, replaceCORS_ALLOWED_ORIGINSwith the exact HTTPS origin of the frontend. If the frontend is on another origin, setCOOKIE_SAME_SITEtonone; it requires the existingCOOKIE_SECURE=truesetting. -
Store each required value as a Cloudflare Worker secret. Do not copy
.envinto source control orwrangler.jsonc:npx wrangler secret put DATABASE_URL npx wrangler secret put JWT_SECRET npx wrangler secret put WEBHOOK_SECRET npx wrangler secret put WALLET_ENCRYPTION_KEY npx wrangler secret put STELLAR_SYSTEM_WALLET_ADDRESS npx wrangler secret put PAYSTACK_SECRET_KEY npx wrangler secret put OTP_HMAC_SECRET # Required when OTP_PROVIDER=termii (the production default). Skip only if # you deliberately set OTP_PROVIDER=mock for a non-production deploy. npx wrangler secret put TERMII_API_KEY npx wrangler secret put TERMII_SENDER_ID
These match every required var in
AppConfig::from_env():DATABASE_URL,JWT_SECRET,WEBHOOK_SECRET,STELLAR_SYSTEM_WALLET_ADDRESS,WALLET_ENCRYPTION_KEY,PAYSTACK_SECRET_KEY, andOTP_HMAC_SECRETare always required at startup.TERMII_API_KEYandTERMII_SENDER_IDare required wheneverOTP_PROVIDERistermii(default). Optional vars with defaults (APP_BIND_ADDR,STELLAR_HORIZON_URL,STELLAR_POLL_INTERVAL_SECS,OTP_PROVIDER,CORS_ALLOWED_ORIGINS,COOKIE_SECURE,COOKIE_SAME_SITE) can stay as Worker plain-text vars inwrangler.jsonc. -
Run the SQL migrations against the production database, then deploy:
cargo install sqlx-cli --no-default-features --features rustls,postgres sqlx migrate run npm install npx wrangler deploy
The Worker has a five-minute Cron Trigger that calls /health. This keeps the container awake so its 60-second Stellar polling loop continues when there is no API traffic. Cloudflare may take several minutes to provision the first container after deployment.
| Piece | Where | Role |
|---|---|---|
| Path constant | scripts/health_path.ts (HEALTH_CHECK_PATH = "/health") |
Single documented source of truth for contributors |
| Rust liveness route | src/lib.rs → GET /health → 204 No Content |
What the cron must hit |
| Worker cron | cloudflare/worker.ts → scheduled() fetches http://container.internal/health |
Wakes the container every tick |
| Schedule | wrangler.jsonc → triggers.crons: ["*/5 * * * *"] |
Every five minutes |
| Contract test | tests/health_keepalive.rs |
Fails CI if the Worker / Rust path / constant drift |
| Monitor | .github/workflows/health-monitor.yml |
Every 10 minutes probes HEALTH_CHECK_URL/health (repo variable); alerts if it stops responding |
Dependency: if /health is renamed or stops returning a success status (204, or 200 behind some proxies), the keep-alive cron no longer proves the container is alive and Stellar polling can stall when there is no API traffic. Update the constant, Worker, Rust route, and monitor together.
Set the GitHub Actions repository variable HEALTH_CHECK_URL to your deployed Worker origin (no trailing slash) to enable the 10-minute monitor.
Integration tests need a separate database, and silently skip with a false "ok" if it isn't configured — this bit us during development (a full green cargo test run had actually tested nothing). Always set TEST_DATABASE_URL before trusting the result:
docker exec -i aframp-postgres psql -U postgres -c "CREATE DATABASE aframp_test;"
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/aframp_test cargo testEvery service function and the deposit worker's poll_once / process_deposit carry a
tracing span, so a slow withdrawal is distinguishable from a slow
deposit and an API call can be correlated with the database work it caused. Turn the spans on with
RUST_LOG:
# The whole app at debug, spans included.
RUST_LOG=aframp=debug cargo run
# Just the deposit worker and the withdrawal service.
RUST_LOG=aframp::blockchain=debug,aframp::services::withdrawals=debug cargo runSpans use skip_all, so no argument is recorded unless it is named explicitly in fields(...).
Only identifiers, amounts and asset codes are named there — bank account numbers, passwords, and
wallet secret seeds are never recorded on a span, even at trace level.
RUST_LOG=debug(with no target filter) also turns onreqwest's own logging, which prints outgoing request headers — including theAuthorization: Bearer sk_live_…header carrying the Paystack secret key. Prefer a target-scoped filter such asaframp=debugin any environment holding a live key.
Every response carries an X-Request-ID header — a UUID either generated for the request or echoed
back if the client already sent a well-formed one. Include it when reporting a bug or searching logs;
it's on the TraceLayer span (request_id) for every log line the request produces, so it correlates
a support ticket straight to the exact server-side trace.
An incoming X-Request-ID that isn't a valid UUID is discarded and replaced rather than trusted
as-is — a client can't use it to inject arbitrary values into the logs.
Building a frontend? Start with API.md — the full contract with request/response examples, error semantics, the stroops convention, polling guidance, and the known gaps worth designing around. openapi.yaml is the machine-readable version; generate a typed client from it rather than hand-writing calls.
The table below is a quick index.
Authenticated routes accept either the aframp_session HttpOnly cookie (set by /signup and /login — what browsers should use) or Authorization: Bearer <token> with the JWT from the same responses (for scripts and API clients). See API.md.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/signup |
— | Validates credentials and sends an OTP — does not create the account yet. Body: { email, password (min 8 chars), name, phone_number } (Nigerian mobile, any common format). Returns { challenge_id, expires_in_secs }. The account is only materialized once /verify-otp succeeds |
POST |
/login |
— | Verifies the password; a phone-verified account gets a fresh OTP challenge (same response shape as signup) instead of a session — full 2FA, every login. An account with no phone on file (only possible pre-OTP-rollout) logs in synchronously, unchanged |
POST |
/verify-otp |
— | { challenge_id, code } → on success, the only place a session is ever issued: same response as the old /signup//login, Set-Cookie included. Codes expire in 10 minutes, lock after 5 wrong attempts, and re-POSTing /signup or /login (not a separate endpoint) is the resend path — rate-limited to one send per 60s and 5 per hour per phone |
POST |
/logout |
— | Expire the session cookie. Ends the browser session; does not revoke the JWT |
GET |
/me |
✅ | Current user + merchant profile. The JWT carries only ids, so a reloaded frontend needs this to render identity |
POST |
/wallet/create |
✅ | Generate a real Stellar wallet for the authenticated merchant. Body: { network? } (defaults to stellar) |
GET |
/wallet |
✅ | Get the merchant's wallet |
GET |
/balance |
✅ | List balances by asset, reflecting real detected Stellar deposits |
GET |
/transactions?limit= |
✅ | List the merchant's detected payments (default limit 50, max 200) |
POST |
/payment-requests |
✅ | Create a payment request for the authenticated merchant's wallet. Body: { amount_stroops, asset? (default XLM), expires_in_secs? (60–86400, default 900) } |
GET |
/payment-requests?limit= |
✅ | List the merchant's own requests, newest first (default 50, max 200) |
GET |
/payment-requests/{id} |
— | Deliberately public — a customer's wallet needs to read amount/destination/status before paying. Includes sep7_uri for XLM requests (null for cNGN — no issuer address configured yet) |
GET |
/payment-requests/{id}/status |
— | Public lightweight poll — { status, paid_at? } with Cache-Control: public, max-age=5. Prefer this after the customer has submitted payment |
GET |
/payment-requests/{id} |
— | Deliberately public — a customer's wallet needs to read amount/destination/status before paying. Includes sep7_uri for XLM requests, and for cNGN requests when CNGNX_ISSUER_ADDRESS is configured (null otherwise) |
POST |
/withdraw |
✅ | Debit available balance, record a withdrawal, and call Paystack Transfers. Body: { amount_stroops, asset? (cNGN only), bank_code, account_number }. Note: the Paystack call is real, but nothing actually pays out yet — Paystack's own account balance is unfunded (Stage A gap) — see Status |
GET |
/withdrawals?limit= |
✅ | List the merchant's withdrawals, including failure_reason on failed ones |
GET |
/health |
— | Liveness check (204 No Content) |
GET |
/admin |
— | Static admin dashboard shell (see Admin access below) |
GET |
/admin/overview |
🔒 admin | Counts + balances/status breakdowns across every merchant |
GET |
/admin/merchants, /admin/users, /admin/wallets, /admin/transactions, /admin/withdrawals, /admin/payment-requests |
🔒 admin | System-wide list views (?limit=, default 100, max 500), each joined with owner/merchant context |
POST |
/webhooks/termii |
🔏 signed | Termii's SMS delivery-status callback — not for merchant/admin use, see Termii webhook below |
POST |
/webhooks |
✅ | Register a URL to receive this merchant's outbound events. Body: { url } (absolute http/https). 409 if already registered. See Merchant webhooks below |
GET |
/webhooks |
✅ | List this merchant's registered webhook URLs |
/verify-otp — the only endpoint that ever issues a session — returns:
{ "token": "...", "user_id": "...", "merchant_id": "..." }…alongside a Set-Cookie: aframp_session=<jwt>; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400; Secure. A browser frontend should use the cookie and ignore the token field — copying it into localStorage puts the session within reach of any XSS on the page. /signup and /login themselves return only { challenge_id, expires_in_secs } — no cookie, nothing to store, until the code is verified.
Local dev never needs a real Termii account: set OTP_PROVIDER=mock (see .env.example) and the code is logged via tracing::info! instead of sent, so you can read it straight out of cargo run's stdout.
OTP / Termii credential handling (API key in request body, HTTPS-only send URL, Token API notes) is documented in docs/SECURITY.md.
There's no self-service way to become an admin — flag a user directly in Postgres:
UPDATE users SET is_admin = true WHERE email = 'you@example.com';The is_admin flag is also baked into the JWT at login, so re-login after granting it. Revoking it takes effect immediately: every admin request re-checks users.is_admin in the database, so an outstanding token stops working for /admin/* on its next request even though it hasn't expired. Then open /admin in a browser and sign in with that account.
Once a deposit is credited to a merchant's balance, Aframp POSTs a payment.confirmed event to every URL the merchant registered with POST /webhooks:
{
"type": "payment.confirmed",
"created_at": "2026-09-26T12:00:00Z",
"data": { "payment_id": "...", "merchant_id": "...", "wallet_address": "G...", "tx_hash": "...", "amount_stroops": 10000000, "asset": "cNGN", "network": "stellar" }
}Each request carries X-Aframp-Event: payment.confirmed and X-Aframp-Signature: the hex HMAC-SHA256 of the raw request body keyed with WEBHOOK_SECRET. Receivers should recompute it over the exact bytes received and compare in constant time. A delivery that doesn't get a 2xx is retried with exponential backoff (5 attempts, 1s → 2s → 4s → 8s); every attempt's outcome is recorded in webhook_deliveries (status pending / delivered / failed, attempts, last_error).
Register https://<your-deployed-host>/webhooks/termii at termii.com/account/webhook/config — it's one account-wide setting, not something passed per API call. Termii POSTs SMS delivery-status events (Delivered, Message Failed, Rejected, etc. — see their docs) there, signed with X-Termii-Signature (HMAC-SHA512). This endpoint verifies that signature and logs the event; it doesn't yet correlate a delivery status back to the otp_challenges row that sent it — that'd need the provider's message_id captured at send time and a column to hold it, which nothing does today.
Their docs don't say which secret signs the header — "your secret key," unspecified. By default this verifies against TERMII_API_KEY, the only secret both sides are known to share. If your Termii dashboard shows a separate webhook/secret key, set TERMII_WEBHOOK_SECRET to it and that is used instead (unset or empty falls back to TERMII_API_KEY).
Diagnosing a mismatch: a wrong key doesn't fail loudly on Termii's side — every genuine event just gets a 403. Each rejected request logs termii webhook: signature verification failed at warn (with signature_present, so a missing header is distinguishable from a wrong key). If those appear for real delivery events:
- Confirm the header is arriving at all (
signature_present=falsemeans a proxy is strippingX-Termii-Signature). - Try the other key: set
TERMII_WEBHOOK_SECRETto the dashboard's secret key if you were relying onTERMII_API_KEY, or unset it if you had set it. - Check nothing between Termii and the API re-serializes the JSON body — the HMAC-SHA512 is over the exact raw bytes.
Known gap since OTP shipped: the /admin page's login form only knows the old one-step /login → cookie flow. An admin account created before OTP existed (no phone_number on the row) still logs in through it fine. An admin account with a phone number now gets a challenge response back instead of a session, and the dashboard has no code-entry step to handle that — it'll appear to fail to log in. Until the dashboard is updated, keep your admin account phone-less, or drive /login → /verify-otp manually with curl/Postman and paste the resulting cookie in by hand.
Every merchant wallet's Stellar secret seed is stored AES-256-GCM encrypted in wallets.secret_key_encrypted under WALLET_ENCRYPTION_KEY. One leaked key exposes every wallet, so:
-
Every decryption is audited. Code that needs a secret must go through
services::wallets::decrypt_secret(db, wallet_id, key, purpose), which writes a row towallet_secret_access_log(wallet_id,purpose,accessed_at) before decrypting. Review it for decryptions you can't explain:SELECT wallet_id, purpose, accessed_at FROM wallet_secret_access_log ORDER BY accessed_at DESC LIMIT 100;
-
The key can be rotated.
rotate_wallet_keyre-encrypts every secret under a new key in a single transaction: if any secret fails to decrypt under the current key, nothing changes. Each re-encryption is logged with purposekey_rotation.
-
Generate the new key:
openssl rand -hex 32. -
Stop the API and the deposit worker so nothing writes wallets during the rotation, and back up the
walletstable. -
Run the rotation with the current key and the new one:
DATABASE_URL=postgres://... \ WALLET_ENCRYPTION_KEY=<current key> \ NEW_WALLET_ENCRYPTION_KEY=<new key> \ cargo run --release --bin rotate_wallet_key
-
Set
WALLET_ENCRYPTION_KEYto the new key everywhere it's deployed (e.g.npx wrangler secret put WALLET_ENCRYPTION_KEY) and restart the API. -
Destroy every copy of the old key. If you are rotating because the old key may have leaked, treat all wallet secrets as exposed as well: rotating the encryption key doesn't change the Stellar keys themselves, so funds should be moved to newly generated wallets.
The server intentionally does not terminate TLS — it speaks plain HTTP and must run behind a TLS-terminating reverse proxy such as Caddy or nginx. It binds to 127.0.0.1:3000 by default, which only accepts connections from the same host, so the proxy is not optional in production. This is also what makes the Secure session cookie meaningful, since browsers won't send a Secure cookie over plain HTTP to a non-localhost host.
Set APP_ENV=production in production. At startup, if APP_ENV=production and COOKIE_SECURE=false, the server logs a loud error and refuses to start — this catches the case where the reverse proxy/TLS setup is missing or misconfigured and the session cookie would otherwise be sent unencrypted. Leave COOKIE_SECURE at its default (true) once TLS is terminated in front of the app.
An equivalent nginx server block, if you'd rather not use Caddy:
server {
listen 443 ssl;
server_name pay.example.com;
ssl_certificate /etc/letsencrypt/live/pay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pay.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /srv/aframp-frontend;
try_files $uri /index.html;
}
}Or, with Caddy, which provisions and renews the certificate itself. Either way, serve the frontend and the API from one origin: then browser requests are same-origin, CORS stops applying entirely, and the SameSite=Lax cookie works without the cross-site relaxation that reintroduces CSRF. A minimal Caddy config:
pay.example.com {
encode gzip
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
handle /api/* {
uri strip_prefix /api
reverse_proxy 127.0.0.1:3000
}
handle {
root * /srv/aframp-frontend
try_files {path} /index.html
file_server
}
}Caddy provisions and renews the certificate itself. With this, the frontend calls /api/me as a relative path, sends no Authorization header, and never touches the token.
If the frontend genuinely must live on a separate origin, set CORS_ALLOWED_ORIGINS to it and COOKIE_SAME_SITE=none — and understand that you have then opted into cross-site cookie sending and need to think about CSRF. (Today's saving grace is that every mutating route uses the JSON extractor, so a cross-site HTML form POST can't reach one; don't rely on that if you add form-encoded endpoints.)
JWT_SECRET, WEBHOOK_SECRET, WALLET_ENCRYPTION_KEY and PAYSTACK_SECRET_KEY are held as
SecretString, whose Debug and Display both print [REDACTED]. AppConfig therefore stays
safe to {:?}, and PaystackProvider has a hand-written Debug for the same reason — a
tracing field pointing at either one cannot leak a key.
The one path that still can is not ours: the Paystack key travels as
Authorization: Bearer sk_… on every outgoing call, and reqwest logs request headers verbatim
under its own targets. An unscoped filter is enough to capture a live key:
RUST_LOG=debug # ⚠ includes reqwest — logs the Paystack key
RUST_LOG=reqwest=debug # ⚠ same
RUST_LOG=aframp=debug # ✅ this crate only, spans includedThe client is built with connection_verbose(false) so reqwest's wire-level logging stays off
whatever the filter says, but the header-level logging is the filter's call — always scope
RUST_LOG to aframp in any environment holding an sk_live_ key. If a key does reach a log,
rotate it in the Paystack dashboard; the value in the log is usable as-is.
src/
api/ HTTP handlers (thin — validation + calling services)
auth/ JWT signing/verification, password hashing, auth extractor
blockchain/ Stellar integration: keypair generation, wallet-secret encryption,
Horizon deposit polling, and the background worker that drives it
models/ Request/response and row types
services/ Business logic (users, wallets, balances, payments, payment_requests, withdrawals)
payments/ PaymentProvider abstraction — real PaystackProvider + a MockProvider for tests
migrations/ SQL schema migrations (sqlx)
tests/ Integration tests (auth, wallet, payment request, withdrawal flows)
examples/ prove_payment_loop.rs — end-to-end demo harness (real testnet payment)
API.md Full API contract for frontend consumers
openapi.yaml Machine-readable spec (generate a typed client from this)
command.txt Copy-paste command reference for running/testing/interacting with the backend
Nigeria has a large digital-payments ecosystem and near-universal familiarity with POS and bank-transfer payments — the exact behavior Aframp is extending rather than replacing. The plan is to prove the merchant payment experience narrowly here, then expand to other African markets and cross-border corridors.
Found a vulnerability? Please do not open a public issue. See SECURITY.md for how to report it privately and what response times to expect.
The docs/adr/ directory documents the reasoning behind key design choices —
not just what was built, but why, and what was ruled out. Read these before
making changes that touch the areas they cover.
| ADR | Decision |
|---|---|
| ADR-001 | Per-merchant custodial wallets (vs. single system wallet + memo correlation) |
| ADR-002 | Phone OTP gates account creation, not just session issuance; full 2FA on login |
| ADR-003 | HMAC-SHA256 for OTP codes (not bcrypt/Argon2 — 6-digit space makes KDFs useless) |
| ADR-004 | Commit withdrawal record before calling Paystack; compensate on failure |
This project is under active MVP development — expect the API and schema to change as real payout funding, a real cNGN issuer address, and settlement sweeping land. Open an issue or PR against dev-backend.
Before opening a PR, read CONTRIBUTING.md for the dev
setup, the dev-backend contribution branch, protected paths, commit/PR
conventions, and the checklist reviewers expect.