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.
@noble/curves · @noble/hashes- ed25519 keygen + sign
- address = sha3-256(pubkey)[..20]
- messageToSign + computeHash
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:
1export function deriveAddress(rawPubkey: Uint8Array): string {2return "0x" + bytesToHex(sha3(rawPubkey).subarray(0, 20));3}
1pub fn derive_address(public_key: &[u8]) -> String {2let mut hasher = Sha3_256::new();3hasher.update(public_key);4let digest = hasher.finalize();5format!("0x{}", hex::encode(&digest[..20]))6}
2 · The canonical message to sign
1return [MAGIC, chain_id, from, to, asset, amount,2fee, priority_fee, nonce, timestamp].join("|");3// "XCH-1|1|0xfrom|0xto|XCH|1000|1000|0|0|1722000000000"
1format!(2"{MAGIC}|{}|{}|{}|{}|{}|{}|{}|{}|{}",3self.chain_id, self.from, self.to, self.asset, self.amount,4self.fee, self.priority_fee, self.nonce, self.timestamp5)
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:
1sha256hex(format!(2"{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",3self.chain_id, self.from, self.to, self.asset, self.amount,4self.fee, self.priority_fee, self.nonce, self.timestamp,5self.pubkey, self.signature, MAGIC6))
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:
This is the same ed25519 signing the app uses. The secret key signs; anyone with the public key can verify — but cannot forge.
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.
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.
0x5a83c218f9…497a7— the account's next transaction counterNotice 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
- Backend → Auth flow — the same signing proves wallet ownership.
- Backend → Transaction lifecycle — what the node does with the signature.
- Basics → Blockchain basics — refresher on hashes and keypairs.