X-Change Guide

Staking & the ledger

Two things live in the ledger layer: how balances are stored, and how proof-of-stake works (validators, staking, rewards, cooldowns) — plus multisig wallets on the side.

Balances

A balance is just a row: (address, asset) → amount. Two assets exist — XCH (native coin) and xUSD (the quote currency for trading). All amounts are integer minor units — no floating point, so no rounding bugs.

apps/node/src/ledger/balances.rs
1pub async fn get_balance(pool: &PgPool, address: &str, asset: &str) -> Result<i64, String> {
2 // SELECT balance FROM balances WHERE address=$1 AND asset=$2 (0 if absent)
3}
4pub async fn upsert_balance(pool, address, asset, new_balance) -> Result<(), String> {
5 // INSERT ... ON CONFLICT (address, asset) DO UPDATE SET balance = EXCLUDED.balance
6}

The block producer works on a full in-memory snapshot — load_view pulls every balance and nonce into a HashMap, and persist_view writes it all back after a block. That's the choke point where "chain state" meets Postgres.

Why the whole-view approach?
Validating a block needs a consistent snapshot of all balances and nonces. Loading the whole table into memory is fine for a demo (i64 math, thousands of rows). A real chain would use an in-memory state trie instead.

Proof of stake, concretely

To take part in consensus you become a validator: lock up at least MIN_STAKE (10 XCH), and your weight in the proposer lottery grows with your stake. Others can delegate by staking to you.

1 · Register

apps/node/src/ledger/staking.rs — register_validator
1INSERT INTO validators (address, stake, status, rewards)
2VALUES ($1, $2, 'active', 0)
3ON CONFLICT (address) DO UPDATE SET status='active'

2 · Stake (one DB transaction)

Check amount > 0 + balance
staker must have the XCH
Debit staker, credit validator escrow
XCH balance moves
Insert staking_position
address, validator, amount, unlock_epoch
Bump validator's stake
validators.stake += amount (creates row if missing)

Note: staking escrow is an on-chain transaction? No — it's a direct balance write inside a DB transaction, like the exchange. Only wallet-to-wallet transfers use the blockchain.

3 · Unstake = schedule a release

You can't pull staked coins out instantly (that would break consensus incentives). Unstaking marks positions with a future unlock_epoch = current + cooldown (default 2 epochs). The coins stay in escrow until that epoch's boundary, when process_epoch releases them.

unstake — only eligible (already-matured) stake can be scheduled
1let eligible: i64 = sqlx::query_scalar(
2 "SELECT COALESCE(SUM(amount),0)::bigint FROM staking_positions
3 WHERE address=$1 AND validator=$2 AND unlock_epoch <= $3",
4) ...;
5if amount > eligible { return Err(format!("only {eligible} eligible to unstake")); }

4 · Epoch rewards

At each epoch boundary (height % 100 == 0), process_epoch mints the epoch reward and splits it among active validators proportionally to stake — plus releases matured cooldowns:

process_epoch
1for v in &validators {
2 let reward = (total_reward as i128 * v.stake as i128 / total_stake as i128) as i64;
3 if reward <= 0 { continue; }
4 // credit v.address with [reward], accumulate validators.rewards
5}
6// then: release all positions with unlock_epoch <= current_epoch,
7// move escrow back to staker, drop validator stake, delete rows

Multisig wallets

A multisig wallet requires N of M signers to approve a transfer — e.g. "2 of 3" people must sign before funds move. Useful for shared or corporate accounts.

  • Address is derived deterministically: sha3-256(sorted(signers) + "|" + threshold)[..20]. Same signers + threshold → same address, so creation is idempotent.
  • Create — propose a tx (only a signer).
  • Sign — each signer endorses (JWT-authenticated; deduped).
  • Execute — once unique valid signatures ≥ threshold: move balances, bump the wallet's nonce, write a confirmed tx row.
multisig address derivation
1pub fn derive_multisig_address(signers: &[String], threshold: i32) -> String {
2 let mut sorted = signers.to_vec();
3 sorted.sort();
4 let mut hasher = Sha3_256::new();
5 hasher.update(sorted.join(",").as_bytes());
6 hasher.update(format!("|{threshold}"));
7 format!("0x{}", hex::encode(&hasher.finalize()[..20]))
8}
'Signatures' here are approvals, not crypto
Multisig endorsements are tracked as JWT-authenticated address approvals in a JSON array — not raw cryptographic signatures. The auth layer already proved who each signer is. A real multisig (like a Bitcoin multi-sig) collects actual signatures.

Where this lives

Defense summary
"Staking locks coins into a validator's escrow in one atomic DB transaction. Unstaking schedules a release two epochs later, so state can't be pulled out to game rewards. Every 100 blocks the node mints the epoch reward and splits it among validators in proportion to stake. Multisig adds N-of-M approval before funds move."