AZ
Back to constellation
Web3Draft

Settleo

Non-custodial P2P/OTC crypto-settlement platform — a ~24-repo polyrepo of hexagonal TypeScript services around a single-writer TigerBeetle ledger.

TypeScriptNode.js 22gRPCTigerBeetleRedis StreamsMongoDBzodprom-clientNext.jsstyled-components

Overview

What it is

Settleo is a non-custodial peer-to-peer and OTC crypto-settlement platform, built as a ~24-repo polyrepo — about eighteen hexagonal (ports-and-adapters) TypeScript backend services, plus the consumer/business/console apps, the Solidity escrow contracts and a shared library. Retail users trade through a consumer app, desks through a business app, and operators watch it all from an internal console — but every surface is downstream of one rule: value moves in exactly one place.

The problem

When money is on the line, "a bunch of services that each touch balances" is a recipe for double-spends and irreconcilable state. The hard part isn't building one service — it's building eighteen of them without letting any two of them disagree about how much money exists. Settleo's answer is architectural: a single value authority, a deny-by-default trust model on every hop, integer-only money, and boundaries that are stubbed honestly rather than faked when the real integration isn't built yet.

The service topology

Traffic enters through settleo-gateway (:8080), a stateless edge/BFF with no database and no domain logic — its only store is Redis (rate-limit, nonce, idempotency). It recognises three credential shapes and denies everything else with a uniform 401; its route table is the allow-list, so an unmatched route 404s before any auth runs. The console, by contrast, is a trusted internal client that signs service-to-service directly to the domain services rather than passing through the gateway.

TierServiceResponsibilityWrites balances?
Edgesettleo-gatewayAuthN of 3 credential shapes, route allow-list, BFF request adaptation, recipient-gated WebSocket fan-outNo
Identitysettleo-iam (:8081)Login/sessions, alg-pinned HS256 tokens, refresh-rotation with reuse detection, permissions-list authzNo
Value authoritysettleo-ledgerSole writer to TigerBeetle; double-entry transfers + two-phase holds over gRPCYes — only here
Money movementwallet, escrow-orchestrator, trade-engine, paymentsDrive deposits, escrow, trade sagas, fiat legs — all through the ledger clientNo
Risk & controlrisk, compliance, review, dispute, reconciliationWithdrawal holds, KYC gates, operator review cases, 2-of-3 arbitration, drift checksNo
Market & socialpricing, offer, reputation, messaging, notificationsPrice ticks, order book, ratings, chat, alertsNo
Chain & devsindexer, developerReorg-safe on-chain ingestion, developer API keysNo

Every service enforces its own deny-by-default S2S authorization rather than trusting the gateway. IAM's authorizer is a permissions-list model — not RBAC — where explicit Deny always beats Allow (the AWS rule).

How money actually moves — the trade settlement path

A cooperative trade settles as a saga, and the interesting property is that no single step is trusted to be atomic across services — every hop is idempotent and legal-only:

  1. trade-engine drives a trade FSM and asks the escrow-orchestrator to escrow the crypto leg.
  2. The orchestrator opens + funds the on-chain SettleoEscrow, then mirrors the lock into the ledger as a two-phase hold (seller → escrow).
  3. On a 2-of-3 release, the orchestrator posts the payout to the ledger as one all-or-nothing linked batch — commit the hold, pay escrow → buyer (net), escrow → fee — tagged settlement{tradeId, transitionId}.
  4. That settlement tag is what makes the ledger emit TradeSettled (once per batch) plus a LedgerTransferPosted per transfer, through a transactional outbox onto Redis Streams.
  5. trade-engine consumes TradeSettled and reaches completed.

The withdrawal path is the mirror image of trust: a wallet withdrawal is risk-assessed, and a risky one becomes a ledger hold plus a WithdrawalHeld event that opens a settleo-review case; an operator decides in the console, and WithdrawalReviewDecided resumes or rejects the wallet. The money is reserved, never released, until a human signs off.

