EOD Service & Account FSM — Proposal¶
Trigger: Richard-HFT WhatsApp questions, 2026-04-30 07:32 GMT+9 Status: Draft for client review. Not yet scoped against a milestone.
Did we include end-of-day (EOD) Service in the full trading account lifecycle (creation, suspension, reactivation, balance adjustments, and cross-system reconciliation)? Will we need a finite state machine (FSM) to manage account states and transitions? Are there any EOD spec[s]?
Short answer: no EOD service today, no formal FSM today, no EOD spec in any of the documents we've received. Both are warranted; this doc proposes scope, ownership, and milestone placement.
1. End-of-Day (EOD) Service¶
1.1 What's already in place (substrate, not a service)¶
| Component | Status | Where |
|---|---|---|
ledger_entries table |
Schema present, 0 rows so far | Postgres quantatrade DB |
user_balance_snapshots table (Merkle-leaf per user) |
Schema present, FK to proof_of_reserves |
Postgres |
accounts table (per user × currency) |
3 rows seeded | Postgres |
| Matching engine balance source-of-truth | In-memory; balance is derived from total − Σ(open orders × price) | exchange-core2 / UserRegistry |
WS event broadcast for order.* + trade.executed |
Live | ws-gateway |
There is no scheduled job in the platform — zero cron, @Cron, setInterval(86400_000), or BullMQ delayed jobs across any of the 10 services.
1.2 What an EOD service must do (proposed scope)¶
Standard centralised-exchange EOD responsibilities:
Per user account, every UTC 00:00:
1. Balance snapshot — read matching-engine UserRegistry total + locked + available per currency, hash, write to user_balance_snapshots (already wired structurally for PoR).
2. P&L calculation — realised P&L from the day's trades, unrealised from open positions × mark price.
3. Fee accrual — sum maker/taker fees per user, write a journal entry to ledger_entries per user.
4. Volume rollup — update 30-day volume tracker that drives fee tiers (already partially in fee/VolumeTracker.java).
5. Staking reward distribution (M5 only) — pro-rata $QTRA payout per active staker.
Cross-system reconciliation (Richard called this out explicitly):
6. Matching engine ↔ Postgres — for every (user, currency), assert UserRegistry.total == sum(ledger_entries.amount). Mismatch → page on-call + freeze withdrawals for that user until cleared.
7. Postgres ↔ custody — for every asset, assert Σ(users.balance) ≈ BitGo wallet balance (within hot-wallet float tolerance).
8. Postgres ↔ on-chain — for staking M5+: staking_positions.amount total reconciles to StakingPool.totalStaked() on-chain.
Lifecycle hooks:
9. Auto-suspend users flagged by sanctions screen (compliance hook, M4).
10. Reactivation — clear suspended status on KYC re-verification.
11. Balance adjustments — admin-initiated credit/debit lands here as a ledger_entries entry with reference_type='admin_adjustment' + audit trail.
1.3 Suggested architecture¶
- New service
eod-serviceinplatform/services/eod-service/(Node.js, same template as ledger-service). - Driven by a leader-elected scheduler (Postgres advisory lock or Redis SET-NX) so multi-instance deploys don't double-run.
- All steps are idempotent keyed on
(YYYY-MM-DD, step_name)in a neweod_runstable — re-running a failed step doesn't double-account. - Each step publishes NATS events (
eod.snapshot.completed,eod.recon.failed, etc.) that the admin panel subscribes to. - Manual trigger via gRPC
AdminService.RunEOD(date)for back-fills and DR. - Hard timeout: 30 min per step. Anything longer pages.
1.4 Milestone placement¶
Best fit: - Steps 1, 2, 4, 6 (snapshot + P&L + volume + ME-vs-DB recon) → M1 amendment since they're tied to the ledger acceptance gate ("Ledger produces auditable trade and balance records") which is currently 🟡. - Steps 5, 7, 8 (staking distribution + custody recon + on-chain recon) → M5 alongside staking + revenue router. - Steps 9, 10, 11 (lifecycle hooks) → M4 (Compliance).
Net effort: ~5 working days for the M1-portion, ~3 days for M4 hooks, ~3 days for M5 distribution.
2. Account FSM¶
2.1 What's already in place¶
The users table has a status enum of three values: active, suspended, banned. Transitions are not codified — any service with DB write access can flip the value. There is no audit trail on transitions (no user_status_history table). KYC level is a separate enum (none, basic, intermediate, advanced) with no FSM either.
2.2 Why a formal FSM is warranted¶
Compliance and Richard's checklist demand a defensible account lifecycle. Three concrete reasons:
1. Forbidden transitions must be hard-rejected at the boundary (e.g. banned → active should require admin override + reason; today nothing prevents it).
2. Audit trail is required by every regulator we'll pass through (M4 KYC milestone) — who flipped the state, when, why, by what authority.
3. Side effects are state-dependent — moving a user to suspended should auto-cancel their open orders and freeze withdrawals; today no service is listening for that transition.
2.3 Proposed states¶
┌──────── pending_kyc ────────┐
│ │
▼ ▼
kyc_rejected kyc_approved
│
▼
┌──────────────────────────── active ─────────┐
│ │ ▲ │
│ ▼ │ │
│ suspended │
│ │ │
│ ▼ │
└────────────────────────► restricted │
│ │
▼ ▼
closed ◄────── banned
| State | Meaning | Trading | Withdrawals |
|---|---|---|---|
pending_kyc |
account created, KYC submitted, pending review | ❌ | ❌ |
kyc_rejected |
KYC failed, can resubmit | ❌ | ❌ |
kyc_approved |
KYC passed, awaiting first deposit | ❌ | ❌ |
active |
normal operation | ✅ | ✅ |
suspended |
compliance hold (re-KYC, sanctions match, dispute) | ❌ | ❌ |
restricted |
withdrawal-only (off-boarding, abandoned account) | ❌ | ✅ |
closed |
user-initiated termination, balances zeroed | ❌ | ❌ |
banned |
terminal — fraud, sanctions hit, court order | ❌ | ❌ |
2.4 Implementation¶
- New service
account-fsm(or merge into existing api-gateway) — a TypeScript module exporting: - Backed by an
account_state_transitionsaudit table (immutable, append-only). - Each transition publishes a NATS event (
account.suspended,account.restored, ...) that downstream services act on (cancel open orders, freeze custody, notify user, etc.). - Migration: extend the current 3-value enum to the 8 states above; back-fill all existing rows to
active.
2.5 Milestone placement¶
- M1 amendment for the FSM library + audit table + 3-state→8-state migration (~2 days).
- M4 for full integration with KYC provider events (
kyc.approved,kyc.rejected, sanctions hits).
3. Existing EOD specs¶
There are none in the document set we've received. The system-spec PDF (8 pp, partial — see docs/system-spec-open-questions.md) does not contain an EOD section, nor do the staking, tokenomics, or delivery-plan documents. Asking the client to confirm:
Is there a longer system spec or operations manual we haven't been sent? The 8-page PDF we have appears truncated (no buyback section, no technical-requirements section, no EOD section).
If there isn't one, we should write the EOD spec ourselves as a deliverable under M1 amendment, scoped per §1.2 above, and have it signed off before implementation.
4. Summary for the client¶
| Question | Answer |
|---|---|
| EOD service in M1? | No — substrate exists (snapshots table, ledger schema), service doesn't. Propose folding the M1-relevant portions (steps ½/4/6 above) into an M1 amendment, ~5 days. |
| FSM for account states? | Yes, recommended. Today we have a 3-value enum without transition guards, audit trail, or side-effect hooks. Propose a formal 8-state FSM with audit table — ~2 days under M1, full KYC integration in M4. |
| EOD specs? | None in any document we've received. Will draft one under M1 amendment if the client doesn't have one already. |
Draft for review. Author: PK. To be sent to Richard-HFT once internally reviewed.