BitcoinEkasi · tsk.bitcoinekasi.xyz · bolt.bitcoinekasi.xyz · April 2026
- Part 1 — Platform Overview
- Part 2 — TSK Rewards System
- Part 3 — BoltCard Server
- Part 4 — How the Two Systems Work Together
- Part 5 — Deployment & Operations
The BitcoinEkasi TSK Bitcoin Rewards Platform is two cooperating systems that together form a complete Bitcoin-powered incentive programme for a youth surf/skate/fitness club in Mossel Bay, South Africa.
| System | Domain | Purpose |
|---|---|---|
| TSK Rewards | tsk.bitcoinekasi.xyz | Participant registry, attendance tracking, monthly report generation, and reward calculation. The administrative brain of the platform. |
| BoltCard Server | bolt.bitcoinekasi.xyz | Bitcoin balance ledger, BoltCard NFC payment handling (LNURL-W), Lightning Network address refills (LNURL-P), and batch reward disbursement. The money layer. |
| Layer | TSK Rewards | BoltCard Server |
|---|---|---|
| Runtime | Node.js 20 / Next.js 15 (App Router) | Node.js 20 / Express 4 |
| Language | TypeScript | TypeScript |
| Database | SQLite via Prisma ORM | SQLite via better-sqlite3 (synchronous) |
| Auth | NextAuth.js v5 (credentials + JWT) | JWT (HS256, jsonwebtoken) |
| Frontend | React Server Components + Tailwind CSS | React + Vite + Tailwind CSS |
| Bitcoin | Calls Bolt API for payouts | Blink GraphQL API + WebSocket |
| Container | Docker (multi-stage) | Docker (multi-stage) |
Both systems run on a single VPS as Docker containers behind an nginx reverse proxy that handles SSL termination (Let's Encrypt) and routes by domain name.
Internet
│
nginx (SSL termination)
├── tsk.bitcoinekasi.xyz → TSK container :3000
└── bolt.bitcoinekasi.xyz → Bolt container :3001
CI/CD is handled by GitHub Actions: on push to main, each repository builds a Docker image and pushes it to the GitHub Container Registry (GHCR). Deployment runs docker compose pull && docker compose up -d on the VPS.
| Role | Access Level | Primary Use |
|---|---|---|
| ADMINISTRATOR | Full access | Participant management, report generation & approval, payout management |
| MARSHAL | Attendance only | Mobile-first view of today's session; marks attendance; can raise change requests |
Marshals are redirected to the current day's attendance session on login and see only the minimal mobile UI needed for that task.
All data lives in a SQLite database managed by Prisma.
The central entity. Represents one registered youth participant or junior coach.
| Field Group | Fields | Notes |
|---|---|---|
| Identity | tskId, surname, fullNames, knownAs, idNumber |
TSK ID is auto-generated sequential (TSK00001). SA ID is unique, parsed for DOB + gender. |
| Derived | gender, dateOfBirth |
Extracted from the 13-digit SA ID number at registration; never entered manually. |
| Status | status, registrationDate, retiredAt, retiredReason, retiredReasonOther |
Status: ACTIVE or RETIRED. Retirement captures date, reason (dropdown), and optional free-text for "Other". |
| Demographics | ethnicity, language, housingType |
Optional fields for programme reporting. |
| School | school, grade |
Used for academic payout eligibility. |
| Guardian | guardian, guardianId, guardianRelationship, address, contact1, contact2 |
Emergency / parental contact information. |
| Measurements | weightKg, heightCm, tshirtSize, shoeSize, wetsuiteSize |
Plain text / numeric. Each field has its own *UpdatedAt timestamp. |
| Measurement timestamps | measurementsUpdatedAt, weightUpdatedAt, heightUpdatedAt, tshirtSizeUpdatedAt, shoeSizeUpdatedAt, wetsuiteUpdatedAt |
Per-field timestamps updated only when that specific field changes. |
| Junior Coach | isJuniorCoach, juniorCoachLevel |
Level 1, 2, or 3 — drives reward multiplier (5×, 7.5×, 10×). |
| TSK Level | tskStatus, tskStatusUpdatedAt |
Programme progression level. Full history stored in TskLevelHistory. |
| Documents | idDocumentUrl, idDocumentUploadedAt, indemnityFormUrl, indemnityUploadedAt, profilePicture |
File paths served from /public/uploads/participants/. |
| Payment | paymentMethod, lightningAddress |
BOLT_CARD (internal credit) or LIGHTNING_ADDRESS (outbound LN send). |
| Notes | notes |
Free-text admin notes; editable without triggering a report recalculation. |
| Model | Key Fields | Notes |
|---|---|---|
| Event | id, date, category, note |
One session per SAST calendar day. Category: SURFING, FITNESS, SKATING, BEACH_CLEAN_UP, OTHER. |
| AttendanceRecord | participantId, eventId, present, onTour |
Composite unique on (participantId, eventId). onTour marks participants away at competitions. |
| Model | Key Fields | Notes |
|---|---|---|
| MonthlyReport | month (YYYY-MM), status, generatedAt, generatedBy, approvedAt, approvedBy |
One row per calendar month. Status: PENDING → APPROVED. Resets to PENDING on any data change. |
| MonthlyReportEntry | reportId, participantId, totalEvents, attended, percentage, rewardSats |
One row per participant per month. Fully replaced (delete + recreate) on each regeneration. |
Append-only audit log of TSK level changes. Each row records participantId, level, and changedAt. A new row is inserted whenever tskStatus changes, providing a full progression timeline.
| Model | Purpose |
|---|---|
| Certification | Surf/lifesaving certificates: certType, issuedAt, fileUrl. Multiple per participant. |
| TskReview | Quarterly review: overall rating, surfing/skating/fitness scores, coach comments, level recommendation. |
| PerformanceEvent | Competition results: event name, date, placement, notes. |
| SchoolReport | Academic results per term: year, term, grade, percentage, reportFileUrl. Used for academic payouts. |
| ParticipantChangeRequest | Marshal-raised profile corrections: field, currentValue, proposedValue, reason, status. |
| AcademicPayout / Entry | Term-based school grade reward batches and per-participant entries. |
| SpecialPayout / Entry | Ad-hoc reward groups and per-participant entries with custom amounts. |
An Administrator enters the participant's SA ID number, names, and other details. The system parses the 13-digit SA ID using the Luhn checksum algorithm to:
- Validate the number is genuine
- Extract the date of birth (first 6 digits: YYMMDD)
- Extract the gender (digit 7: 0–4 = Female, 5–9 = Male)
The TSK ID (TSK00001, TSK00002, …) is auto-generated sequentially inside a database transaction to prevent duplicates under concurrent writes.
Weight, height, t-shirt size, shoe size, and wetsuit size each have an independent *UpdatedAt timestamp. The timestamp is set to now only when that specific field's value changes. This lets staff see exactly when each measurement was last taken.
A participant starts ACTIVE. When retired, the Administrator selects a reason from a standardised list:
- At age 18
- Relocated
- Poor attendance
- Lack of interest
- Negative attitude or behaviour
- Other (requires free-text explanation)
The retiredAt date is set to the current timestamp. On retirement, the monthly report for the current month is automatically regenerated.
| Level | Multiplier | Effect on 10,000 sat base reward |
|---|---|---|
| 1 | 5× | 50,000 sats |
| 2 | 7.5× | 75,000 sats |
| 3 | 10× | 100,000 sats |
When a Marshal notices a profile error, they submit a ParticipantChangeRequest specifying the field, current value, and proposed correction. Administrators see a queue of pending requests and approve or reject each one. This gives Marshals a way to surface data issues without granting them direct edit access.
An Event (session) represents a single training day. Only one session can exist per SAST calendar day — enforced by a unique index on the date field.
- Marshals see a mobile-optimised single-page view. On login they are redirected to today's session (if it exists) or to a stripped-down creation form.
- Administrators see the full attendance overview: all historical sessions grouped by month, a session creation form, and a link to today's session.
Inside a session, every active participant is listed. Staff tap to toggle each participant between:
- Present — counted towards rewards
- Absent — default state, not counted
- On Tour — away at a competition; recorded separately, treated as absent for percentage purposes
The attendance overview groups sessions by calendar month with an expand/collapse chevron. The most recent month is open by default. Each month header shows the total session count and combined attendee count. Each session row shows: date, category badge, attendee count (present only), optional note, and action links.
Administrators can delete a session provided its month has not been approved. If the month's report is APPROVED, deletion is blocked. When a session is deleted:
- All
AttendanceRecordrows for that session are deleted. - The
Eventrow is deleted. - If other sessions remain in that month, the monthly report is automatically regenerated.
- If it was the only session in the month, the report is reset to
PENDINGwith no entries.
This is the core business logic of the platform. Each month produces a report stating: for each participant, how many sessions they attended, their attendance percentage, and how many sats they earned.
A single idempotent function (src/lib/upsert-report.ts) handles all report creation and updates. It is called automatically when:
- A new session is created
- A session is deleted
- A participant is marked RETIRED
The function runs inside a database transaction and fully replaces all MonthlyReportEntry rows — safe to call repeatedly with no side effects.
| Participant State | Included? | Reasoning |
|---|---|---|
| ACTIVE, registered before month end | Yes | Normal active participant. |
| ACTIVE, registered after month end | No | Not yet a participant during the month. |
RETIRED, retiredAt ≥ monthStart |
Yes — retirement month only | Was participating at some point during the month. |
RETIRED, retiredAt < monthStart |
No | Retired before this month began; fully excluded. |
New-joiner rule: A participant who joins mid-month is included. Their attendance for sessions before their registration date is naturally zero. They are divided by the full month session count — no prorated denominator. The same rule applies to retired participants.
// For each participant:
const attendableEvents = participant.retiredAt
? events.filter(e => e.date <= participant.retiredAt)
: events; // all month events for active participants
const totalEvents = events.length; // ALWAYS the full month count
const attended = attendableEvents.filter(e =>
attendedSet.get(participant.id)?.has(e.id)
).length;
const percentage = (attended / totalEvents) * 100;Key design decisions:
attendedonly counts sessions up to the retirement date for retired participants.totalEventsis always the full count — no participant gets a smaller denominator regardless of when they joined or left. This creates a consistent, fair metric.
| Attendance % | Base Reward (sats) |
|---|---|
| 100% | 10,000 |
| 95 – 99% | 8,200 |
| 90 – 94% | 6,700 |
| 85 – 89% | 5,500 |
| 80 – 84% | 4,500 |
| 75 – 79% | 3,700 |
| 70 – 74% | 3,000 |
| < 70% | 0 |
const JC_MULTIPLIERS = { 1: 5, 2: 7.5, 3: 10 };
const rewardSats = participant.isJuniorCoach
? Math.round(baseReward * (JC_MULTIPLIERS[participant.juniorCoachLevel ?? 1] ?? 5))
: baseReward;- Generate — created or refreshed automatically on data changes. Status starts
PENDING. If the report wasAPPROVEDand is regenerated, it resets toPENDING. - Review — Administrator views per-participant attendance, percentage, and reward amounts.
- Approve — Administrator approves. Blocked for the current or future months. On approval, a batch payout is created via the Bolt API.
- Pay — Payout transitions:
unpaid→invoiced→paidas the Lightning payment settles.
- BOLT_CARD — reward sats are credited as an internal balance on the BoltCard Server. The participant spends using their NFC card at point-of-sale.
- LIGHTNING_ADDRESS — reward sats are sent outbound via Lightning Network to the participant's Lightning address.
When an Administrator approves a monthly report, TSK calls Bolt's POST /api/v1/payout/batch with a list of { userId, amountSats, payoutType } items. Bolt credits internal balances or sends outbound Lightning payments accordingly.
Term-based school grade rewards separate from monthly attendance. Administrators upload school reports and enter academic percentages; the system calculates rewards based on grade thresholds and dispatches via the same Bolt batch API.
Ad-hoc reward batches for specific events (competition prizes, community participation, etc.) with custom per-participant amounts.
South Africa uses SAST (UTC+2) with no daylight-saving time.
Convention: All Event dates are stored as noon UTC on the SAST calendar date. For example, "Monday 7 April 2026 SAST" is stored as
2026-04-07T10:00:00.000Z. This ensures the date never crosses midnight UTC in either direction.
Month boundary calculations in src/lib/sast.ts:
getStartOfSASTMonth("2026-04")→2026-03-31T22:00:00.000ZgetEndOfSASTMonth("2026-04")→2026-04-30T21:59:59.999Z
All Prisma date-range queries use these UTC boundary values.
An Express.js application that manages a Bitcoin balance ledger, handles NFC card payments, accepts Lightning Network refills, and exposes an admin dashboard and a payout API.
Eleven SQLite tables (created synchronously on startup):
| Table | Purpose |
|---|---|
admins |
Admin user accounts. Stores bcrypt-hashed password and display name. |
users |
Participants/cardholders. Fields: username, display_name, balance_sats, daily_limit_sats, tx_limit_sats, lightning_address, external_id (links to TSK participant ID). |
cards |
NFC BoltCards. One card per user. Stores all 5 AES-128 keys (K0–K4), the card UID (captured on first tap), last seen counter, and enabled/disabled status. |
transactions |
Completed payment events. Records direction (debit/credit), amount, fee, memo, and Unix timestamp. |
pending_refills |
In-flight LNURL-P invoices awaiting payment. Holds Blink payment hash, amount, expiry timestamp. |
pending_withdrawals |
In-flight LNURL-W requests awaiting wallet callback. Holds one-time k1 token and amount. |
api_keys |
API keys for server-to-server calls (TSK → Bolt). Stored as SHA-256 hash — raw key shown once at creation. |
card_events |
Append-only audit log of every card tap attempt (success, failure, reason). |
payout_batches |
Monthly/academic/special reward batches received from TSK. Tracks total sats, status, Lightning invoice hash. |
payout_batch_items |
Individual user entries within a batch. Tracks payout_type (internal vs ln_address), amount, and per-item status. |
ln_payouts |
Records of outbound Lightning payments. Stores payment hash, destination address, amount, fee, status. |
Each BoltCard has 5 AES-128 (128-bit) keys written during programming:
| Key | Role |
|---|---|
K0 |
Card authentication master key (used by card internally) |
K1 |
Session key — used by the server to decrypt the encrypted UID and tap counter from the NFC URL |
K2 |
CMAC key — used to verify the message authentication code in the NFC URL, preventing replay attacks |
K3 |
Reserved / factory use |
K4 |
Reserved / factory use |
- Administrator opens the user detail page and clicks "Generate Card QR".
- The server generates 5 random 128-bit AES keys (K0–K4) and stores them against the card record. The card UID is initially unknown.
- The server constructs a configuration payload encoding the keys and the LNURL-W endpoint URL, returned as a QR code.
- An administrator scans the QR code with the BoltCard Programmer mobile app (iOS/Android).
- The app establishes an NFC connection with the blank card and writes all 5 keys plus the LNURL-W URL to the card's NTAG424 memory.
- On the card's first tap, the server decrypts the NFC payload and captures the card's real UID, completing registration.
To decommission a card, the administrator generates a "wipe QR" which resets the card to factory keys.
When a participant taps their BoltCard at a point-of-sale terminal:
- NFC tap — The card broadcasts a URL:
https://bolt.bitcoinekasi.xyz/lnurlw?p=ENCRYPTED_PAYLOAD&c=CMAC - Server receives
/lnurlw— Extractsp(AES-encrypted UID + counter) andc(CMAC authentication tag). - Decrypt — Using K1, the server decrypts
pto recover the plaintext UID and 3-byte tap counter. - Identify card — Looks up the card by UID. On first tap, stores the UID.
- Verify CMAC — Using K2, recomputes the CMAC and compares to
c. Mismatch = rejected. - Counter check — Decrypted counter must be strictly greater than the last seen counter. Prevents replay attacks.
- Return LNURL-W response — JSON with
minWithdrawable,maxWithdrawable(balance up to per-tx limit),defaultDescription, and callback URL. - Wallet creates invoice — The payment terminal creates a Lightning invoice and sends it to
/lnurlw/callback?k1=TOKEN&pr=INVOICE. - Callback processing — Server decodes the invoice, checks amount against balance and limits, then calls Blink's
lnInvoicePaymentSendmutation. - Balance deduction — On successful payment,
balance_satsis decremented and atransactionsrow is inserted.
Replay prevention: The strictly-increasing counter (stored on the card's secure memory and verified server-side) means that re-broadcasting a recorded NFC URL fails the counter check. The CMAC further ensures the payload hasn't been tampered with.
Participants can top up their balance by sending Lightning payments to username@bolt.bitcoinekasi.xyz:
- Lightning address lookup — Sender's wallet fetches
/.well-known/lnurlp/usernameand receives LNURL-P metadata. - Invoice request — Sender's wallet requests an invoice via
/lnurlp/username/callback?amount=MILLISATS. - Invoice creation — Server calls Blink's
lnInvoiceCreatemutation, stores the invoice inpending_refills(1-hour expiry), and returns it to the sender's wallet. - Payment — Sender pays the invoice. Blink detects the incoming payment.
- Real-time notification — The Blink WebSocket subscription fires an
LnUpdateevent with the payment hash and settled amount. - Balance credit — Server matches the payment hash to a
pending_refillsrow, credits the user'sbalance_sats, inserts atransactionsrow, and removes the pending entry.
A background job runs every 30 minutes to clean up expired unpaid pending_refills.
All Lightning Network operations go through Blink (galoy.io), a custodial Bitcoin Lightning wallet with a GraphQL API and WebSocket subscription feed.
| Operation | Type | Used For |
|---|---|---|
lnInvoiceCreate |
Mutation | Create a Lightning invoice for refills |
lnInvoicePaymentSend |
Mutation | Pay a Lightning invoice (card withdrawals) |
getBalance |
Query | Fetch current Blink account balance |
getTransactions |
Query | Fetch transaction history with counterparty details |
The server maintains a persistent WebSocket connection subscribing to myUpdates. When a payment arrives, Blink pushes a real-time LnUpdate event. The server uses graphql-ws with retryAttempts: Infinity for auto-reconnect.
The admin dashboard shows a "From/To" column on Blink transactions, extracted from settlementVia:
- IntraLedger (Blink-to-Blink) — counterparty's Blink username
- OnChain — Bitcoin transaction hash
- Lightning — "Lightning" (external LN payment; no identifying info available)
A React SPA (Vite build) authenticating via JWT stored in localStorage.
- Live search bar filtering by username or display name
- Each user row: display name, username, card status badge (Active / Inactive / No Card), balance in sats and ZAR, lightning bolt icon if LN address is set
- Clicking a user opens the detail panel with: balance management, transaction history, card programming QR / wipe QR, limits, Lightning payout
Shows the main Blink account (skredit@blink.sv) transaction history:
- Direction indicator (↓ receive / ↑ send)
- Date and time
- From/To counterparty
- Amount in sats (with ZAR equivalent)
- Fee in sats
- Memo
- Status badge (SUCCESS / PENDING / FAILED)
- Refresh button (loads lazily on first tab open)
TSK calls POST /api/v1/payout/batch on report approval. Protected by a SHA-256-hashed API key.
{
"batchId": "tsk-2026-03",
"description": "Monthly rewards — March 2026",
"items": [
{ "userId": "TSK00001", "amountSats": 10000, "payoutType": "internal" },
{ "userId": "TSK00042", "amountSats": 8200, "payoutType": "ln_address" }
]
}internalitems — balance credited immediately;payout_batch_itemsrow markedcompleted.ln_addressitems — outbound Lightning payment initiated; tracked inln_payouts; markedcompletedon WebSocket confirmation from Blink.
| Mechanism | Implementation |
|---|---|
| Admin authentication | JWT (HS256), 8-hour expiry, signed with JWT_SECRET |
| Password storage | bcrypt, salt rounds = 12 |
| API key storage | SHA-256 hash of raw key; raw key shown once at creation |
| NFC replay prevention | Strictly-increasing tap counter (3 bytes, stored per card) |
| NFC tamper detection | CMAC (AES-128 CBC-MAC) using K2; mismatch = rejected |
| NFC payload confidentiality | AES-128 decryption of UID + counter using K1 |
| Withdrawal limits | Per-transaction and daily rolling limits enforced server-side before calling Blink |
| HTTPS | Let's Encrypt TLS via nginx; all traffic encrypted in transit |
TSK Rewards and the BoltCard Server are loosely coupled via a simple REST API. TSK is the source of truth for participants and rewards; Bolt is the source of truth for balances and card payments.
- Attendance tracked (TSK) — Marshals mark daily attendance throughout the month. Each session auto-updates the monthly report.
- Month closes (TSK) — Administrator opens the monthly report showing each participant's attendance % and calculated reward.
- Report approved (TSK → Bolt) — Administrator clicks Approve. TSK calls
POST /api/v1/payout/batchon Bolt, passing each participant's TSK ID and reward amount. - Balances credited (Bolt) — Bolt processes the batch. BOLT_CARD participants have their internal balance increased; LIGHTNING_ADDRESS participants receive an outbound Lightning payment.
- Spending (Bolt) — Participants tap their BoltCard at community vendors. Each tap deducts from their internal balance via LNURL-W.
- Lightning refills (Bolt) — Any Lightning wallet can send sats to
username@bolt.bitcoinekasi.xyzto top up a balance directly.
| TSK Field | Bolt Field | How Linked |
|---|---|---|
participant.tskId |
users.external_id |
TSK creates/looks up the Bolt user by TSK ID when dispatching a payout batch |
participant.paymentMethod |
payout_batch_items.payout_type |
BOLT_CARD → internal; LIGHTNING_ADDRESS → ln_address |
participant.lightningAddress |
users.lightning_address |
Synced when the Bolt user is created or updated from TSK |
The systems can operate independently — Bolt has no knowledge of attendance or reward calculations, and TSK has no knowledge of card balances or tap history.
Both systems use multi-stage Docker builds:
| Stage | Purpose |
|---|---|
deps |
Install all npm dependencies (including devDependencies for the build) |
builder |
Run the production build (next build for TSK, tsc + vite build for Bolt) |
runner |
Copy only compiled output + production dependencies; set NODE_ENV=production |
Triggered on push to main. Builds the Docker image and pushes to GHCR (ghcr.io/org/repo:latest). The GITHUB_TOKEN secret is used for authentication — no manual credentials needed.
Both services are defined in a single docker-compose.yml on the VPS. To deploy:
docker compose pull
docker compose up -dDocker Compose only restarts services where the image digest changed.
Disk space: Docker image layers accumulate over time. Run
docker system prune -fbefore deploying if disk space is low.
server {
server_name tsk.bitcoinekasi.xyz;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
server_name bolt.bitcoinekasi.xyz;
location /assets/ {
proxy_pass http://localhost:5173;
proxy_buffering off; # Critical: prevents truncation of large JS bundles
}
location / {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
}
}
proxy_buffering offon the/assets/location is critical. Without it, nginx's default 32KB proxy buffer truncates large JavaScript bundles, causing a blank white page.
SSL is managed by Certbot (Let's Encrypt) with automatic HTTPS redirect.
| Variable | System | Purpose |
|---|---|---|
DATABASE_URL |
TSK | Prisma connection string (SQLite file path) |
NEXTAUTH_SECRET |
TSK | JWT signing secret for NextAuth sessions |
NEXTAUTH_URL |
TSK | Public URL of the TSK app |
AUTH_TRUST_HOST |
TSK | Set to true when behind a proxy |
BOLT_API_URL |
TSK | Internal URL of the Bolt server for payout API calls |
BOLT_API_KEY |
TSK | API key for authenticating TSK → Bolt calls |
JWT_SECRET |
Bolt | Signs admin dashboard JWTs |
BLINK_API_KEY |
Bolt | Blink GraphQL API authentication |
BLINK_WALLET_ID |
Bolt | Blink Bitcoin wallet identifier |
ZAR_RATE |
Bolt | BTC/ZAR exchange rate for display purposes |
TSK (Prisma): The Docker entrypoint runs prisma migrate deploy before starting the Next.js server. This applies any pending SQL migrations from prisma/migrations/ safely — skipping already-applied migrations, never touching data.
Bolt (SQLite): On startup, Express runs synchronous CREATE TABLE IF NOT EXISTS statements for all 11 tables. Schema changes require a manual ALTER TABLE run on the server.
# View live logs
docker compose logs -f tsk
docker compose logs -f bolt
# Backup TSK database
docker cp infra-tsk-1:/data/tsk.db ./backup-tsk.db
# Seed default admin + marshal accounts (fresh deploy only)
docker compose exec tsk npm run db:seed
# Free up disk space before deploying
docker system prune -fBitcoinEkasi — TSK Bitcoin Rewards Platform — April 2026