The crown jewel: a single-writer ledger

settleo-ledger is the only writer to TigerBeetle and the only authority on balances, exposed as double-entry transfers and two-phase holds over a versioned gRPC contract (settleo.ledger.v1.Ledger). Every other service reaches money through its @settleo/ledger-client port — types only, no TigerBeetle access escapes the ledger. Accounts and transfers map straight onto TigerBeetle primitives:

ConceptFieldMeaning
Accountledgerone ledger space per asset (e.g. ETH, USDC)
Accountcodeaccount type chosen by callers: user / fee / escrow / gateway / world
AccountallowDebitsExceedCreditssystem/funding accounts (e.g. world) may run a debit balance; customer accounts may not
BalanceavailableBalancecredit-normal: creditsPosted − debitsPosted − debitsPending
Transferflagsingle | pending | post_pending | void_pending
Transferamountu128 integer minor units; decimal string on the wire, bigint internally

Account codes and ledger spaces are chosen by callers, not fixed in the ledger repo, so the taxonomy stays open (crypto ETH/USDC spaces; fiat mirror accounts at a higher offset with allowDebitsExceedCredits because fiat isn't custodied on-platform). A rebuildable MongoDB projection is derived from the transfer log and is never the source of truth — deterministic string→u128 id encoding preserves idempotency across the boundary:

export function encodeId(id: string): bigint {
  const digest = createHash('sha256').update(id).digest(); // 32 bytes
  let value = 0n;
  for (let i = 0; i < 16; i++) value = (value << 8n) | BigInt(digest[i]!);
  return value === 0n ? 1n : value; // TigerBeetle rejects a zero id
}

Money model & correctness

Money is integer minor units (u128-safe bigint), never floats; it crosses the wire as a decimal string bounds-checked against U128_MAX. The ledger's pure reference reducer encodes six invariants — double-entry always balances, no overdraft by construction, idempotent replays, pending posts-xor-voids, holds auto-void on timeout, linked transfers commit all-or-nothing — and doubles as both the spec the TigerBeetle adapter must match and a deterministic fake for higher-layer tests. Because TigerBeetle and Mongo cannot share a transaction, durability for that gap is honestly stated as rebuild-from-log, and a reconciliation job cross-checks the committed projection against TigerBeetle field-by-field on an interval. drift = 0 is a HARD non-functional requirement — but it is meaningful only once the real event sources are wired, which the repo's per-cell tracker says out loud rather than dressing up.

Internal contract & transport security

gRPC is the canonical internal contract (org ADR-006): .proto packages are versioned, and a breaking change requires a new package version, never an in-place edit. Every internal call carries a body-bound HMAC — the caller signs [METHOD, ts, nonce, sha256(canonicalBody)] where METHOD is the fully-qualified RPC path (so a signature can't be replayed onto a different RPC), and the verifier checks the HMAC in constant time, enforces a clock-skew window, and burns the nonce once via a durable Redis SET NX PX guard that makes replay protection cross-instance and fail-closed. Signing a gRPC body is a genuinely hard problem because proto3 elides default-valued fields on the wire; Settleo solves it with a transport-neutral canonical form (recursively drop proto3 defaults, sort keys, JSON-encode) computed identically by signer and verifier.

Testing & security posture

Ledger invariants are property-tested with fast-check; integration tests run against real TigerBeetle + a Mongo replica set via testcontainers. STRIDE threat models were authored before feature code, targeting OWASP ASVS L3 for money-tier services — the ledger's model maps eleven threats (L1–L11) each to a mitigation and a security test (overdraft-by-construction, replay-is-a-no-op, post-xor-void, all-or-nothing rollback). Boundary adapters that aren't built yet (MPC signer, PSP, chain-balance reads) are stubbed and fail closed rather than faked, and the honesty is a documented product rule: domain logic and FSMs are real and tested; simulated seams are labelled as such.

Related write-ups

Write-up in progress

A companion post for this project is being drafted. Check back soon.