The Wallet Service is a FastAPI (Python) microservice that serves as the single custodian of all user balances (INR). It maintains a double-entry ledger, handles margin locking for orders, processes deposits/withdrawals, and exposes a high-performance gRPC API for internal services.
- Balance Custody: Maintains per-user, per-currency balance slices:
available, locked_order_margin, locked_withdrawal.
- Double-Entry Ledger: Every balance mutation produces a debit + credit journal entry. The ledger is append-only and immutable.
- Margin Locking: Lock funds when orders are accepted, unlock on cancel/reject, settle on fill.
- Deposit / Withdrawal Processing: Handle deposit confirmations and withdrawal requests with multi-stage approval.
- Idempotent Operations: Every mutating RPC accepts
Idem { key, scope } — duplicates are safely rejected.
- Hold System: Freeze funds under AML, risk, velocity, or operations holds.
- FX Oracle: Provides USDT-INR conversion rate for settlement calculations.
Wallet does not decide whether to lock or unlock funds — it executes instructions from the Order Service, Settlement Service, and Admin Panel.
graph TB
OrderSvc[Order Service] -->|gRPC| Wallet[Wallet Service]
PositionSvc[Position Service] -->|gRPC| Wallet
FundingSvc[Funding Service] -->|gRPC| Wallet
AdminPanel[Admin Panel] -->|REST| Wallet
Wallet -->|Stores| PG[(PostgreSQL)]
Wallet -->|Events| Kafka[Kafka Bus]
Wallet -->|Cache| Redis[(Redis)]
| Field | Type | Description |
|---|
id | UUID (PK) | Balance row ID |
user_id | UUID | Account owner |
currency | VARCHAR(10) | INR or USDT |
cash_total | NUMERIC(20,4) | Total cash balance |
available | NUMERIC(20,4) | Available for trading/withdrawal |
locked_order_margin | NUMERIC(20,4) | Margin locked by open orders |
locked_withdrawal | NUMERIC(20,4) | Funds locked for pending withdrawals |
rounding_carry | NUMERIC(20,8) | Sub-paisa rounding accumulator |
balance_version | BIGINT | Optimistic locking version (incremented on every update) |
created_at | TIMESTAMPTZ | Creation time |
updated_at | TIMESTAMPTZ | Last update time |
Invariant: cash_total = available + locked_order_margin + locked_withdrawal (enforced by CHECK constraint)
| Field | Type | Description |
|---|
id | UUID (PK) | Ledger entry ID |
user_id | UUID | Account owner |
entry_type | ENUM | lock, unlock, settle_trade, deposit, withdrawal, funding, fee, admin_adjust |
debit_account | TEXT | Chart of accounts debit (e.g., User:Cash) |
credit_account | TEXT | Chart of accounts credit (e.g., Exchange:FeeRevenue) |
amount | NUMERIC(20,4) | Entry amount (always positive) |
currency | VARCHAR(10) | INR or USDT |
fx_usdtinr | NUMERIC(12,4) | FX rate at time of entry (NULL for INR-only) |
reference_type | TEXT | order, trade, deposit, withdrawal, funding, admin |
reference_id | UUID | FK to the originating entity |
trace_id | TEXT | OpenTelemetry trace ID |
idempotency_key | TEXT UNIQUE | Deduplication key |
created_at | TIMESTAMPTZ | Entry timestamp |
| Account | Description |
|---|
User:Cash | User’s available cash |
User:LockedMargin | Margin held for open orders |
User:LockedWithdrawal | Funds pending withdrawal |
Exchange:FeeRevenue | Trading fee income |
Exchange:FundingPool | Funding payment clearing |
Exchange:InsuranceFund | Insurance fund balance |
Exchange:OperatingAccount | Exchange operating account |
| Field | Type | Description |
|---|
id | UUID (PK) | Hold ID |
user_id | UUID | Affected user |
hold_type | ENUM(AML, COOL_OFF, RISK, VELOCITY, OPS) | Reason for hold |
amount | NUMERIC(20,4) | Amount frozen (NULL = entire balance) |
currency | VARCHAR(10) | Currency |
reason | TEXT | Human-readable reason |
placed_by | TEXT | Service or admin that placed the hold |
released_at | TIMESTAMPTZ | NULL until released |
created_at | TIMESTAMPTZ | Hold creation time |
| Field | Type | Description |
|---|
id | UUID (PK) | Transaction ID |
user_id | UUID | Account owner |
amount | NUMERIC(20,4) | Transaction amount |
currency | VARCHAR(10) | Currency |
status | ENUM | pending, processing, completed, failed, cancelled |
payment_ref | TEXT | External payment reference |
created_at | TIMESTAMPTZ | Request time |
completed_at | TIMESTAMPTZ | Completion time |
| Field | Type | Description |
|---|
key | TEXT (PK) | Idempotency key |
scope | TEXT | Operation scope |
response_hash | TEXT | Hash of the original response |
created_at | TIMESTAMPTZ | First request time |
expires_at | TIMESTAMPTZ | Key expiry (e.g., 24 hours) |
The Wallet Service exposes a gRPC API consumed by internal services:
| RPC | Description | Called By |
|---|
LockFunds(user_id, amount, currency, order_id, idem) | Reserve margin for a new order | Order Service |
UnlockFunds(user_id, amount, currency, order_id, idem) | Release locked funds on cancel/reject | Order Service |
SettleTrade(maker_id, taker_id, amount, fee_maker, fee_taker, trade_id, idem) | Apply realized P&L and fees after a fill | Order Service |
ApplyFunding(user_id, amount, currency, funding_cycle_id, idem) | Process perpetuals funding payment | Funding Service |
DepositConfirmed(user_id, amount, currency, payment_ref, idem) | Credit balance after deposit confirmation | Payment Gateway |
WithdrawRequest(user_id, amount, currency, destination, idem) | Initiate withdrawal | Client via Gateway |
WithdrawComplete(withdrawal_id, status, idem) | Mark withdrawal as completed/failed | Payment Gateway |
AdminAdjust(user_id, amount, currency, reason, admin_id, idem) | Manual balance adjustment | Admin Panel |
UpsertHold(user_id, hold_type, amount, currency, reason) | Place a balance hold | Risk / Admin |
ReleaseHold(hold_id) | Release a balance hold | Risk / Admin |
GetBalances(user_id) | Query current balances | Order Service, Position Service |
Every mutating RPC includes Idem { key: string, scope: string }:
- On first call: execute operation, store result keyed by
(key, scope)
- On duplicate call: return stored result without re-executing
- Keys expire after 24 hours
| Method | Endpoint | Description |
|---|
GET | /v1/wallet/balances | Get user balances |
GET | /v1/wallet/transactions | Transaction history (paginated) |
POST | /v1/wallet/deposit | Initiate deposit |
POST | /v1/wallet/withdraw | Request withdrawal |
GET | /v1/wallet/withdrawals | Withdrawal history |
| Topic | Description | Consumers |
|---|
wallet.ledger_posted.v1 | Every ledger entry written | Analytics, Audit, Settlement |
wallet.balance_changed.v1 | Balance snapshot after change | Risk, Position, Frontend |
wallet.deposit.confirmed.v1 | Deposit completed | Notification Service |
wallet.withdrawal.requested.v1 | Withdrawal initiated | Risk (AML check) |
wallet.withdrawal.completed.v1 | Withdrawal finalized | Notification Service |
wallet.hold.created.v1 | Balance hold placed | Notification Service |
wallet.hold_released.v1 | Balance hold released | Notification Service |
wallet.admin_adjust.v1 | Admin adjustment made | Audit Service |
- Order Service calls
LockFunds(user_id, margin_amount, INR, order_id)
- Wallet checks:
available >= margin_amount and balance_version matches
- Atomically:
available -= margin_amount, locked_order_margin += margin_amount, balance_version++
- Write
ledger_entries (debit User:Cash, credit User:LockedMargin)
- Emit
wallet.balance_changed.v1
- Return success
- Order Service calls
SettleTrade(maker_id, taker_id, pnl, fees, trade_id)
- For each party:
- Unlock margin for the filled portion
- Apply realized P&L (credit or debit
User:Cash)
- Debit trading fee, credit
Exchange:FeeRevenue
- All operations within a single DB transaction
- Write ledger entries for each leg
- Emit
wallet.ledger_posted.v1 for each entry
- External payment gateway confirms deposit
- Gateway calls
DepositConfirmed(user_id, amount, INR, payment_ref)
- Wallet credits
available += amount
- Write ledger entry (debit
Exchange:OperatingAccount, credit User:Cash)
- Emit
wallet.deposit.confirmed.v1
- User requests
WithdrawRequest(user_id, amount, INR, destination)
- Validate:
available >= amount, no active holds blocking withdrawal
- Lock:
available -= amount, locked_withdrawal += amount
- Apply cooling-off hold if configured
- Process via payment gateway
- On success:
locked_withdrawal -= amount, write ledger entry
- On failure: reverse lock, restore
available
- Per-User Serialization: All balance mutations for a user are serialized via PostgreSQL advisory locks (
pg_advisory_xact_lock(user_id_hash))
- Optimistic Locking: Every balance update checks and increments
balance_version. Concurrent updates fail and retry.
- Idempotency:
(key, scope) uniqueness in idempotency_keys prevents double-execution
- Transaction Isolation:
READ COMMITTED with explicit row locks for critical paths
The Wallet Service provides USDT-INR conversion for settlement:
- Strategy: Composed index from multiple sources (exchange APIs, reference rates)
- Staleness: FX rate has a configurable staleness threshold (e.g., 60 seconds). If stale, operations requiring FX conversion are rejected.
- Recording: Every ledger entry involving cross-currency records the
fx_usdtinr rate used
- Rounding: Banker’s rounding (round half to even) for INR amounts to 4 decimal places
| Currency | Precision | Rounding |
|---|
| INR | 4 decimal places | Banker’s rounding |
| USDT | 4 decimal places | Banker’s rounding |
Sub-paisa residuals accumulate in rounding_carry and are periodically reconciled.
- Cooling Period: Configurable delay after deposit before withdrawal (e.g., 24 hours)
- Daily Limits: Per-user daily withdrawal limits based on KYC tier
- Velocity Checks: Max N withdrawals per hour
- AML Screening: Large withdrawals trigger Risk Service review
- Address Whitelisting: Optional destination address whitelist
wallet_lock_total{status} — Lock operations (success/failure/insufficient)
wallet_settle_total{status} — Trade settlements
wallet_deposit_total{status} — Deposit operations
wallet_withdrawal_total{status} — Withdrawal operations
wallet_balance_invariant_violations — Balance constraint violations (should be 0)
wallet_grpc_latency_ms{method} — gRPC method latency
Spans: wallet.lock_funds, wallet.unlock_funds, wallet.settle_trade, wallet.apply_funding, wallet.deposit, wallet.withdraw
Structured JSON: user_id, operation, amount, currency, balance_version, trace_id, idempotency_key. Amounts are logged; no PII.
- gRPC Authentication: mTLS between services; service identity validated
- Idempotency: Prevents replay attacks and double-spends
- Advisory Locks: PostgreSQL advisory locks prevent concurrent balance races
- Audit Trail: Every balance change has a corresponding immutable ledger entry
- Hold System: Funds can be frozen without user’s ability to withdraw
- Admin Operations: Require admin JWT scope + audit log entry
- Financial Precision: NUMERIC(20,4) for all monetary values (never floating-point)
| Scenario | Handling |
|---|
| DB transaction failure | Automatic rollback; client retries with same idempotency key |
| Kafka publish failure | Transactional outbox pattern: ledger entry + outbox row in same transaction |
| FX rate stale | Reject cross-currency operations; alert ops |
| Payment gateway timeout | Withdrawal stays in processing; background poller checks status |
| Balance invariant violation | Alert critical; pause mutations for user; trigger reconciliation |
| Metric | Target |
|---|
LockFunds latency (p95) | < 50 ms |
SettleTrade latency (p95) | < 100 ms |
GetBalances latency (p95) | < 20 ms |
| Balance invariant accuracy | 100% |
| Idempotency correctness | 100% |
| Ledger consistency (audit) | +/- 0.01% |
| Variable | Description | Default |
|---|
POSTGRES_URL | PostgreSQL connection string | Required |
REDIS_URL | Redis connection string | Required |
KAFKA_BROKERS | Kafka broker addresses | Required |
GRPC_PORT | gRPC server port | 50051 |
FX_STALENESS_THRESHOLD_SEC | Max FX rate age | 60 |
WITHDRAWAL_COOLING_HOURS | Post-deposit cooling period | 24 |
MAX_DAILY_WITHDRAWAL_INR | Daily withdrawal limit | Tier-based |
- Language: Python 3.10+
- Framework: FastAPI + Uvicorn
- ORM: SQLAlchemy + Alembic
- gRPC: grpcio + protobuf
- Kafka: confluent-kafka-python with Avro
- Package Manager: uv