X-Change Guide

Client-side crypto

The coolest part of this project: the cryptography is implemented twice— once in TypeScript for the browser, once in Rust for the node — and the two implementations must agree byte for byte. This page shows why that matters and how it works.

The trick: two languages, one output

The browser builds a transaction, hashes it and signs it. The node receives the result and recomputes everything. If the frontend and backend had even one byte of disagreement in how they format the message, every transaction would be rejected.

TypeScript (browser)
@noble/curves · @noble/hashes
  • ed25519 keygen + sign
  • address = sha3-256(pubkey)[..20]
  • messageToSign + computeHash
Rust (node)
ed25519-dalek · sha2 · sha3
  • ed25519 verify
  • derive_address (same formula)
  • message_to_sign + compute_hash

Both must produce the exact same strings — a single test asserts it.

The four crypto functions

1 · Address derivation

Identical in both languages:

TypeScript — lib/crypto-utils.ts
1export function deriveAddress(rawPubkey: Uint8Array): string {
2 return "0x" + bytesToHex(sha3(rawPubkey).subarray(0, 20));
3}
Rust — src/wallet.rs
1pub fn derive_address(public_key: &[u8]) -> String {
2 let mut hasher = Sha3_256::new();
3 hasher.update(public_key);
4 let digest = hasher.finalize();
5 format!("0x{}", hex::encode(&digest[..20]))
6}

2 · The canonical message to sign

TypeScript — messageToSign
1return [MAGIC, chain_id, from, to, asset, amount,
2 fee, priority_fee, nonce, timestamp].join("|");
3// "XCH-1|1|0xfrom|0xto|XCH|1000|1000|0|0|1722000000000"
Rust — message_to_sign
1format!(
2 "{MAGIC}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
3 self.chain_id, self.from, self.to, self.asset, self.amount,
4 self.fee, self.priority_fee, self.nonce, self.timestamp
5)

3 & 4 · Transaction hash

A subtle but test-worthy detail: the signing message uses SHA3-256 with the MAGIC prefix, while the transaction hash uses SHA-256 with the MAGIC suffix and includes pubkey + signature:

Rust — compute_hash (fields + pubkey + signature + |XCH-1)
1sha256hex(format!(
2 "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
3 self.chain_id, self.from, self.to, self.asset, self.amount,
4 self.fee, self.priority_fee, self.nonce, self.timestamp,
5 self.pubkey, self.signature, MAGIC
6))
Why include the signature in the hash?
The hash "seals" the entire signed transaction. Any change to any field — or to the signature itself — changes the hash, and the node catches it with one comparison.

Wallet storage & recovery

  • Secret key is 32 random bytes (ed25519), stored as hex in localStorage.
  • The wallet can be recovered from the secret key hex — import it anywhere, the address is re-derived from it.
  • The demo also ships a seed tool (cargo run --bin seed) that prints 5 pre-funded demo wallets you can import.

Try it — it's the real thing

These demos use the same libraries as the actual app, so what you see is what the code does:

Live: sign and verify with ed25519

This is the same ed25519 signing the app uses. The secret key signs; anyone with the public key can verify — but cannot forge.

address 0xc10f072e…8496
public key 9c3523d2ba32213f327d6c80271c794896d45c0eac4e06088ce3494dba28a808
secret key 83b52af9778186b8e1b8dd249581ca040a20fb641412c85140da46a06a628e0d

The address is 0x + sha3-256(pubkey)[..20]. Because the address is derived from the public key, proving you own the key proves you are the address. That is the whole idea behind sign-in-with-wallet.

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.

from0x5a83c218f9…497a
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.

Cross-references

Defense gold
"The frontend and backend each implement the same four crypto operations in different languages. Both build the identical canonical string, so the node can verify exactly what the browser signed. This dual implementation is a great thing to demonstrate live: sign on the site, then watch the node's logs confirm it."