Skip to content

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-service in platform/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 new eod_runs table — 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:
    type Transition = { from: AccountState; to: AccountState; reasonCode: string; authority: 'self' | 'admin' | 'system' };
    function transition(userId: string, target: AccountState, ctx: TransitionContext): Result<User, ForbiddenTransition>;
    
  • Backed by an account_state_transitions audit 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.