Skip to content

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,391 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Aframp

codecov CI

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.

Why

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.

Status: real progress, not aspiration

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.

What's real today

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

What's still a stub or missing

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.

Settlement/sweep wallet architecture

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.

What the sweep wallet is for

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

Planned sweep flow

  1. The deposit-detection worker detects a confirmed deposit into a merchant wallet.
  2. 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.
  3. The platform now holds the funds on its own Stellar address.
  4. The fiat conversion pipeline (cNGN issuer redemption or XLM → NGN via an exchange) converts the Stellar balance to NGN.
  5. Paystack's balance is topped up with the NGN equivalent.
  6. Merchant withdrawal requests draw from Paystack's funded balance.

Current state

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.

How it actually works today

  • 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 request paid if 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.

Architecture

How data flows from merchant signup to payment detection and payout (#1240).

Signup and OTP verification

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
Loading

Payment detection and withdrawal

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
Loading

Tech stack

  • 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)

Getting started

Prerequisites

  • 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)

Setup

cp .env.example .env

Fill 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

Rotating WALLET_ENCRYPTION_KEY

# 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.

Quick start (Docker Postgres)

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 run

Alternative: sqlx-cli against an existing Postgres

cargo install sqlx-cli --no-default-features --features rustls,postgres
sqlx database create
sqlx migrate run

cargo run

The 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.

Cloudflare Containers

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:

  1. In wrangler.jsonc, replace CORS_ALLOWED_ORIGINS with the exact HTTPS origin of the frontend. If the frontend is on another origin, set COOKIE_SAME_SITE to none; it requires the existing COOKIE_SECURE=true setting.

  2. Store each required value as a Cloudflare Worker secret. Do not copy .env into source control or wrangler.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, and OTP_HMAC_SECRET are always required at startup. TERMII_API_KEY and TERMII_SENDER_ID are required whenever OTP_PROVIDER is termii (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 in wrangler.jsonc.

  3. 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.

Container keep-alive (/health) — #1130

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.

Running tests

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 test

Structured logging

Every 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 run

Spans 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 on reqwest's own logging, which prints outgoing request headers — including the Authorization: Bearer sk_live_… header carrying the Paystack secret key. Prefer a target-scoped filter such as aframp=debug in any environment holding a live key.

Request correlation

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.

API reference

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.

Admin access

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.

Merchant webhooks

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).

Termii webhook

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:

  1. Confirm the header is arriving at all (signature_present=false means a proxy is stripping X-Termii-Signature).
  2. Try the other key: set TERMII_WEBHOOK_SECRET to the dashboard's secret key if you were relying on TERMII_API_KEY, or unset it if you had set it.
  3. 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.

Wallet key custody and rotation

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 to wallet_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_key re-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 purpose key_rotation.

Rotating WALLET_ENCRYPTION_KEY

  1. Generate the new key: openssl rand -hex 32.

  2. Stop the API and the deposit worker so nothing writes wallets during the rotation, and back up the wallets table.

  3. 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
  4. Set WALLET_ENCRYPTION_KEY to the new key everywhere it's deployed (e.g. npx wrangler secret put WALLET_ENCRYPTION_KEY) and restart the API.

  5. 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.

Deploying behind TLS

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.)

Secrets and logging

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 included

The 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.

Project layout

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

Why Nigeria first

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.

Security

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.

Architecture Decision Records

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

Contributing

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.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages