X-Change Guide

Transactions

A transaction moves XCH from one wallet to another. It is created and signed in the browser, then sent to the node, which validates it, holds it in the mempool, and packs it into the next block.

Anatomy of a transaction

apps/node/src/chain/transaction.rs
1pub struct Transaction {
2 pub chain_id: i64, // network id (must equal CHAIN_ID=1)
3 pub from: String, // sender address
4 pub to: String, // recipient address (0x00..00 = burn)
5 pub asset: String, // "XCH" or "xUSD"
6 pub amount: i64, // minor units (1 XCH = 1e8)
7 pub fee: i64, // >= MIN_FEE
8 pub priority_fee: i64, // tip to the proposer (extra)
9 pub nonce: i64, // sender's tx counter (replay protection)
10 pub timestamp: i64,
11 pub pubkey: String, // sender's public key (hex)
12 pub signature: String, // ed25519 signature (hex)
13 pub hash: String, // sha256 of everything incl. signature
14}

The canonical message

Signing happens over a precise, pipe-delimited string. Both the frontend and the backend build exactly this string — if they ever disagreed, verification would fail:

message_to_sign
1"XCH-1|1|0xfrom|0xto|XCH|100000000|1000|0|0|1722000000000"
2// MAGIC |chain|from |to |asset|amount |fee |prio|nonce|timestamp

The full lifecycle

Browser signs
client builds tx, signs message
POST /tx/submit
Node validates
hash ✓ signature ✓ balance ✓ nonce ✓
Mempool
status='pending' row in Postgres
Next block (≤3s)
producer includes it
Confirmed
status='confirmed' + block_index

Step 1 — the browser signs

apps/web/app/(dashboard)/wallet/page.tsx (simplified)
1const { nonce } = await api.nonce(address);
2const unsigned = {
3 chain_id: CHAIN_ID, from: address, to, asset: "XCH",
4 amount: amountMinor, fee: feeMinor, priority_fee: 0,
5 nonce, timestamp: Date.now(),
6};
7const message = messageToSign(unsigned); // "XCH-1|..."
8const signature = signBytes(secretKey, encoder.encode(message));
9const tx = { ...unsigned, pubkey, signature,
10 hash: computeHash({ ...unsigned, pubkey, signature }) };
11await api.submit(tx);

Step 2 — the node validates

tx::submit rejects the transaction if any check fails. The two most important are verify_signature and validate_state:

verify_signature — two independent checks
1pub fn verify_signature(&self) -> bool {
2 self.hash == self.compute_hash() // (a) hash matches fields
3 && verify_signed_by(&self.pubkey, &self.signature, // (b) sig + key -> from
4 self.message_to_sign().as_bytes(), &self.from)
5}
validate_state — business rules
1fn validate_state(&self, balance, next_nonce, min_fee, chain_id) -> Result<(), ...> {
2 // chain_id must match · amount > 0 · fee >= min_fee
3 // from/to are valid 0x addresses
4 // nonce == next_nonce (replay protection)
5 // amount + fee + priority_fee <= balance (can't overdraw)
6}
Why nonce is checked twice
nonce == next_nonce means the account's stored counter must equal the tx's nonce. After a tx confirms, the nonce advances — so the same tx can never be replayed, and txs from the same wallet must be submitted one at a time in order.

Step 3–5 — mempool to confirmation

The mempool is just a database status: a row with status='pending'. The producer pulls pending txs ordered by fee + priority_fee DESC (richest first), re-validates them, and packs valid ones into a block. Confirmed txs get status='confirmed' plus block_index and block_hash. Invalid ones are marked failed.

apps/node/src/chain/mempool.rs — pending query
1SELECT hash, from_addr, to_addr, asset, amount, fee, priority_fee,
2 nonce, chain_id, timestamp, pubkey, signature, status, block_index
3FROM transactions WHERE status='pending'
4ORDER BY fee + priority_fee DESC LIMIT $1

Fees and the proposer

Every tx pays a base fee (≥ MIN_FEE = 1000 minor units) plus an optional priority_fee (a tip to jump the queue). In apply_block, both go to the block's proposer in the transaction's own asset. That's the incentive for producing blocks.

The faucet — server-side minting

To give testers coins, the node has a faucet: POST /faucet mints 100 XCH once per address. Interesting detail: the faucet tx is also a real signed transaction — but signed by the node, with its own faucet key (from FAUCET_SEED), from the faucet address which is pre-funded with 10,000 XCH at genesis.

faucet nonce trick — avoid collisions on burst claims
1let nonce = account_nonce + count_of_pending_faucet_txs;
2// the faucet signs many txs; counting its pending ones keeps nonces unique

Try it yourself

Live: build and sign a real transaction

This is exactly what the frontend does before calling POST /tx/submit. The message is a canonical string; the hash is SHA-256 of everything including the signature.

from0xde1a73ec76…5947
to
amountXCH
feeXCH (min 0.00001)
nonce7— the account's next transaction counter

Notice the hash includes the signature and the magic prefix XCH-1. When the node receives this, it recomputes the hash and verifies the signature against the public key — and that the public key derives to from.

Defense summary
"Transactions are created in the browser with the user's ed25519 key. The node checks the hash, the signature, and that the public key derives to the sender address — then checks balance, nonce, chain id and minimum fee. Valid txs sit in the mempool (a pending status in Postgres) until the 3-second producer packs them into a block and marks them confirmed."