X-Change Guide

Database: schema & tables

PostgreSQL is the source of truth for everything. There are 12 tables, created by a single migration. Each one exists because a specific piece of the app needs it — the cross-links below show exactly where.

The database in one picture

Three groups of tables, roughly: chain (blocks, transactions),ledger (accounts, balances, validators, staking), and app (orders, trades, auth, multisig, faucet). They all live in the same database — the node just uses different tables for different jobs.

apps/node/src/db/migrations/0001_init.sql (single migration, applied at boot)
1CREATE TABLE accounts ( ... );
2CREATE TABLE balances ( ... );
3CREATE TABLE transactions ( ... );
4CREATE TABLE blocks ( ... );
5CREATE TABLE validators ( ... );
6CREATE TABLE staking_positions ( ... );
7CREATE TABLE orders ( ... );
8CREATE TABLE trades ( ... );
9CREATE TABLE auth_challenges ( ... );
10CREATE TABLE multisig_wallets ( ... );
11CREATE TABLE multisig_txs ( ... );
12CREATE TABLE faucet_claims ( ... );
13// applied automatically: sqlx::migrate!("./src/db/migrations").run(&db)
Design note worth mentioning
There are no foreign-key constraints — referential integrity is enforced in Rust. That's unusual but deliberate for a demo: the node logic already guarantees consistency, and it keeps the schema tiny. A production schema would likely add FKs.

Conventions used everywhere

  • Money = BIGINT minor units. 1 XCH = 1e8 minor units. No floats, ever.
  • Time = unix epoch milliseconds as BIGINT.
  • address = VARCHAR(44) ("0x" + 40 hex).
  • hash = VARCHAR(64) hex (SHA-256/SHA3-256).

All 12 tables

👤accountstable

One row per wallet address. Tracks the nonce (replay counter) and creation time.

ColumnTypeNotes
addressVARCHAR(44) PK0x + 40 hex chars
nonceBIGINTnext tx counter, starts 0
created_atBIGINTepoch ms
💰balancestable

How much of each asset an address owns. Composite key (address, asset).

ColumnTypeNotes
addressVARCHAR(44)
assetVARCHAR(16)'XCH' or 'xUSD'
balanceBIGINTminor units, no floats
🧾transactionstable

Every on-chain transaction. Also serves as the MEMPOOL (status='pending').

ColumnTypeNotes
hashVARCHAR(64) PK
from_addr / to_addrVARCHAR(44)
asset / amount / fee / priority_feeVARCHAR(16) / BIGINT
nonce / chain_id / timestampBIGINT
pubkey / signatureVARCHAR(64) / VARCHAR(128)
statusVARCHAR(10)pending → confirmed / failed
block_index / block_hashBIGINT / VARCHAR(64)NULL until confirmed
🧱blockstable

The chain itself: each row is a block. index is the height.

ColumnTypeNotes
indexBIGINT PKblock height (0 = genesis)
hashVARCHAR(64) UNIQUE
prev_hashVARCHAR(64)the chain link
merkle_rootVARCHAR(64)
proposerVARCHAR(44)
tx_countBIGINT
created_atBIGINT
⚖️validatorstable

Proof-of-stake participants. stake = their consensus weight.

ColumnTypeNotes
addressVARCHAR(44) PK
stakeBIGINT
statusVARCHAR(10)active / removed
rewardsBIGINTlifetime rewards
joined_atBIGINT
🔒staking_positionstable

Every individual stake. unlock_epoch controls when coins can be withdrawn.

ColumnTypeNotes
idBIGSERIAL PK
addressVARCHAR(44)the staker
validatorVARCHAR(44)who it's staked to
amountBIGINT
unlock_epochBIGINTwhen it matures
📋orderstable

Exchange orders. status drives the in-memory order book.

ColumnTypeNotes
idBIGSERIAL PK
traderVARCHAR(44)
pair / sideVARCHAR(16) / VARCHAR(4)side = bid | ask
price / amount / filledBIGINT
statusVARCHAR(10)open / closed / cancelled
created_atBIGINT
🤝tradestable

Every executed fill. Price is the maker's price.

ColumnTypeNotes
idBIGSERIAL PK
pair / price / amountVARCHAR(16) / BIGINT
buy_addr / sell_addrVARCHAR(44)
created_atBIGINT
🎟️auth_challengestable

Temporary login challenges (10 min, single-use).

ColumnTypeNotes
idBIGSERIAL PK
addressVARCHAR(44)
challengeVARCHAR(64)16 random bytes hex
expires_atBIGINT
usedBOOLEANsingle-use
🏦multisig_walletstable

N-of-M wallets. Address is derived from signers + threshold.

ColumnTypeNotes
addressVARCHAR(44) PK
signersTEXTJSON array of addresses
thresholdINT
created_atBIGINT
✍️multisig_txstable

Pending multisig proposals + their approvals.

ColumnTypeNotes
idBIGSERIAL PK
wallet / to_addr / asset / amount / nonce
signaturesTEXTJSON array of approvers
executedBOOLEAN
created_atBIGINT
🚰faucet_claimstable

One claim per address — makes the faucet one-time.

ColumnTypeNotes
addressVARCHAR(44) PK
claimed_atBIGINT

How the schema gets created

There's no "CREATE DATABASE xchange" step to run by hand — the node connects to a database and applies the migration at boot with sqlx::migrate!(). For local dev, docker compose up -d starts a Postgres 16 container with database xchange.

Next

See how the node reads and writes all of this — every query lives in one file — on the Database data & queries page.