diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..964d2cf0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [develop] + pull_request: + branches: [develop, main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + lint-and-build: + name: Lint & Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.81.0 + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81.0" + components: rustfmt, clippy + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy (server + shared) + run: cargo clippy -p server -p shared -- -D warnings + + - name: Run clippy (program lib) + run: cargo clippy -p zkcoins-program --lib -- -D warnings + + - name: Build server + run: cargo build -p server + + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.81.0 + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81.0" + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests (server + shared, skip slow SP1 prover tests) + run: cargo test -p server -p shared -- --skip account_server::tests + + - name: Run tests (program lib) + run: cargo test -p zkcoins-program --lib diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b281f454..26ae526f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ This guide covers everything you need to develop, test, and deploy the zkCoins b git clone https://github.com/zk-coins/server.git cd server SP1_PROVER=mock cargo run -p server -# Server starts on http://127.0.0.1:4242 +# Server starts on http://0.0.0.0:4242 ``` ## Prerequisites @@ -169,10 +169,11 @@ The `zkvm` feature gates the SP1 entrypoint and all `sp1_zkvm::` calls. | Variable | Default | Description | |---|---|---| -| `SP1_PROVER` | `mock` | `mock` (stub proofs) or `local` (real SP1) | -| `ESPLORA_URL` | `https://mutinynet.com/api` | Bitcoin node API | -| `BITCOIN_RPC_USER` | — | Bitcoin Core RPC username | -| `BITCOIN_RPC_PASSWORD` | — | Bitcoin Core RPC password | +| `SP1_PROVER` | `mock` | `mock` (no proof), `cpu`, `cuda`, or `network` | +| `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora API endpoint (electrs or public) | +| `IS_MAINNET` | `false` | `true` for Bitcoin Mainnet, `false` for Mutinynet/Signet | +| `NETWORK_NAME` | `Mutinynet` | Human-readable network name (returned by `/api/info`) | +| `PUBLISHER_KEY` | test key | 32-byte hex private key for inscription publishing. **Required on mainnet** | | `RUST_LOG` | `info` | Log level (`debug`, `info`, `warn`, `error`) | ## Docker @@ -182,15 +183,15 @@ docker build -t zkcoin/server . docker run -p 4242:4242 \ --network bitcoin \ -e SP1_PROVER=mock \ - -e ESPLORA_URL=http://bitcoind-mainnet:8332 \ + -e ESPLORA_URL=http://electrs-mainnet:3000 \ zkcoin/server ``` -The Dockerfile removes the `script` crate from the workspace (via `sed`) to avoid requiring the SP1 toolchain. The stub prover in `script/src/lib.rs` provides the same API surface with mock proofs. +The pre-built ELF (`elf/zkcoins-program`) is committed to the repo, so Docker builds do not require the Succinct toolchain — only standard Rust. ### Bitcoin Node -The server needs a Bitcoin node. In production, it connects via the shared Docker network `bitcoin` to `bitcoind-mainnet:8332`. Requirements: +The server needs a Bitcoin node with an Esplora-compatible indexer (electrs). In production, it connects via the shared Docker network `bitcoin` to `electrs-mainnet:3000` (DEV: `electrs-mutinynet:3000`). The underlying bitcoind requires: - `txindex=1` - `rest=1` - `server=1` @@ -207,15 +208,6 @@ See [docs.zkcoins.app/infrastructure/backend](https://docs.zkcoins.app/infrastru Build time is ~5 minutes (Rust compilation on ARM64). -## API Reference - -| Endpoint | Method | Description | Success | -|---|---|---|---| -| `/api/mint` | POST | Mint coins from minting account | `{ proof_id }` | -| `/api/send` | POST | Transfer coins between accounts | `{ proof_id }` | -| `/api/balance?address=` | GET | Query account balance | `{ balance }` | -| `/api/proof/:id` | GET | Download coin proof (binary) | Binary data | - ## Related Repos - [zk-coins/app](https://github.com/zk-coins/app) — Web application (frontend) diff --git a/Cargo.lock b/Cargo.lock index 6ca1b5f4..265a0beb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4644,11 +4644,14 @@ dependencies = [ "bitcoin_hashes 0.16.0", "esplora-client", "hex", + "http-body-util", "lazy_static", "serde", + "serde_json", "sha2 0.10.8", "shared", "tokio", + "tower 0.5.2", "tower-http", "zkcoins-program", "zkcoins-prover", diff --git a/README.md b/README.md index 0c0aae0a..3443f968 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Rust/Axum backend for [zkcoins.app](https://zkcoins.app) — account management, | ZK Proofs | SP1 zkVM | Write proofs in standard Rust, no DSL | | Data structures | SMT + MMR | Non-inclusion proofs + append-only history | | Bitcoin | Taproot Inscriptions | 64-byte nullifiers, Esplora API scanning | -| Bitcoin node | bitcoind-mainnet | Shared Docker network `bitcoin`, port 8332 | +| Bitcoin index | electrs (Esplora) | Esplora REST API via shared Docker network `bitcoin` | Full rationale: [docs.zkcoins.app/tech-decisions](https://docs.zkcoins.app/tech-decisions) @@ -36,11 +36,25 @@ SP1_PROVER=mock cargo run -p server | Endpoint | Method | Description | Response | |---|---|---|---| | `/health` | GET | Health check | `ok` (200) | -| `/api/mint` | POST | Mint coins (faucet) | `{ proof_id }` | -| `/api/send` | POST | Transfer coins | `{ proof_id }` | +| `/api/info` | GET | Network info | `{ network }` | +| `/api/mint` | POST | Mint coins (faucet) | `{ success, proof_id }` | +| `/api/send` | POST | Transfer coins (phase 1) | `{ success, proof_id, account_state_hash, output_coins_root }` | +| `/api/commit` | POST | Submit signed commitment (phase 2) | `{ success, proof_id }` | | `/api/balance?address=` | GET | Query balance | `{ balance }` | +| `/api/address` | GET | List all addresses | `{ addresses }` | +| `/api/receive` | POST | Receive coins from sender | `{ success }` | | `/api/proof/:id` | GET | Download coin proof | Binary | +### Two-Phase Send Flow + +User sends require a two-phase flow because the server doesn't hold sender private keys: + +1. **`POST /api/send`** — server generates ZK proof, returns `proof_id` + `account_state_hash` + `output_coins_root` +2. **Client signs commitment** — `Schnorr(hash_concat(account_state_hash, output_coins_root))` with BIP-32 key at `numPubkeys` +3. **`POST /api/commit`** — server verifies commitment, broadcasts Taproot inscription, delivers coin to recipient via `receive_coin` + +Mint uses a single-phase flow (server holds the minting account key). + ## Project Structure ``` @@ -54,7 +68,7 @@ server/ # Axum REST API │ └── publisher.rs # Taproot Inscription broadcaster (commit/reveal) shared/ # Shared types (Commitment, Invoice, ClientAccount) program/ # SP1 zkVM circuit types (AccountState, Coin, ProofData) -│ └── src/merkle/ # SMT + MMR implementations +├── src/merkle/ # SMT + MMR implementations script/ # Prover (real SP1 zkVM — create_account, update_account) ``` @@ -63,9 +77,10 @@ script/ # Prover (real SP1 zkVM — create_account, update_accoun | Variable | Default | Description | |---|---|---| | `SP1_PROVER` | `mock` | `mock` (no proof), `cpu`, `cuda`, or `network` | -| `ESPLORA_URL` | `https://mutinynet.com/api` | Bitcoin node API | -| `BITCOIN_RPC_USER` | — | Bitcoin Core RPC username | -| `BITCOIN_RPC_PASSWORD` | — | Bitcoin Core RPC password | +| `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora API endpoint (electrs or public) | +| `IS_MAINNET` | `false` | `true` for Bitcoin Mainnet, `false` for Mutinynet/Signet | +| `NETWORK_NAME` | `Mutinynet` | Human-readable network name (returned by `/api/info`) | +| `PUBLISHER_KEY` | test key | 32-byte hex private key for inscription publishing. **Required on mainnet** — server panics if default test key is used | | `RUST_LOG` | `info` | Log level | ## Docker @@ -75,7 +90,7 @@ docker build -t zkcoin/server . docker run -p 4242:4242 \ --network bitcoin \ -e SP1_PROVER=mock \ - -e ESPLORA_URL=http://bitcoind-mainnet:8332 \ + -e ESPLORA_URL=http://electrs-mainnet:3000 \ zkcoin/server ``` @@ -97,7 +112,8 @@ Staged scaling for the SP1 prover: | Stage | When to move | Configuration | |---|---|---| -| **1. CPU (current)** | Baseline | `SP1_PROVER=cpu` running on Mac Studio M3 Ultra, 96 GB unified memory. Measure `update_account` / `create_account` latency under real load before scaling further. | +| **0. Mock (DEV)** | Development & testing | `SP1_PROVER=mock` — no real proofs, instant responses. Required on DEV because CPU prover causes OOM (SP1 `update_account` exceeds available memory). | +| **1. CPU (PRD)** | Production baseline | `SP1_PROVER=cpu` running on Mac Studio M3 Ultra, 96 GB unified memory. `create_account` works, `update_account` needs memory tuning. | | **2. Succinct Prover Network** | CPU latency becomes a bottleneck | `SP1_PROVER=network` — no hardware commitment, requires PROVE token deposit and accepts token-price exposure. See [docs.succinct.xyz](https://docs.succinct.xyz/docs/sp1/prover-network/quickstart). | | **3. Self-hosted CUDA** | Network volume too costly or PROVE exposure undesirable | `SP1_PROVER=cuda` on x86 Linux with NVIDIA GPU (Compute Capability ≥ 8.6, ≥ 24 GB VRAM — RTX 4090 / 5090 / RTX 6000 Ada). Apple Silicon is not supported. | @@ -105,11 +121,8 @@ Skip stages only with concrete latency or cost data, not assumptions. ## Open Tasks -- [x] CORS headers (allow frontend to call API directly) -- [x] Real SP1 proofs (CPU prover live on DEV/PRD) - [ ] GPU acceleration (`SP1_PROVER=cuda`) or Succinct Prover Network - [ ] Explorer endpoints (`/api/stats`, `/api/nullifiers`) -- [ ] Publisher key from environment variable (currently hardcoded) - [ ] Light client support ## Related diff --git a/elf/zkcoins-program b/elf/zkcoins-program index 2a2eb044..e6991fa4 100755 Binary files a/elf/zkcoins-program and b/elf/zkcoins-program differ diff --git a/program/src/lib.rs b/program/src/lib.rs index 61d96901..cef4277e 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,11 +1,11 @@ use merkle::{hash_concat, merkle_mountain_range::MMRProof}; -use rand::Rng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use derive_builder::Builder; use merkle::{ - sparse_merkle_tree::{InclusionProof, NonInclusionProof, DEFAULT_HASHES}, HashDigest + sparse_merkle_tree::{InclusionProof, NonInclusionProof, DEFAULT_HASHES}, + HashDigest, }; pub type Amount = u64; @@ -67,7 +67,10 @@ impl CommitmentMerkleProofs { } } -pub const MINTING_ADDRESS: HashDigest = [44, 153, 26, 227, 141, 88, 195, 127, 88, 144, 228, 143, 121, 49, 51, 158, 111, 205, 183, 53, 133, 35, 183, 240, 183, 165, 104, 116, 66, 228, 94, 242]; +pub const MINTING_ADDRESS: HashDigest = [ + 175, 83, 161, 5, 16, 78, 44, 44, 237, 20, 140, 19, 48, 116, 86, 210, 247, 116, 223, 190, 106, + 191, 59, 198, 226, 248, 55, 102, 143, 24, 155, 216, +]; pub fn hash(data: &[u8]) -> HashDigest { Sha256::digest(data).into() @@ -139,13 +142,7 @@ pub struct AccountState { impl AccountState { pub fn new(initial_public_key: PublicKey) -> Self { - // TODO: The randomness here is annoying why do we not hash the public key directly and - // skip the first one in the commitments? - // We add random bytes to the public key as a blinding factor for the address. - // This ensures that the on-chain commited public keys can not be linked to the address. - let mut rng = rand::thread_rng(); - let random_bytes: [u8; 32] = rng.gen(); - let address = hash(&[initial_public_key.clone(), random_bytes.to_vec()].concat()); + let address = hash(&initial_public_key); AccountState { owner: address, balance: 0, @@ -160,7 +157,7 @@ impl AccountState { self.balance = match self.balance.checked_add(coin.amount) { Some(balance) => balance, - None => return Err("Receiving coin causes an overflow") + None => return Err("Receiving coin causes an overflow"), }; Ok(self) } @@ -187,7 +184,7 @@ impl AccountState { // Apply coin. self.balance = match self.balance.checked_sub(coin.amount) { Some(balance) => balance, - None => return Err("Balance too small to create Coin.") + None => return Err("Balance too small to create Coin."), }; } diff --git a/program/src/main.rs b/program/src/main.rs index a186ce6c..b6f1056f 100644 --- a/program/src/main.rs +++ b/program/src/main.rs @@ -26,9 +26,15 @@ fn verify_account_state_proof( let previous_proof_data = verify_proof(public_values, vkey); let account_state_hash = account_state.hash(); assert_eq!(account_state_hash, previous_proof_data.account_state_hash); - assert_eq!(account_state_hash, merkle_proofs.commitment_account_state_hash); + assert_eq!( + account_state_hash, + merkle_proofs.commitment_account_state_hash + ); assert!(merkle_proofs.verify_commitment(commitment_history_root)); - assert!(merkle_proofs.verify_previous_root(previous_proof_data.commitment_history_root, commitment_history_root)); + assert!(merkle_proofs.verify_previous_root( + previous_proof_data.commitment_history_root, + commitment_history_root + )); previous_proof_data.coin_history_root } @@ -45,7 +51,10 @@ fn verify_coin_proof( assert!(coin_proof.verify(coin.identifier, out_coin_root)); assert_eq!(out_coin_root, merkle_proofs.commitment_out_coins_root); assert!(merkle_proofs.verify_commitment(commitment_history_root)); - assert!(merkle_proofs.verify_previous_root(coin_proof_data.commitment_history_root, commitment_history_root)); + assert!(merkle_proofs.verify_previous_root( + coin_proof_data.commitment_history_root, + commitment_history_root + )); } pub fn main() { @@ -57,9 +66,13 @@ pub fn main() { let mut coin_history_root = match hidden_inputs.proof_type { ProofType::AccountUpdateProof => verify_account_state_proof( &account_state, - hidden_inputs.prev_proof_public_values.expect("Missing previous proofs public values"), + hidden_inputs + .prev_proof_public_values + .expect("Missing previous proofs public values"), vkey, - hidden_inputs.prev_proof_history_proofs.expect("Missing previous proof's history proofs"), + hidden_inputs + .prev_proof_history_proofs + .expect("Missing previous proof's history proofs"), commitment_history_root, ), ProofType::InitialProof => { @@ -71,36 +84,50 @@ pub fn main() { }; let mut coin_history_proofs = hidden_inputs.in_coin_proofs_history_proofs.into_iter(); - let mut non_inclusion_proofs = hidden_inputs.in_coin_proofs_non_inclusion_proofs.into_iter(); + let mut non_inclusion_proofs = hidden_inputs + .in_coin_proofs_non_inclusion_proofs + .into_iter(); let mut public_values = hidden_inputs.in_coin_proofs_public_values.into_iter(); let mut inclusion_proofs = hidden_inputs.in_coins_inclusion_proofs.into_iter(); for coin in &hidden_inputs.in_coins { verify_coin_proof( - public_values.next().expect("Missing coin proof public values"), + public_values + .next() + .expect("Missing coin proof public values"), vkey, - coin_history_proofs.next().expect("Missing coin proof history proofs"), + coin_history_proofs + .next() + .expect("Missing coin proof history proofs"), commitment_history_root, coin, - inclusion_proofs.next().expect("Missing coin inclusion proof"), + inclusion_proofs + .next() + .expect("Missing coin inclusion proof"), ); - let coin_non_inclusion_proof = non_inclusion_proofs.next().expect("Missing non_inclusion_proofs"); + let coin_non_inclusion_proof = non_inclusion_proofs + .next() + .expect("Missing non_inclusion_proofs"); assert_eq!(coin_history_root, coin_non_inclusion_proof.root); - coin_history_root = coin_non_inclusion_proof.verify_and_insert(coin.identifier).expect("Coin was already integrated"); + coin_history_root = coin_non_inclusion_proof + .verify_and_insert(coin.identifier) + .expect("Coin was already integrated"); account_state = account_state.apply_coin(coin).unwrap(); } - let output_coins_root = account_state.send_coins( - hidden_inputs.out_coins, - hidden_inputs.out_coin_proofs, - hidden_inputs.next_public_key - ).unwrap(); + let output_coins_root = account_state + .send_coins( + hidden_inputs.out_coins, + hidden_inputs.out_coin_proofs, + hidden_inputs.next_public_key, + ) + .unwrap(); let commitment = ProofData { vk: vkey, account_state_hash: account_state.hash(), output_coins_root, commitment_history_root, - coin_history_root + coin_history_root, }; sp1_zkvm::io::commit::(&commitment); } diff --git a/program/src/merkle/sparse_merkle_tree.rs b/program/src/merkle/sparse_merkle_tree.rs index 3993f7ff..b9dbc8c1 100644 --- a/program/src/merkle/sparse_merkle_tree.rs +++ b/program/src/merkle/sparse_merkle_tree.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fs::File; use std::io::{self, Read, Write}; -use super::{HashDigest, ZERO_HASH, hash_concat}; +use super::{hash_concat, HashDigest, ZERO_HASH}; /// The tree depth. For a 256-bit key space, depth is 256. pub const TREE_DEPTH: usize = 256; @@ -50,7 +50,6 @@ impl InclusionProof { let mut siblings = self.siblings.clone(); // Start with the leaf hash and work our way up to the root while let Some(sibling) = siblings.pop() { - // Get the bit at this level (from most significant to least) let branch = get_bit(&self.key, siblings.len()); @@ -79,7 +78,7 @@ pub struct NonInclusionProof { /// The sibling hashes along the path from the root to the leaf pub siblings: Vec, /// The sibling hint (key, leaf) - pub leaf: ([u8; 32], HashDigest) + pub leaf: ([u8; 32], HashDigest), } impl NonInclusionProof { @@ -102,7 +101,6 @@ impl NonInclusionProof { }; // Reconstruct the root by combining the siblings while let Some(sibling) = siblings.pop() { - // Combine the current hash with its sibling in the correct order current_hash = if get_bit(&self.leaf.0, siblings.len()) { hash_concat(&sibling, ¤t_hash) @@ -137,8 +135,7 @@ impl NonInclusionProof { // non-inclusion proof: expecting keys not equal debug_assert_ne!(self.leaf.0, self.key); // Padding with default hashes - while get_bit(&self.key, siblings.len()) - == get_bit(&self.leaf.0, siblings.len()) { + while get_bit(&self.key, siblings.len()) == get_bit(&self.leaf.0, siblings.len()) { siblings.push(DEFAULT_HASHES[siblings.len() + 1]) } let sibling = hash_concat(&self.leaf.1, &self.leaf.0); @@ -152,7 +149,6 @@ impl NonInclusionProof { }; // Hash through previous siblings while let Some(sibling) = siblings.pop() { - // Combine children in the correct order. current_hash = if get_bit(&self.key, siblings.len()) { hash_concat(&sibling, ¤t_hash) @@ -168,7 +164,7 @@ impl NonInclusionProof { pub fn verify_and_insert(&self, leaf: HashDigest) -> Result { // First, verify the proof using the global DEFAULT_HASHES. if !self.verify() { - return Err("Invalid non-inclusion proof") + return Err("Invalid non-inclusion proof"); } self.insert(leaf) } @@ -258,12 +254,13 @@ impl SparseMerkleTree { /// Returns an error if the key already exists in the tree. /// The key is assumed to be a 256‑bit value (as a `[u8; 32]` array). pub fn insert(&mut self, key: [u8; 32], leaf: HashDigest) -> Result<(), &'static str> { - // Check if the key already exists in the tree if self.leaf_values.contains_key(&key) { // Allow to insert the exact same leaf return if self.leaf_values.get(&key) == Some(&leaf) { - Ok(eprintln!("\u{1B}[33mWARNING: Leaf already exists in the tree\u{1B}[0m")) + Ok(eprintln!( + "\u{1B}[33mWARNING: Leaf already exists in the tree\u{1B}[0m" + )) } else { Err("Key already exists in the tree with different value") }; @@ -286,9 +283,16 @@ impl SparseMerkleTree { let parent_key = trim_key(&key, level); // Sibling key is computed by taking the opposite branch. let sibling_key = child_key(&parent_key, !branch, level); - let sibling = self.nodes.get(&(level + 1, sibling_key)).cloned().unwrap_or(DEFAULT_HASHES[level + 1]); + let sibling = self + .nodes + .get(&(level + 1, sibling_key)) + .cloned() + .unwrap_or(DEFAULT_HASHES[level + 1]); // Update sibling node - self.nodes.insert((level + 1, child_key(&parent_key, branch, level)), current_hash); + self.nodes.insert( + (level + 1, child_key(&parent_key, branch, level)), + current_hash, + ); if current_hash != leaf_hash || sibling != DEFAULT_HASHES[level + 1] { current_hash = if branch { // Combine children in the correct order. @@ -329,12 +333,12 @@ impl SparseMerkleTree { let mut sibling_leaf = (key, DEFAULT_HASHES[TREE_DEPTH]); - if self.nodes.get(&(0, [0; 32])).is_none() { + if !self.nodes.contains_key(&(0, [0; 32])) { return Ok(NonInclusionProof { key, root: DEFAULT_HASHES[0], siblings, - leaf: (key, DEFAULT_HASHES[0]) + leaf: (key, DEFAULT_HASHES[0]), }); } @@ -345,29 +349,45 @@ impl SparseMerkleTree { if let Some(parent) = self.nodes.get(&(level, parent_key)) { // Compute the sibling key (the key for the other branch) let sibling_key = child_key(&parent_key, !branch, level); - let sibling = self.nodes.get(&(level + 1, sibling_key)).cloned().unwrap_or(DEFAULT_HASHES[level + 1]); + let sibling = self + .nodes + .get(&(level + 1, sibling_key)) + .cloned() + .unwrap_or(DEFAULT_HASHES[level + 1]); let key = child_key(&parent_key, branch, level); - let child = self.nodes.get(&(level + 1, key)).cloned().unwrap_or(DEFAULT_HASHES[level + 1]); + let child = self + .nodes + .get(&(level + 1, key)) + .cloned() + .unwrap_or(DEFAULT_HASHES[level + 1]); if sibling == *parent || child == *parent { let mut parent_key = if child == *parent { key } else { sibling_key }; // Restore full sibling key and fetch its leaf for layer in level + 1..TREE_DEPTH { let key_1 = child_key(&parent_key, true, layer); let key_0 = child_key(&parent_key, false, layer); - let node_1 = self.nodes.get(&(layer + 1, key_1)).cloned().unwrap_or(DEFAULT_HASHES[layer + 1]); - let node_0 = self.nodes.get(&(layer + 1, key_0)).cloned().unwrap_or(DEFAULT_HASHES[layer + 1]); + let node_1 = self + .nodes + .get(&(layer + 1, key_1)) + .cloned() + .unwrap_or(DEFAULT_HASHES[layer + 1]); + let node_0 = self + .nodes + .get(&(layer + 1, key_0)) + .cloned() + .unwrap_or(DEFAULT_HASHES[layer + 1]); debug_assert!(node_1 == *parent || node_0 == *parent); parent_key = if node_1 == *parent { key_1 } else { key_0 }; } sibling_leaf.0 = parent_key; sibling_leaf.1 = *self.leaf_values.get(&parent_key).unwrap(); - break + break; } siblings.push(sibling); } else { sibling_leaf.0 = key; sibling_leaf.1 = DEFAULT_HASHES[level]; - break + break; } } @@ -375,7 +395,7 @@ impl SparseMerkleTree { key, root: self.root(), siblings, - leaf: sibling_leaf + leaf: sibling_leaf, }) } @@ -392,7 +412,6 @@ impl SparseMerkleTree { &self, key: &[u8; 32], ) -> Result<(InclusionProof, HashDigest), &'static str> { - // Check if this key exists in the nodes map at the leaf level if !self.nodes.contains_key(&(TREE_DEPTH, *key)) { // The key doesn't exist in the tree @@ -402,16 +421,30 @@ impl SparseMerkleTree { let commitment = self.get(key).unwrap(); let mut siblings = Vec::new(); - let mut parent = self.nodes.get(&(0, [0; 32])).cloned().unwrap_or(DEFAULT_HASHES[0]); + let mut parent = self + .nodes + .get(&(0, [0; 32])) + .cloned() + .unwrap_or(DEFAULT_HASHES[0]); for level in 0..TREE_DEPTH { let branch = get_bit(key, level); let parent_key = trim_key(key, level); let sibling_key = child_key(&parent_key, !branch, level); - let sibling = self.nodes.get(&(level + 1, sibling_key)).cloned().unwrap_or(DEFAULT_HASHES[level + 1]); + let sibling = self + .nodes + .get(&(level + 1, sibling_key)) + .cloned() + .unwrap_or(DEFAULT_HASHES[level + 1]); let child_key = child_key(&parent_key, branch, level); - let child = self.nodes.get(&(level + 1, child_key)).cloned().unwrap_or(DEFAULT_HASHES[level + 1]); - if child == parent || sibling == parent { break } + let child = self + .nodes + .get(&(level + 1, child_key)) + .cloned() + .unwrap_or(DEFAULT_HASHES[level + 1]); + if child == parent || sibling == parent { + break; + } siblings.push(sibling); parent = child; } @@ -453,56 +486,256 @@ mod tests { use super::*; const SAMPLES: [[u8; 32]; 50] = [ - [0xFF,0x86,0x1D,0xB2,0xA9,0xA1,0x5A,0x20,0x0A,0x6E,0xED,0x82,0xF8,0x3F,0xFA,0x04,0xD0,0x3B,0xB4,0xDB,0xF1,0x23,0xAC,0x2F,0x19,0x74,0xE2,0xB2,0xC8,0x86,0xD4,0x37], - [0x2D,0x54,0x24,0xE6,0x8B,0xA1,0x19,0xFA,0x0B,0x20,0x82,0xD2,0x74,0x02,0x3E,0xAA,0xA3,0x81,0xCA,0x0E,0xB7,0x8E,0xB1,0x86,0x9E,0xBF,0xB8,0x95,0x9B,0xA2,0x59,0xE8], - [0xF8,0x1C,0xA1,0xF1,0xF4,0x93,0x7A,0x62,0x14,0x05,0x32,0xA1,0xF4,0x43,0xD7,0xAB,0xCA,0x9A,0x15,0xC2,0xA3,0xCF,0x3F,0x42,0x5D,0x90,0x7D,0xEC,0x29,0xE7,0x5D,0x71], - [0xA2,0xFC,0xAD,0x39,0xBC,0x3B,0x65,0x30,0x78,0x31,0x34,0x46,0x89,0x05,0x49,0xE9,0xF6,0xF1,0x06,0x9B,0x13,0xDB,0x75,0xD4,0x45,0xC1,0x97,0x43,0x2A,0xD6,0x1C,0x64], - [0xC7,0x79,0x0C,0x63,0xE2,0xA5,0x01,0x6F,0xA6,0xC4,0xA1,0x6E,0xB5,0x3C,0x0D,0x7A,0xF9,0xF4,0xFD,0x58,0x02,0xF0,0xF1,0x8C,0x7F,0xC0,0x4E,0x3D,0x58,0x3A,0x60,0xF2], - [0xD4,0xE9,0x69,0xD7,0x52,0xAD,0xBD,0xF2,0x41,0x08,0x96,0xB2,0xD7,0xBD,0xF6,0x6D,0x4B,0x43,0x81,0xC9,0x1B,0xD3,0xC9,0x96,0x27,0x2F,0xAB,0xE7,0xC2,0xF7,0x60,0xC4], - [0x00,0x5E,0x18,0x2F,0x55,0x0A,0xFA,0x74,0x8E,0x8E,0xE2,0x12,0xAF,0xF4,0xBD,0xE6,0xF2,0x04,0xEE,0x7F,0xE1,0xD7,0x05,0x0C,0x1B,0x16,0x4B,0x48,0xC3,0x49,0x70,0x0F], - [0x95,0x4A,0x8A,0x33,0x34,0x99,0x42,0xA0,0x95,0x98,0x1F,0x83,0x03,0x58,0x92,0xAC,0xEE,0xA6,0x70,0xE4,0x3C,0x00,0x55,0xEE,0xB4,0x71,0xD1,0xAC,0xDC,0xB6,0xDB,0x21], - [0xB3,0x7B,0xF4,0xB3,0x6E,0x4F,0x41,0x47,0xD7,0x39,0xB8,0x4F,0x5E,0xC4,0x68,0x18,0x4F,0xAD,0x9C,0xE7,0x76,0x65,0x70,0x6B,0xC6,0x88,0x77,0x9E,0x29,0x1D,0x0B,0xC8], - [0x01,0xBA,0xF8,0x76,0xBF,0x30,0xFF,0x03,0xDF,0x84,0x61,0x4F,0xC1,0x06,0xCB,0x37,0x00,0x78,0x13,0xC6,0x0B,0xAE,0x30,0x69,0xD4,0xB0,0x25,0x0C,0x29,0x0F,0x2F,0x80], - [0x6D,0xB8,0xE4,0xA7,0xE4,0xA6,0x37,0x00,0x2F,0x47,0xBD,0x50,0x67,0x3D,0x7A,0x89,0x2D,0x3F,0xFE,0xE3,0xBA,0x58,0x15,0xBE,0x9A,0xDA,0xA7,0xE2,0x8A,0xDE,0xD4,0xB7], - [0x78,0xDE,0x51,0x6F,0x01,0xF2,0x28,0xFE,0x23,0xEE,0xFA,0xA3,0x7C,0x91,0xF0,0x07,0x41,0x7A,0x59,0x36,0xF8,0x87,0x57,0x91,0x8A,0x9E,0x39,0xF3,0x84,0x98,0xF0,0xF6], - [0xCB,0x08,0x00,0xD0,0xB5,0x17,0xF0,0x2F,0x80,0x8A,0xC8,0x40,0xAC,0x52,0xAF,0x27,0x2D,0x10,0x22,0xE4,0x30,0xB3,0x72,0x34,0x3F,0xBD,0x0C,0x23,0x44,0x87,0x14,0xCC], - [0x7F,0x87,0xAD,0x4E,0x0F,0x83,0x18,0x12,0x2D,0x73,0x4C,0xB3,0xF5,0x42,0x69,0x5E,0xC3,0xAC,0x03,0x00,0xB1,0x27,0xCB,0xFE,0x07,0x9C,0xED,0xC3,0x4A,0xFC,0x09,0xB4], - [0x1A,0x73,0x9F,0x3E,0xE9,0x1F,0xE5,0x6B,0x3C,0xE0,0x81,0x75,0x78,0xC8,0x7E,0x8D,0x65,0x1A,0x33,0xE4,0x57,0x2F,0x4C,0x2D,0x0F,0x02,0x3F,0x76,0x57,0xB1,0x51,0x82], - [0x76,0x9D,0x74,0x79,0xBC,0x89,0xBF,0xA2,0x67,0x54,0x27,0x67,0xC7,0xE9,0xFD,0x81,0x3F,0xBC,0x2F,0x85,0xBB,0x09,0x82,0xFC,0x70,0x29,0x93,0x8B,0x44,0x8B,0xB0,0x5D], - [0xB5,0x07,0x83,0xBF,0x44,0x92,0xE3,0xCB,0x65,0x85,0x01,0xFF,0x8D,0xDB,0xF5,0xEC,0x90,0x04,0x1C,0x81,0xA1,0x08,0x70,0x11,0xD4,0x80,0x4C,0xA4,0x7B,0xA0,0x59,0x11], - [0x92,0x2F,0x9C,0xA9,0x27,0xE4,0xEA,0xB5,0x4F,0x85,0x45,0xC3,0xFB,0x17,0xAD,0x68,0x54,0x0F,0x4E,0x96,0x3E,0xF8,0x22,0x61,0x8F,0x4E,0x5A,0x8E,0x75,0x97,0x47,0x3F], - [0xC5,0xC2,0xBC,0x32,0x2C,0xE9,0xC4,0x0E,0x36,0x10,0xF0,0x02,0x67,0xBF,0xF5,0x2A,0x24,0xF7,0x31,0x7F,0x0F,0xBE,0x18,0x0C,0x2A,0x18,0x71,0x15,0xE4,0x21,0x35,0xA9], - [0xCF,0x06,0x69,0x7D,0x61,0xD1,0x18,0xC5,0xF2,0xE2,0x78,0x82,0xDC,0x0D,0xF3,0x06,0xAA,0xA5,0x21,0x12,0xAA,0xCA,0x48,0x1D,0x6C,0xA7,0x66,0x3D,0xDF,0xA5,0x2A,0x00], - [0xA7,0x3D,0xF6,0x26,0xE0,0x12,0xAB,0x45,0xE7,0x7E,0xB3,0x90,0x99,0x11,0x73,0x72,0x21,0x18,0x85,0x57,0xF2,0xCF,0x1E,0xBE,0xC2,0x78,0x66,0x3D,0x67,0xD6,0xDE,0x0F], - [0xF7,0xFC,0x3C,0xAA,0xAC,0xF4,0x70,0x84,0x62,0x79,0xBC,0x6B,0x78,0x92,0x85,0x25,0x2C,0xCB,0x10,0x9E,0x57,0x3A,0x77,0xA9,0x12,0x57,0xE9,0x6B,0x87,0x70,0x69,0xAE], - [0x65,0x43,0x0E,0x20,0x0A,0x8B,0x3E,0x38,0xD0,0x7F,0x75,0x52,0x4C,0xC3,0x51,0x29,0x56,0x69,0x1E,0xB8,0xEB,0x80,0x15,0x95,0x0C,0xD7,0x52,0xF7,0x53,0x16,0x00,0x4B], - [0xB8,0xC5,0xEF,0xF1,0x16,0xDA,0x0D,0x16,0xE4,0xF1,0xB1,0x0B,0x91,0x39,0x1E,0xC1,0x3F,0x3C,0xD3,0x9D,0xAD,0x7D,0x2A,0x85,0xCA,0x5E,0xCE,0xEC,0xFC,0x30,0xEE,0x73], - [0x2D,0x48,0xB4,0x51,0xC1,0x5F,0x56,0x7A,0x96,0x78,0x4D,0xB7,0x5D,0xFB,0xF7,0xE7,0xA1,0xA8,0xDA,0xAF,0x1B,0x42,0xFB,0x12,0xE0,0xC2,0x3B,0xFC,0x28,0x34,0x6C,0x7A], - [0xB1,0x21,0x6A,0x05,0xEF,0xF1,0xFC,0x1C,0x41,0x1D,0xF8,0xC5,0xF8,0x72,0x83,0xA0,0xEA,0x2F,0x19,0x22,0x29,0x11,0x42,0x19,0x42,0x31,0xD3,0xEB,0xE2,0xFC,0xF2,0xFA], - [0xE2,0xA9,0xAD,0x90,0x5F,0xDE,0xE0,0x97,0xB9,0x83,0x6C,0xF9,0x04,0x07,0x01,0x54,0x68,0x15,0x67,0x9A,0x4F,0x88,0x64,0x8E,0x4F,0xAD,0xA0,0xA7,0x0F,0xF7,0xFA,0xBB], - [0xDB,0xDD,0xB1,0x47,0x1D,0x8B,0x12,0x3F,0xF9,0x3F,0x9E,0x3D,0xDE,0x91,0xBC,0x36,0x5E,0x53,0x2E,0x32,0x55,0xB4,0x2D,0x35,0x12,0x29,0x5A,0x6E,0xE5,0xEB,0xBF,0x48], - [0xAB,0x9A,0x8C,0x63,0x8A,0x8B,0xDE,0xBE,0x24,0x93,0xC4,0x23,0x1E,0xF3,0x55,0x27,0x54,0x2E,0xC2,0x59,0xC6,0x7B,0xC7,0x00,0x6D,0x44,0x1A,0x5A,0x63,0x99,0x51,0x14], - [0x46,0xAD,0xA6,0x5D,0x94,0x68,0xE7,0x74,0x70,0x51,0x60,0x64,0x19,0x0A,0x22,0x10,0xEF,0xFE,0x34,0x24,0x8F,0x25,0xAA,0xE8,0xEE,0x53,0xCD,0xFD,0xE9,0xD0,0x7E,0x36], - [0x31,0xAC,0x1C,0xA2,0xC2,0xD0,0xF4,0x0F,0x9C,0xD4,0x47,0x9A,0xE7,0x3E,0xA8,0xD0,0x17,0xB0,0x7E,0xF1,0xCF,0x1F,0x22,0xC1,0xB4,0x81,0x7E,0x2C,0xD2,0xAB,0x0A,0xC5], - [0xAA,0xCE,0x93,0x26,0x30,0x36,0x81,0xE5,0xCE,0xAF,0x72,0x45,0xB4,0xCB,0x54,0x9F,0xB0,0x5F,0x29,0xAE,0x5A,0xE2,0x05,0xFC,0xFF,0x34,0x9A,0x9B,0xF9,0x01,0x88,0x0E], - [0x8C,0x2C,0x47,0xEB,0xF1,0x33,0x7D,0x64,0xE4,0xAB,0x71,0xFE,0x61,0xBB,0x8A,0xB2,0xEE,0x02,0xA1,0x4C,0x56,0xA5,0x5C,0x79,0xAC,0x75,0x7D,0x3D,0x02,0xD0,0x29,0xEA], - [0x24,0xF2,0xA4,0x7D,0x59,0x72,0x2F,0xD4,0x02,0xE8,0x5E,0xEF,0x01,0xDD,0x67,0x50,0xAD,0xDE,0xE1,0x1A,0xF4,0x73,0x88,0x14,0x71,0x04,0xF2,0x9E,0x55,0xC4,0xCC,0x3A], - [0xB0,0xBD,0x22,0x70,0x36,0xDF,0x04,0x92,0x2D,0x73,0x1B,0xAD,0x63,0xAF,0x29,0x51,0x1C,0x59,0x36,0x82,0xD6,0xE7,0xC9,0x4A,0x22,0xEE,0xA6,0x46,0x2E,0x65,0xA8,0x0C], - [0x66,0xAC,0x15,0xAF,0x80,0x88,0x69,0x05,0x81,0x63,0x2B,0x19,0x57,0xB3,0x20,0xC5,0x81,0xAF,0xD9,0x89,0xC3,0x60,0x4D,0xB3,0x6C,0xCF,0x6F,0xFB,0x87,0x5D,0x94,0xC2], - [0xEF,0x9F,0x14,0xBA,0x96,0x6D,0x52,0xB6,0x9F,0xEE,0xAF,0x6C,0xAE,0x68,0x51,0xD6,0x3A,0x60,0xBF,0x4E,0x97,0x36,0xA0,0x29,0x8E,0x58,0x04,0xD4,0x7E,0xA7,0xD2,0x52], - [0x9E,0x23,0x7A,0xB7,0xF5,0xEB,0xA7,0xDE,0x94,0x75,0x25,0xF0,0xCF,0x0A,0x8B,0x5D,0x2C,0x7A,0xC5,0x21,0x4D,0xB3,0x5A,0x2D,0xBA,0xCA,0x8C,0x6E,0xCA,0x24,0x33,0xC6], - [0x92,0x5C,0x2C,0x6A,0x89,0x02,0x04,0xA0,0xB3,0x08,0xDB,0x0C,0x55,0x54,0xF7,0xDC,0x6C,0xF9,0x6F,0x06,0xC6,0x6D,0x56,0xD8,0xA2,0xEB,0x17,0xF8,0xBD,0xCD,0x26,0x0C], - [0xD3,0xB0,0x44,0x3D,0x9A,0xDB,0x10,0xD4,0x70,0xEE,0x72,0x15,0x0E,0x8B,0x34,0x3F,0xF2,0x84,0x40,0x2F,0x31,0xF5,0x37,0x0A,0x88,0x7D,0xDF,0x28,0xF3,0x13,0xD3,0xEC], - [0xB3,0xB3,0xBD,0x3A,0x71,0x6C,0x66,0x55,0x36,0x73,0x17,0x65,0x39,0x82,0x85,0x3B,0xA2,0x2C,0xB5,0xF9,0x8A,0x65,0x9E,0xF3,0x8E,0x77,0x02,0x6E,0x13,0xA4,0xB2,0x73], - [0x3C,0x11,0xAE,0x67,0xF5,0x80,0xC0,0x4E,0x6F,0xC0,0x03,0x9B,0x2A,0xD0,0xEC,0x4E,0x4A,0x38,0x3F,0xC3,0x62,0x3B,0x9A,0xAE,0x54,0x08,0x63,0xE0,0xBE,0x4D,0x5C,0x21], - [0x0A,0x60,0x74,0x8E,0xE2,0x37,0x24,0x81,0x2C,0xBC,0x13,0xA0,0xBA,0xF1,0x33,0x4B,0xFD,0xE1,0x1B,0x23,0x07,0x6D,0x5B,0x1A,0x38,0xD6,0x09,0x98,0xDB,0x65,0x0C,0x75], - [0xFC,0xB5,0x46,0x72,0xE3,0xBC,0x2B,0xAD,0xA1,0xAF,0x1F,0x36,0x1C,0x6E,0x62,0x06,0x41,0x62,0x8C,0x1C,0x7A,0x1F,0x5B,0x8B,0x8F,0x85,0xA2,0x00,0x99,0x32,0xBD,0x41], - [0x19,0xEE,0x3D,0x28,0x51,0x27,0xAE,0xFA,0xF7,0x60,0xBC,0x10,0x42,0x14,0x7C,0x67,0x4E,0x6A,0x47,0x47,0xA7,0x9F,0x4E,0xC3,0xB2,0x1C,0xE4,0x6C,0x02,0x5E,0x89,0x9C], - [0xB8,0xD9,0x6C,0xDE,0xA1,0x88,0x53,0xC2,0xD5,0xFA,0x01,0x9F,0x12,0xD6,0xFD,0xF5,0x48,0xAA,0x0B,0xF4,0x8D,0xBC,0x0F,0x5B,0x13,0x24,0x52,0x24,0x10,0x72,0xE6,0x0C], - [0x94,0x40,0x9B,0x3C,0x0F,0x21,0xDF,0x96,0x91,0x59,0x29,0xE6,0xC8,0xFC,0xC2,0x07,0xC9,0x58,0x44,0xA7,0xED,0xF5,0x20,0x22,0xE6,0x5E,0x8F,0x93,0xC9,0xC9,0x51,0xBF], - [0x7A,0x85,0x40,0x71,0xD6,0x4A,0x4B,0x58,0x8D,0xD6,0x20,0xDF,0x7F,0xB1,0x34,0x58,0xD8,0x8C,0x5A,0x6B,0x55,0xB0,0x75,0xCD,0x6A,0x52,0x8F,0x9D,0xB0,0x08,0xED,0xC6], - [0x4E,0xC4,0x9B,0x94,0xC3,0x5A,0x63,0xC6,0xD9,0xDC,0x45,0x41,0xF6,0x30,0x55,0x10,0x9E,0x91,0x00,0x05,0x2C,0xDA,0x94,0x6D,0xB3,0x76,0xD1,0x44,0xFA,0x44,0xD7,0xD3], - [0x00,0x74,0xC8,0x8F,0x71,0x48,0x6A,0x2C,0xEC,0x90,0x97,0x05,0x77,0x9A,0x26,0x9E,0x42,0xBB,0x08,0x97,0x89,0xAE,0xE2,0xB6,0x6C,0x58,0x4F,0x4E,0xE3,0x51,0x39,0xA5], + [ + 0xFF, 0x86, 0x1D, 0xB2, 0xA9, 0xA1, 0x5A, 0x20, 0x0A, 0x6E, 0xED, 0x82, 0xF8, 0x3F, + 0xFA, 0x04, 0xD0, 0x3B, 0xB4, 0xDB, 0xF1, 0x23, 0xAC, 0x2F, 0x19, 0x74, 0xE2, 0xB2, + 0xC8, 0x86, 0xD4, 0x37, + ], + [ + 0x2D, 0x54, 0x24, 0xE6, 0x8B, 0xA1, 0x19, 0xFA, 0x0B, 0x20, 0x82, 0xD2, 0x74, 0x02, + 0x3E, 0xAA, 0xA3, 0x81, 0xCA, 0x0E, 0xB7, 0x8E, 0xB1, 0x86, 0x9E, 0xBF, 0xB8, 0x95, + 0x9B, 0xA2, 0x59, 0xE8, + ], + [ + 0xF8, 0x1C, 0xA1, 0xF1, 0xF4, 0x93, 0x7A, 0x62, 0x14, 0x05, 0x32, 0xA1, 0xF4, 0x43, + 0xD7, 0xAB, 0xCA, 0x9A, 0x15, 0xC2, 0xA3, 0xCF, 0x3F, 0x42, 0x5D, 0x90, 0x7D, 0xEC, + 0x29, 0xE7, 0x5D, 0x71, + ], + [ + 0xA2, 0xFC, 0xAD, 0x39, 0xBC, 0x3B, 0x65, 0x30, 0x78, 0x31, 0x34, 0x46, 0x89, 0x05, + 0x49, 0xE9, 0xF6, 0xF1, 0x06, 0x9B, 0x13, 0xDB, 0x75, 0xD4, 0x45, 0xC1, 0x97, 0x43, + 0x2A, 0xD6, 0x1C, 0x64, + ], + [ + 0xC7, 0x79, 0x0C, 0x63, 0xE2, 0xA5, 0x01, 0x6F, 0xA6, 0xC4, 0xA1, 0x6E, 0xB5, 0x3C, + 0x0D, 0x7A, 0xF9, 0xF4, 0xFD, 0x58, 0x02, 0xF0, 0xF1, 0x8C, 0x7F, 0xC0, 0x4E, 0x3D, + 0x58, 0x3A, 0x60, 0xF2, + ], + [ + 0xD4, 0xE9, 0x69, 0xD7, 0x52, 0xAD, 0xBD, 0xF2, 0x41, 0x08, 0x96, 0xB2, 0xD7, 0xBD, + 0xF6, 0x6D, 0x4B, 0x43, 0x81, 0xC9, 0x1B, 0xD3, 0xC9, 0x96, 0x27, 0x2F, 0xAB, 0xE7, + 0xC2, 0xF7, 0x60, 0xC4, + ], + [ + 0x00, 0x5E, 0x18, 0x2F, 0x55, 0x0A, 0xFA, 0x74, 0x8E, 0x8E, 0xE2, 0x12, 0xAF, 0xF4, + 0xBD, 0xE6, 0xF2, 0x04, 0xEE, 0x7F, 0xE1, 0xD7, 0x05, 0x0C, 0x1B, 0x16, 0x4B, 0x48, + 0xC3, 0x49, 0x70, 0x0F, + ], + [ + 0x95, 0x4A, 0x8A, 0x33, 0x34, 0x99, 0x42, 0xA0, 0x95, 0x98, 0x1F, 0x83, 0x03, 0x58, + 0x92, 0xAC, 0xEE, 0xA6, 0x70, 0xE4, 0x3C, 0x00, 0x55, 0xEE, 0xB4, 0x71, 0xD1, 0xAC, + 0xDC, 0xB6, 0xDB, 0x21, + ], + [ + 0xB3, 0x7B, 0xF4, 0xB3, 0x6E, 0x4F, 0x41, 0x47, 0xD7, 0x39, 0xB8, 0x4F, 0x5E, 0xC4, + 0x68, 0x18, 0x4F, 0xAD, 0x9C, 0xE7, 0x76, 0x65, 0x70, 0x6B, 0xC6, 0x88, 0x77, 0x9E, + 0x29, 0x1D, 0x0B, 0xC8, + ], + [ + 0x01, 0xBA, 0xF8, 0x76, 0xBF, 0x30, 0xFF, 0x03, 0xDF, 0x84, 0x61, 0x4F, 0xC1, 0x06, + 0xCB, 0x37, 0x00, 0x78, 0x13, 0xC6, 0x0B, 0xAE, 0x30, 0x69, 0xD4, 0xB0, 0x25, 0x0C, + 0x29, 0x0F, 0x2F, 0x80, + ], + [ + 0x6D, 0xB8, 0xE4, 0xA7, 0xE4, 0xA6, 0x37, 0x00, 0x2F, 0x47, 0xBD, 0x50, 0x67, 0x3D, + 0x7A, 0x89, 0x2D, 0x3F, 0xFE, 0xE3, 0xBA, 0x58, 0x15, 0xBE, 0x9A, 0xDA, 0xA7, 0xE2, + 0x8A, 0xDE, 0xD4, 0xB7, + ], + [ + 0x78, 0xDE, 0x51, 0x6F, 0x01, 0xF2, 0x28, 0xFE, 0x23, 0xEE, 0xFA, 0xA3, 0x7C, 0x91, + 0xF0, 0x07, 0x41, 0x7A, 0x59, 0x36, 0xF8, 0x87, 0x57, 0x91, 0x8A, 0x9E, 0x39, 0xF3, + 0x84, 0x98, 0xF0, 0xF6, + ], + [ + 0xCB, 0x08, 0x00, 0xD0, 0xB5, 0x17, 0xF0, 0x2F, 0x80, 0x8A, 0xC8, 0x40, 0xAC, 0x52, + 0xAF, 0x27, 0x2D, 0x10, 0x22, 0xE4, 0x30, 0xB3, 0x72, 0x34, 0x3F, 0xBD, 0x0C, 0x23, + 0x44, 0x87, 0x14, 0xCC, + ], + [ + 0x7F, 0x87, 0xAD, 0x4E, 0x0F, 0x83, 0x18, 0x12, 0x2D, 0x73, 0x4C, 0xB3, 0xF5, 0x42, + 0x69, 0x5E, 0xC3, 0xAC, 0x03, 0x00, 0xB1, 0x27, 0xCB, 0xFE, 0x07, 0x9C, 0xED, 0xC3, + 0x4A, 0xFC, 0x09, 0xB4, + ], + [ + 0x1A, 0x73, 0x9F, 0x3E, 0xE9, 0x1F, 0xE5, 0x6B, 0x3C, 0xE0, 0x81, 0x75, 0x78, 0xC8, + 0x7E, 0x8D, 0x65, 0x1A, 0x33, 0xE4, 0x57, 0x2F, 0x4C, 0x2D, 0x0F, 0x02, 0x3F, 0x76, + 0x57, 0xB1, 0x51, 0x82, + ], + [ + 0x76, 0x9D, 0x74, 0x79, 0xBC, 0x89, 0xBF, 0xA2, 0x67, 0x54, 0x27, 0x67, 0xC7, 0xE9, + 0xFD, 0x81, 0x3F, 0xBC, 0x2F, 0x85, 0xBB, 0x09, 0x82, 0xFC, 0x70, 0x29, 0x93, 0x8B, + 0x44, 0x8B, 0xB0, 0x5D, + ], + [ + 0xB5, 0x07, 0x83, 0xBF, 0x44, 0x92, 0xE3, 0xCB, 0x65, 0x85, 0x01, 0xFF, 0x8D, 0xDB, + 0xF5, 0xEC, 0x90, 0x04, 0x1C, 0x81, 0xA1, 0x08, 0x70, 0x11, 0xD4, 0x80, 0x4C, 0xA4, + 0x7B, 0xA0, 0x59, 0x11, + ], + [ + 0x92, 0x2F, 0x9C, 0xA9, 0x27, 0xE4, 0xEA, 0xB5, 0x4F, 0x85, 0x45, 0xC3, 0xFB, 0x17, + 0xAD, 0x68, 0x54, 0x0F, 0x4E, 0x96, 0x3E, 0xF8, 0x22, 0x61, 0x8F, 0x4E, 0x5A, 0x8E, + 0x75, 0x97, 0x47, 0x3F, + ], + [ + 0xC5, 0xC2, 0xBC, 0x32, 0x2C, 0xE9, 0xC4, 0x0E, 0x36, 0x10, 0xF0, 0x02, 0x67, 0xBF, + 0xF5, 0x2A, 0x24, 0xF7, 0x31, 0x7F, 0x0F, 0xBE, 0x18, 0x0C, 0x2A, 0x18, 0x71, 0x15, + 0xE4, 0x21, 0x35, 0xA9, + ], + [ + 0xCF, 0x06, 0x69, 0x7D, 0x61, 0xD1, 0x18, 0xC5, 0xF2, 0xE2, 0x78, 0x82, 0xDC, 0x0D, + 0xF3, 0x06, 0xAA, 0xA5, 0x21, 0x12, 0xAA, 0xCA, 0x48, 0x1D, 0x6C, 0xA7, 0x66, 0x3D, + 0xDF, 0xA5, 0x2A, 0x00, + ], + [ + 0xA7, 0x3D, 0xF6, 0x26, 0xE0, 0x12, 0xAB, 0x45, 0xE7, 0x7E, 0xB3, 0x90, 0x99, 0x11, + 0x73, 0x72, 0x21, 0x18, 0x85, 0x57, 0xF2, 0xCF, 0x1E, 0xBE, 0xC2, 0x78, 0x66, 0x3D, + 0x67, 0xD6, 0xDE, 0x0F, + ], + [ + 0xF7, 0xFC, 0x3C, 0xAA, 0xAC, 0xF4, 0x70, 0x84, 0x62, 0x79, 0xBC, 0x6B, 0x78, 0x92, + 0x85, 0x25, 0x2C, 0xCB, 0x10, 0x9E, 0x57, 0x3A, 0x77, 0xA9, 0x12, 0x57, 0xE9, 0x6B, + 0x87, 0x70, 0x69, 0xAE, + ], + [ + 0x65, 0x43, 0x0E, 0x20, 0x0A, 0x8B, 0x3E, 0x38, 0xD0, 0x7F, 0x75, 0x52, 0x4C, 0xC3, + 0x51, 0x29, 0x56, 0x69, 0x1E, 0xB8, 0xEB, 0x80, 0x15, 0x95, 0x0C, 0xD7, 0x52, 0xF7, + 0x53, 0x16, 0x00, 0x4B, + ], + [ + 0xB8, 0xC5, 0xEF, 0xF1, 0x16, 0xDA, 0x0D, 0x16, 0xE4, 0xF1, 0xB1, 0x0B, 0x91, 0x39, + 0x1E, 0xC1, 0x3F, 0x3C, 0xD3, 0x9D, 0xAD, 0x7D, 0x2A, 0x85, 0xCA, 0x5E, 0xCE, 0xEC, + 0xFC, 0x30, 0xEE, 0x73, + ], + [ + 0x2D, 0x48, 0xB4, 0x51, 0xC1, 0x5F, 0x56, 0x7A, 0x96, 0x78, 0x4D, 0xB7, 0x5D, 0xFB, + 0xF7, 0xE7, 0xA1, 0xA8, 0xDA, 0xAF, 0x1B, 0x42, 0xFB, 0x12, 0xE0, 0xC2, 0x3B, 0xFC, + 0x28, 0x34, 0x6C, 0x7A, + ], + [ + 0xB1, 0x21, 0x6A, 0x05, 0xEF, 0xF1, 0xFC, 0x1C, 0x41, 0x1D, 0xF8, 0xC5, 0xF8, 0x72, + 0x83, 0xA0, 0xEA, 0x2F, 0x19, 0x22, 0x29, 0x11, 0x42, 0x19, 0x42, 0x31, 0xD3, 0xEB, + 0xE2, 0xFC, 0xF2, 0xFA, + ], + [ + 0xE2, 0xA9, 0xAD, 0x90, 0x5F, 0xDE, 0xE0, 0x97, 0xB9, 0x83, 0x6C, 0xF9, 0x04, 0x07, + 0x01, 0x54, 0x68, 0x15, 0x67, 0x9A, 0x4F, 0x88, 0x64, 0x8E, 0x4F, 0xAD, 0xA0, 0xA7, + 0x0F, 0xF7, 0xFA, 0xBB, + ], + [ + 0xDB, 0xDD, 0xB1, 0x47, 0x1D, 0x8B, 0x12, 0x3F, 0xF9, 0x3F, 0x9E, 0x3D, 0xDE, 0x91, + 0xBC, 0x36, 0x5E, 0x53, 0x2E, 0x32, 0x55, 0xB4, 0x2D, 0x35, 0x12, 0x29, 0x5A, 0x6E, + 0xE5, 0xEB, 0xBF, 0x48, + ], + [ + 0xAB, 0x9A, 0x8C, 0x63, 0x8A, 0x8B, 0xDE, 0xBE, 0x24, 0x93, 0xC4, 0x23, 0x1E, 0xF3, + 0x55, 0x27, 0x54, 0x2E, 0xC2, 0x59, 0xC6, 0x7B, 0xC7, 0x00, 0x6D, 0x44, 0x1A, 0x5A, + 0x63, 0x99, 0x51, 0x14, + ], + [ + 0x46, 0xAD, 0xA6, 0x5D, 0x94, 0x68, 0xE7, 0x74, 0x70, 0x51, 0x60, 0x64, 0x19, 0x0A, + 0x22, 0x10, 0xEF, 0xFE, 0x34, 0x24, 0x8F, 0x25, 0xAA, 0xE8, 0xEE, 0x53, 0xCD, 0xFD, + 0xE9, 0xD0, 0x7E, 0x36, + ], + [ + 0x31, 0xAC, 0x1C, 0xA2, 0xC2, 0xD0, 0xF4, 0x0F, 0x9C, 0xD4, 0x47, 0x9A, 0xE7, 0x3E, + 0xA8, 0xD0, 0x17, 0xB0, 0x7E, 0xF1, 0xCF, 0x1F, 0x22, 0xC1, 0xB4, 0x81, 0x7E, 0x2C, + 0xD2, 0xAB, 0x0A, 0xC5, + ], + [ + 0xAA, 0xCE, 0x93, 0x26, 0x30, 0x36, 0x81, 0xE5, 0xCE, 0xAF, 0x72, 0x45, 0xB4, 0xCB, + 0x54, 0x9F, 0xB0, 0x5F, 0x29, 0xAE, 0x5A, 0xE2, 0x05, 0xFC, 0xFF, 0x34, 0x9A, 0x9B, + 0xF9, 0x01, 0x88, 0x0E, + ], + [ + 0x8C, 0x2C, 0x47, 0xEB, 0xF1, 0x33, 0x7D, 0x64, 0xE4, 0xAB, 0x71, 0xFE, 0x61, 0xBB, + 0x8A, 0xB2, 0xEE, 0x02, 0xA1, 0x4C, 0x56, 0xA5, 0x5C, 0x79, 0xAC, 0x75, 0x7D, 0x3D, + 0x02, 0xD0, 0x29, 0xEA, + ], + [ + 0x24, 0xF2, 0xA4, 0x7D, 0x59, 0x72, 0x2F, 0xD4, 0x02, 0xE8, 0x5E, 0xEF, 0x01, 0xDD, + 0x67, 0x50, 0xAD, 0xDE, 0xE1, 0x1A, 0xF4, 0x73, 0x88, 0x14, 0x71, 0x04, 0xF2, 0x9E, + 0x55, 0xC4, 0xCC, 0x3A, + ], + [ + 0xB0, 0xBD, 0x22, 0x70, 0x36, 0xDF, 0x04, 0x92, 0x2D, 0x73, 0x1B, 0xAD, 0x63, 0xAF, + 0x29, 0x51, 0x1C, 0x59, 0x36, 0x82, 0xD6, 0xE7, 0xC9, 0x4A, 0x22, 0xEE, 0xA6, 0x46, + 0x2E, 0x65, 0xA8, 0x0C, + ], + [ + 0x66, 0xAC, 0x15, 0xAF, 0x80, 0x88, 0x69, 0x05, 0x81, 0x63, 0x2B, 0x19, 0x57, 0xB3, + 0x20, 0xC5, 0x81, 0xAF, 0xD9, 0x89, 0xC3, 0x60, 0x4D, 0xB3, 0x6C, 0xCF, 0x6F, 0xFB, + 0x87, 0x5D, 0x94, 0xC2, + ], + [ + 0xEF, 0x9F, 0x14, 0xBA, 0x96, 0x6D, 0x52, 0xB6, 0x9F, 0xEE, 0xAF, 0x6C, 0xAE, 0x68, + 0x51, 0xD6, 0x3A, 0x60, 0xBF, 0x4E, 0x97, 0x36, 0xA0, 0x29, 0x8E, 0x58, 0x04, 0xD4, + 0x7E, 0xA7, 0xD2, 0x52, + ], + [ + 0x9E, 0x23, 0x7A, 0xB7, 0xF5, 0xEB, 0xA7, 0xDE, 0x94, 0x75, 0x25, 0xF0, 0xCF, 0x0A, + 0x8B, 0x5D, 0x2C, 0x7A, 0xC5, 0x21, 0x4D, 0xB3, 0x5A, 0x2D, 0xBA, 0xCA, 0x8C, 0x6E, + 0xCA, 0x24, 0x33, 0xC6, + ], + [ + 0x92, 0x5C, 0x2C, 0x6A, 0x89, 0x02, 0x04, 0xA0, 0xB3, 0x08, 0xDB, 0x0C, 0x55, 0x54, + 0xF7, 0xDC, 0x6C, 0xF9, 0x6F, 0x06, 0xC6, 0x6D, 0x56, 0xD8, 0xA2, 0xEB, 0x17, 0xF8, + 0xBD, 0xCD, 0x26, 0x0C, + ], + [ + 0xD3, 0xB0, 0x44, 0x3D, 0x9A, 0xDB, 0x10, 0xD4, 0x70, 0xEE, 0x72, 0x15, 0x0E, 0x8B, + 0x34, 0x3F, 0xF2, 0x84, 0x40, 0x2F, 0x31, 0xF5, 0x37, 0x0A, 0x88, 0x7D, 0xDF, 0x28, + 0xF3, 0x13, 0xD3, 0xEC, + ], + [ + 0xB3, 0xB3, 0xBD, 0x3A, 0x71, 0x6C, 0x66, 0x55, 0x36, 0x73, 0x17, 0x65, 0x39, 0x82, + 0x85, 0x3B, 0xA2, 0x2C, 0xB5, 0xF9, 0x8A, 0x65, 0x9E, 0xF3, 0x8E, 0x77, 0x02, 0x6E, + 0x13, 0xA4, 0xB2, 0x73, + ], + [ + 0x3C, 0x11, 0xAE, 0x67, 0xF5, 0x80, 0xC0, 0x4E, 0x6F, 0xC0, 0x03, 0x9B, 0x2A, 0xD0, + 0xEC, 0x4E, 0x4A, 0x38, 0x3F, 0xC3, 0x62, 0x3B, 0x9A, 0xAE, 0x54, 0x08, 0x63, 0xE0, + 0xBE, 0x4D, 0x5C, 0x21, + ], + [ + 0x0A, 0x60, 0x74, 0x8E, 0xE2, 0x37, 0x24, 0x81, 0x2C, 0xBC, 0x13, 0xA0, 0xBA, 0xF1, + 0x33, 0x4B, 0xFD, 0xE1, 0x1B, 0x23, 0x07, 0x6D, 0x5B, 0x1A, 0x38, 0xD6, 0x09, 0x98, + 0xDB, 0x65, 0x0C, 0x75, + ], + [ + 0xFC, 0xB5, 0x46, 0x72, 0xE3, 0xBC, 0x2B, 0xAD, 0xA1, 0xAF, 0x1F, 0x36, 0x1C, 0x6E, + 0x62, 0x06, 0x41, 0x62, 0x8C, 0x1C, 0x7A, 0x1F, 0x5B, 0x8B, 0x8F, 0x85, 0xA2, 0x00, + 0x99, 0x32, 0xBD, 0x41, + ], + [ + 0x19, 0xEE, 0x3D, 0x28, 0x51, 0x27, 0xAE, 0xFA, 0xF7, 0x60, 0xBC, 0x10, 0x42, 0x14, + 0x7C, 0x67, 0x4E, 0x6A, 0x47, 0x47, 0xA7, 0x9F, 0x4E, 0xC3, 0xB2, 0x1C, 0xE4, 0x6C, + 0x02, 0x5E, 0x89, 0x9C, + ], + [ + 0xB8, 0xD9, 0x6C, 0xDE, 0xA1, 0x88, 0x53, 0xC2, 0xD5, 0xFA, 0x01, 0x9F, 0x12, 0xD6, + 0xFD, 0xF5, 0x48, 0xAA, 0x0B, 0xF4, 0x8D, 0xBC, 0x0F, 0x5B, 0x13, 0x24, 0x52, 0x24, + 0x10, 0x72, 0xE6, 0x0C, + ], + [ + 0x94, 0x40, 0x9B, 0x3C, 0x0F, 0x21, 0xDF, 0x96, 0x91, 0x59, 0x29, 0xE6, 0xC8, 0xFC, + 0xC2, 0x07, 0xC9, 0x58, 0x44, 0xA7, 0xED, 0xF5, 0x20, 0x22, 0xE6, 0x5E, 0x8F, 0x93, + 0xC9, 0xC9, 0x51, 0xBF, + ], + [ + 0x7A, 0x85, 0x40, 0x71, 0xD6, 0x4A, 0x4B, 0x58, 0x8D, 0xD6, 0x20, 0xDF, 0x7F, 0xB1, + 0x34, 0x58, 0xD8, 0x8C, 0x5A, 0x6B, 0x55, 0xB0, 0x75, 0xCD, 0x6A, 0x52, 0x8F, 0x9D, + 0xB0, 0x08, 0xED, 0xC6, + ], + [ + 0x4E, 0xC4, 0x9B, 0x94, 0xC3, 0x5A, 0x63, 0xC6, 0xD9, 0xDC, 0x45, 0x41, 0xF6, 0x30, + 0x55, 0x10, 0x9E, 0x91, 0x00, 0x05, 0x2C, 0xDA, 0x94, 0x6D, 0xB3, 0x76, 0xD1, 0x44, + 0xFA, 0x44, 0xD7, 0xD3, + ], + [ + 0x00, 0x74, 0xC8, 0x8F, 0x71, 0x48, 0x6A, 0x2C, 0xEC, 0x90, 0x97, 0x05, 0x77, 0x9A, + 0x26, 0x9E, 0x42, 0xBB, 0x08, 0x97, 0x89, 0xAE, 0xE2, 0xB6, 0x6C, 0x58, 0x4F, 0x4E, + 0xE3, 0x51, 0x39, 0xA5, + ], ]; #[test] @@ -510,16 +743,19 @@ mod tests { let mut tree = SparseMerkleTree::new(); let value = [42; HASH_SIZE]; for key in SAMPLES { - let non_inclusion = tree.generate_non_inclusion_proof(key).unwrap(); // Insert should succeed for a new key assert!(tree.insert(key, value).is_ok()); - assert_eq!(tree.root(), non_inclusion.verify_and_insert(value).unwrap(), "Roots deviate"); + assert_eq!( + tree.root(), + non_inclusion.verify_and_insert(value).unwrap(), + "Roots deviate" + ); } } - + #[test] fn test_verify_and_insert_sibling() { let mut tree = SparseMerkleTree::new(); @@ -536,7 +772,11 @@ mod tests { assert!(tree.insert(key, value).is_ok()); - assert_eq!(tree.root(), non_inclusion.verify_and_insert(value).unwrap(), "Roots deviate"); + assert_eq!( + tree.root(), + non_inclusion.verify_and_insert(value).unwrap(), + "Roots deviate" + ); } } @@ -545,7 +785,6 @@ mod tests { let mut tree = SparseMerkleTree::new(); let value = [42; HASH_SIZE]; for key in SAMPLES { - // Insert should succeed for a new key assert!(tree.insert(key, value).is_ok()); @@ -554,11 +793,11 @@ mod tests { } } - #[test] fn test_insert_existing_key() { + #[test] + fn test_insert_existing_key() { let mut tree = SparseMerkleTree::new(); let value = [42; HASH_SIZE]; for key in SAMPLES { - // First insertion should succeed assert!(tree.insert(key, value).is_ok()); @@ -578,7 +817,6 @@ mod tests { let mut tree = SparseMerkleTree::new(); let value = [42; HASH_SIZE]; for key in SAMPLES { - // Get the initial root let initial_root = tree.root(); @@ -616,7 +854,6 @@ mod tests { let mut tree = SparseMerkleTree::new(); let value = [45; HASH_SIZE]; for key in SAMPLES { - // Insert the key-value pair assert!(tree.insert(key, value).is_ok()); @@ -652,7 +889,10 @@ mod tests { for (value, key) in SAMPLES.into_iter().enumerate() { // Test non-existent key - assert!(tree.generate_inclusion_proof(&key).is_err(), "Proof for non-existent key should fail"); + assert!( + tree.generate_inclusion_proof(&key).is_err(), + "Proof for non-existent key should fail" + ); // Insert key and test proof match tree.insert(key, [value as u8; HASH_SIZE]) { Ok(_) => { diff --git a/server/Cargo.toml b/server/Cargo.toml index c5521622..cbb46352 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,5 +22,10 @@ tower-http = { version = "0.5", features = ["cors", "fs"] } +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +serde_json = "1.0" + [features] default = [] diff --git a/server/src/account_server.rs b/server/src/account_server.rs index f9bf2049..5cbb318d 100644 --- a/server/src/account_server.rs +++ b/server/src/account_server.rs @@ -1,10 +1,8 @@ +use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard}; -use std::{collections::HashMap, mem::take}; use crate::state::State; -use bitcoin::bip32::Xpriv; use bitcoin::secp256k1::PublicKey; -use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use shared::commitment::Commitment; use shared::{Address, Invoice}; @@ -131,14 +129,19 @@ impl AccountServer { let proof_data = coin_proof.proof.public_values.clone().read::(); // Verify the inclusion of the coin in the proof. - // TODO: Return an err and also verify the proof verification itself. (Dry-run the - // aggregation) - // TODO: Verify that the commitment was not included in our state. - coin_proof + if !coin_proof .inclusion_proof - .verify(coin_proof.coin.identifier, proof_data.output_coins_root); + .verify(coin_proof.coin.identifier, proof_data.output_coins_root) + { + return Err("Coin inclusion proof verification failed"); + } - println!("Receiving coin for address: {:?}", coin_proof.coin.recipient); + // Log coin receipt without exposing full address (privacy). + let addr = &coin_proof.coin.recipient; + eprintln!( + "Receiving coin for address: {:02x}{:02x}…", + addr[0], addr[1] + ); // Get the recipient account let mut account = self .accounts @@ -158,16 +161,32 @@ impl AccountServer { // &account.state.public_key, //); - // TODO: Make sure the coin_queue doesn't include this coin already. + // Reject duplicate coins (replay protection) + let coin_id = coin_proof.coin.identifier; + if account + .coin_queue + .iter() + .any(|cp| cp.coin.identifier == coin_id) + { + return Err("Coin already in queue (duplicate)"); + } + if account + .coin_history + .generate_inclusion_proof(&coin_id) + .is_ok() + { + return Err("Coin already spent (replay)"); + } + let address = coin_proof.coin.recipient; account.coin_queue.push(coin_proof); self.accounts.insert(address, account); Ok(()) } - /// Get all required merkle proofs from the state for the public key and the previous proof - pub fn get_merkle_proofs( - &self, + /// Get all required merkle proofs from the state for the public key and the previous proof. + /// Static method: does not access self.accounts, only the state guard. + fn get_merkle_proofs( mut previous_proof: Proof, public_key: PublicKey, state: &MutexGuard<'_, State>, @@ -212,8 +231,12 @@ impl AccountServer { account_address: Address, public_key: PublicKey, next_public_key: PublicKey, + prev_commitment_pubkey: Option, ) -> Result, &'static str> { - let state = &self.state.lock().unwrap(); + let state = &self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // Check if the account balance is enough let balance = self.get_account_balance(&account_address)?; let invoiced_amount = invoices.iter().fold(0, |acc, x| acc + x.amount); @@ -221,7 +244,7 @@ impl AccountServer { return Err("Insufficient funds"); } - let mut account = match self.accounts.remove(&account_address) { + let account = match self.accounts.get_mut(&account_address) { Some(account) => account, None => return Err("Unknown account address"), }; @@ -241,7 +264,7 @@ impl AccountServer { for coin_proof in &account.coin_queue { coin_history_proofs.push({ match &coin_proof.commitment { - Some(commitment) => self.get_merkle_proofs( + Some(commitment) => Self::get_merkle_proofs( coin_proof.proof.clone(), commitment.public_key, state, @@ -310,37 +333,38 @@ impl AccountServer { .out_coins(out_coins.clone()) .out_coin_proofs(out_coin_proofs); - // TODO(Refactor): Don't use take and move this up into the loop - let received_proofs = take(&mut account.coin_queue) - .into_iter() - .map(|x| x.proof) - .collect(); + let received_proofs: Vec<_> = account.coin_queue.iter().map(|x| x.proof.clone()).collect(); - let proof = match account.proof.take() { + let proof = match &account.proof { Some(account_proof) => { - let account_commitment_public_key = public_key; - proof_hints_builder.prev_proof_history_proofs(Some(self.get_merkle_proofs( + let account_commitment_public_key = prev_commitment_pubkey + .ok_or("prev_commitment_pubkey required for account update")?; + let merkle_proofs = Self::get_merkle_proofs( account_proof.clone(), account_commitment_public_key, state, - )?)); + )?; + proof_hints_builder.prev_proof_history_proofs(Some(merkle_proofs)); proof_hints_builder.proof_type(ProofType::AccountUpdateProof); - self.prover - .update_account(proof_hints_builder, account_proof, received_proofs)? + self.prover.update_account( + proof_hints_builder, + account_proof.clone(), + received_proofs, + )? } - _ => self + None => self .prover .create_account(proof_hints_builder, received_proofs)?, }; - // Update account. + // Proof generation succeeded — now commit the state changes. + // coin_queue and proof were read non-destructively above, + // so the account is unchanged if we got an error before this point. + account.coin_queue.clear(); account.balance = balance - invoiced_amount; account.proof = Some(proof.clone()); - - // Insert account back into database. - self.accounts.insert(account_address, account); - let public_values = - bincode::deserialize::(&proof.public_values.to_vec()).unwrap(); + let public_values = bincode::deserialize::(&proof.public_values.to_vec()) + .map_err(|_| "Failed to deserialize proof public values")?; if public_values.output_coins_root != out_coins_tree.root() { return Err( "The simulated out_coins_tree root does not match the commited output_coins_root", @@ -372,7 +396,7 @@ impl AccountServer { pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { let bytes = bincode::serialize(&self.accounts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - std::fs::write(path, bytes) + crate::atomic_write(path, &bytes) } pub fn load_from_file(state: Arc>, path: &str) -> std::io::Result { @@ -450,14 +474,7 @@ mod tests { .expect("Failed to create private key for generic account."); let initial_pk_bytes = generate_test_public_key(&xpriv, 0).serialize().to_vec(); - // Deterministic address generation for tests, mimicking AccountState::new but with fixed randomness - let address = zkcoins_program::hash( - &[ - initial_pk_bytes, - TEST_ACCOUNT_RANDOM_SEED_FOR_ADDRESS.to_vec(), - ] - .concat(), - ); + let address = zkcoins_program::hash(&initial_pk_bytes); TestAccountData { xpriv, @@ -473,8 +490,14 @@ mod tests { ) -> Result, String> { let current_pk = generate_test_public_key(&self.xpriv, self.num_pubkeys); let next_pk = generate_test_public_key(&self.xpriv, self.num_pubkeys + 1); + let prev_pk = if self.num_pubkeys > 0 { + Some(generate_test_public_key(&self.xpriv, self.num_pubkeys - 1)) + } else { + None + }; - let mut coin_proofs = server.send_coins(invoices, self.address, current_pk, next_pk)?; + let mut coin_proofs = + server.send_coins(invoices, self.address, current_pk, next_pk, prev_pk)?; // The key used for the commitment corresponds to current_pk let signing_secret_key = derive_test_secret_key(&self.xpriv, self.num_pubkeys); @@ -504,10 +527,6 @@ mod tests { let mut server = AccountServer::new(Arc::clone(&state_arc)); let mut minting_account_data = TestAccountData::new_minting_account(); - println!( - "minting account address: {:?}", - minting_account_data.address - ); server.import_account( minting_account_data.address, Account { @@ -645,10 +664,6 @@ mod tests { let mut server = AccountServer::new(state_arc); let minting_account_data = TestAccountData::new_minting_account(); - println!( - "minting account address: {:?}", - minting_account_data.address - ); server.import_account( minting_account_data.address, // This is MINTING_ADDRESS @@ -696,6 +711,107 @@ mod tests { assert_eq!(coin_proofs.len(), 1); } + #[test] + fn test_receive_duplicate_coin_rejected() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut server = AccountServer::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + server.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); + let invoice = Invoice::new(100, account_1_data.address); + + let coin_proofs = minting_account_data + .execute_send_coins(&mut server, vec![invoice]) + .expect("Mint failed"); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + + let coin_proof = coin_proofs.into_iter().next().unwrap(); + let duplicate = coin_proof.clone(); + + // First receive should succeed + server + .receive_coin(coin_proof) + .expect("First receive should succeed"); + + // Second receive of the same coin should be rejected + let result = server.receive_coin(duplicate); + assert!(result.is_err(), "Duplicate coin receive must be rejected"); + } + + #[test] + fn test_receive_updates_balance() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut server = AccountServer::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + server.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); + let invoice = Invoice::new(250, account_1_data.address); + + // Balance should not exist before any receive + assert!( + server.get_account_balance(&account_1_data.address).is_err(), + "Account should not exist before receiving coins" + ); + + let coin_proofs = minting_account_data + .execute_send_coins(&mut server, vec![invoice]) + .expect("Mint failed"); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + + for cp in coin_proofs { + server.receive_coin(cp).expect("Receive should succeed"); + } + + // Balance should reflect the received coin amount + let balance = server + .get_account_balance(&account_1_data.address) + .expect("Account should exist after receive"); + assert_eq!( + balance, 250, + "Balance should equal the received coin amount" + ); + } + /// Reproduces the exact configuration of /api/mint on the live DEV server: /// balance = u64::MAX, recipient = raw [1u8; 32] bytes, amount = 1. #[test] diff --git a/server/src/main.rs b/server/src/main.rs index 0adf3f95..4c6ad412 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,25 +1,27 @@ +mod account_server; mod publisher; mod scanner; mod server; -mod account_server; mod state; +mod username; -use shared::commitment::Commitment; use crate::publisher::EsploraConfig; use crate::scanner::scan_for_inscriptions; use crate::server::start_rest_server; use crate::state::State; use bitcoin::hashes::Hash; use bitcoin::BlockHash; +use shared::commitment::Commitment; use std::error::Error as StdError; -use std::sync::{Arc, Mutex}; use std::fs::File; use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; const SMT_PATH: &str = "smt.bin"; const MMR_PATH: &str = "mmr.bin"; const LATEST_BLOCK_PATH: &str = "latest_block.bin"; const ACCOUNTS_PATH: &str = "accounts.bin"; +const USERNAMES_PATH: &str = "usernames.bin"; const ACCOUNT_SERVER_ADDR: &str = "0.0.0.0:4242"; //const START_BLOCK_HASH: &str = "000000f43ca5c99c54c4738878fe1c5cca07691dc614a2734b73aa78ca868fb8"; @@ -27,7 +29,8 @@ use esplora_client::{ r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, }; -const DEFAULT_PUBLISHER_KEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; +const DEFAULT_PUBLISHER_KEY: &str = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; lazy_static::lazy_static! { pub static ref NETWORK_CONFIG: EsploraConfig = { @@ -52,10 +55,20 @@ lazy_static::lazy_static! { }; } +/// Atomic write: write to a temp file, then rename. +/// This prevents data corruption if the process crashes mid-write. +pub fn atomic_write(path: &str, data: &[u8]) -> std::io::Result<()> { + let tmp_path = format!("{}.tmp", path); + let mut file = File::create(&tmp_path)?; + file.write_all(data)?; + file.sync_all()?; + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + // Helper function to save the latest block hash fn save_latest_block(block_hash: &BlockHash, path: &str) -> Result<(), Box> { - let mut file = File::create(path)?; - file.write_all(&block_hash.to_byte_array())?; + atomic_write(path, &block_hash.to_byte_array())?; Ok(()) } @@ -81,9 +94,9 @@ async fn main() -> Result<(), Box> { println!("Creating new State"); State::new() } - } + }, )); - + // Create a new AccountServer instance with a reference to the state. // Try to restore persisted accounts; otherwise start with an empty server // and let start_rest_server seed the minting account. @@ -99,15 +112,33 @@ async fn main() -> Result<(), Box> { } }; + // Load or create UsernameStore + let username_store = match username::UsernameStore::load_from_file(USERNAMES_PATH) { + Ok(store) => { + println!("Loaded existing usernames from {}", USERNAMES_PATH); + store + } + Err(_) => { + println!("No usernames file found, creating new UsernameStore"); + username::UsernameStore::new() + } + }; + // Spawn the account_server as a separate task tokio::spawn(async move { - if let Err(e) = - start_rest_server(account_server, ACCOUNT_SERVER_ADDR, ACCOUNTS_PATH.to_string()).await + if let Err(e) = start_rest_server( + account_server, + username_store, + ACCOUNT_SERVER_ADDR, + ACCOUNTS_PATH.to_string(), + USERNAMES_PATH.to_string(), + ) + .await { eprintln!("Account server error: {}", e); } }); - + // Try to load the latest block hash or use the default starting point let start_block_hash = match load_latest_block(LATEST_BLOCK_PATH) { Ok(hash) => { @@ -116,8 +147,10 @@ async fn main() -> Result<(), Box> { } Err(_) => { println!("No saved block hash found, fetching latest from Esplora..."); - let client = EsploraAsyncClient::::from_builder(EsploraBuilder::new(&NETWORK_CONFIG.url))?; - + let client = EsploraAsyncClient::::from_builder(EsploraBuilder::new( + &NETWORK_CONFIG.url, + ))?; + let tip_hash = client.get_tip_hash().await?; println!("Fetched latest tip hash from Esplora: {}", tip_hash); tip_hash @@ -126,37 +159,37 @@ async fn main() -> Result<(), Box> { // Clone the State's Arc for the closure let state_clone = Arc::clone(&state); - + scan_for_inscriptions(&NETWORK_CONFIG, start_block_hash, &move |content_bytes: Vec, current_block_hash| { println!("Received content size: {} bytes", content_bytes.len()); - + // Try to deserialize the content as a Commitment match bincode::deserialize::(&content_bytes) { Ok(commitment) => { println!("Successfully deserialized as commitment"); println!("Public key: {}", commitment.public_key); - + // Verify the commitment if commitment.verify() { println!("Commitment signature verified successfully"); - + // Lock the mutex to modify the state let mut state = state_clone.lock().unwrap(); // Update the state with this commitment let new_root = state.update(&[commitment]).unwrap(); - + println!("Added to State. New MMR root: {}", hex::encode(new_root)); - + // Save the state after each update if let Err(e) = state.save_to_files(SMT_PATH, MMR_PATH) { eprintln!("Failed to save state after update: {}", e); } - + // Save the latest block hash after each update if let Err(e) = save_latest_block(¤t_block_hash, LATEST_BLOCK_PATH) { eprintln!("Failed to save latest block hash: {}", e); } - + } else { println!("Commitment verification failed, not adding to state"); } diff --git a/server/src/publisher.rs b/server/src/publisher.rs index a0f05e27..65b8470f 100644 --- a/server/src/publisher.rs +++ b/server/src/publisher.rs @@ -29,7 +29,11 @@ pub struct EsploraConfig { impl EsploraConfig { pub fn network(&self) -> Network { - if self.is_mainnet { Network::Bitcoin } else { Network::Signet } + if self.is_mainnet { + Network::Bitcoin + } else { + Network::Signet + } } } @@ -310,8 +314,13 @@ pub async fn create_and_broadcast_inscription( get_publisher_utxo(&publisher_address, config, Some(MIN_INSCRIPTION_AMOUNT)).await?; if outpoints_with_sats.is_empty() { - println!("No UTXOs found for the publisher address. Please fund the address first."); - return Ok(None); + eprintln!( + "ERROR: No UTXOs found for publisher address {}. Fund it to continue.", + publisher_address + ); + return Err( + "No UTXOs available for inscription broadcast — publisher wallet is empty".into(), + ); } // Log found UTXOs diff --git a/server/src/scanner.rs b/server/src/scanner.rs index 5160dc71..1ae7db8d 100644 --- a/server/src/scanner.rs +++ b/server/src/scanner.rs @@ -1,14 +1,14 @@ use crate::publisher::{EsploraConfig, INSCRIPTION_MARKER_PREFIX}; +use bitcoin::blockdata::opcodes; use bitcoin::hashes::Hash; +use bitcoin::script::Instruction; +use bitcoin::script::ScriptBuf; use bitcoin::{BlockHash, Transaction, Txid}; use esplora_client::r#async::DefaultSleeper; use esplora_client::{AsyncClient, Builder, Error as EsploraError, Sleeper}; use std::collections::HashSet; -use std::time::Duration; -use bitcoin::blockdata::opcodes; -use bitcoin::script::Instruction; -use bitcoin::script::ScriptBuf; use std::error::Error as StdError; +use std::time::Duration; /// Type alias for the inscription callback function type InscriptionCallback = dyn Fn(Vec, BlockHash) + Send + Sync + 'static; @@ -40,7 +40,7 @@ impl InscriptionScanner { loop { // Update the current block hash self.current_block_hash = Some(current_hash); - + // Skip if we've already processed this block if self.processed_blocks.contains(¤t_hash) { // We've reached a block we've already processed @@ -174,7 +174,7 @@ pub async fn scan_for_inscriptions( let builder = Builder::new(&config.url); let client = AsyncClient::::from_builder(builder)?; let mut scanner = InscriptionScanner::new(client); - + scanner.scan_from_block(start_block_hash, callback).await?; Ok(()) @@ -210,7 +210,8 @@ pub fn extract_inscription_content(script_bytes: &[u8]) -> Option> { } } else { match instruction { - Instruction::Op(op) if op == opcodes::OP_FALSE => { + // OP_FALSE (0x00) is parsed as PushBytes of empty data by the bitcoin crate + Instruction::PushBytes(bytes) if bytes.is_empty() => { prev_was_op_false = true; } Instruction::Op(op) if op == opcodes::all::OP_IF && prev_was_op_false => { @@ -223,5 +224,169 @@ pub fn extract_inscription_content(script_bytes: &[u8]) -> Option> { } } - if content.is_empty() { None } else { Some(content) } + if content.is_empty() { + None + } else { + Some(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::blockdata::{opcodes, script}; + use bitcoin::script::PushBytesBuf; + use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; + use bitcoin::XOnlyPublicKey; + use shared::commitment::Commitment; + use std::str::FromStr; + + /// Build a reveal script in the same format as the publisher: + /// OP_CHECKSIG OP_FALSE OP_IF OP_ENDIF + fn build_inscription_script(pubkey: XOnlyPublicKey, data: &[u8]) -> ScriptBuf { + let mut builder = script::Builder::new() + .push_slice(pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIG) + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF); + + for chunk in data.chunks(520) { + let buffer = PushBytesBuf::try_from(chunk.to_vec()).unwrap(); + builder = builder.push_slice(buffer); + } + + builder.push_opcode(opcodes::all::OP_ENDIF).into_script() + } + + /// Helper: create a deterministic x-only public key for tests. + fn test_xonly_pubkey() -> XOnlyPublicKey { + let secp = Secp256k1::new(); + let sk = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let kp = Keypair::from_secret_key(&secp, &sk); + XOnlyPublicKey::from_keypair(&kp).0 + } + + // --- extract_inscription_content --- + + #[test] + fn parse_valid_inscription_into_commitment() { + let sk = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let message = b"test commitment data".to_vec(); + let commitment = Commitment::new(&sk, message.clone()).expect("should create commitment"); + let commitment_bytes = + bincode::serialize(&commitment).expect("should serialize commitment"); + + let pubkey = test_xonly_pubkey(); + let script = build_inscription_script(pubkey, &commitment_bytes); + + let extracted = extract_inscription_content(script.as_bytes()); + assert!( + extracted.is_some(), + "should extract content from valid script" + ); + + let extracted_bytes = extracted.unwrap(); + assert_eq!( + extracted_bytes, commitment_bytes, + "extracted bytes must match the serialized commitment" + ); + + // Deserialize back into a Commitment and verify fields + let deserialized: Commitment = + bincode::deserialize(&extracted_bytes).expect("should deserialize commitment"); + assert_eq!(deserialized.message, message); + assert_eq!(deserialized.public_key, commitment.public_key); + } + + #[test] + fn reject_invalid_inscription_data() { + // Empty script has no envelope + assert_eq!(extract_inscription_content(&[]), None); + + // Random bytes without OP_FALSE OP_IF envelope + assert_eq!(extract_inscription_content(&[0xab, 0xcd, 0xef]), None); + + // Script with OP_IF but missing OP_FALSE before it (just OP_1 OP_IF OP_ENDIF) + let script = script::Builder::new() + .push_opcode(opcodes::all::OP_PUSHNUM_1) + .push_opcode(opcodes::all::OP_IF) + .push_opcode(opcodes::all::OP_ENDIF) + .into_script(); + assert_eq!( + extract_inscription_content(script.as_bytes()), + None, + "OP_IF without OP_FALSE should not open an envelope" + ); + + // Script with OP_FALSE OP_IF but no push data (only OP_ENDIF) + let script = script::Builder::new() + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF) + .push_opcode(opcodes::all::OP_ENDIF) + .into_script(); + assert_eq!( + extract_inscription_content(script.as_bytes()), + None, + "envelope with no push data should return None" + ); + } + + #[test] + fn verify_commitment_signature_after_deserialization() { + let sk = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000002") + .unwrap(); + let message = vec![42u8; 32]; // 32-byte message (treated as raw digest) + let commitment = Commitment::new(&sk, message.clone()).expect("should create commitment"); + let commitment_bytes = bincode::serialize(&commitment).unwrap(); + + let pubkey = test_xonly_pubkey(); + let script = build_inscription_script(pubkey, &commitment_bytes); + let extracted = extract_inscription_content(script.as_bytes()).unwrap(); + + let deserialized: Commitment = bincode::deserialize(&extracted).unwrap(); + assert!( + deserialized.verify(), + "commitment signature must be valid after round-trip through inscription script" + ); + + // Tamper with the message and verify that verification fails + let mut tampered = deserialized.clone(); + tampered.message = vec![0u8; 32]; + assert!( + !tampered.verify(), + "tampered commitment must fail signature verification" + ); + } + + #[test] + fn parse_multi_chunk_inscription() { + let sk = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000003") + .unwrap(); + // Create a large message that will be split into multiple chunks (>520 bytes) + let large_message = vec![0xAB; 1200]; + let commitment = Commitment::new(&sk, large_message).expect("should create commitment"); + let commitment_bytes = bincode::serialize(&commitment).unwrap(); + assert!( + commitment_bytes.len() > 520, + "test data should span multiple chunks" + ); + + let pubkey = test_xonly_pubkey(); + let script = build_inscription_script(pubkey, &commitment_bytes); + let extracted = extract_inscription_content(script.as_bytes()).unwrap(); + + assert_eq!( + extracted, commitment_bytes, + "multi-chunk inscription must reassemble correctly" + ); + + let deserialized: Commitment = bincode::deserialize(&extracted).unwrap(); + assert!(deserialized.verify(), "multi-chunk commitment must verify"); + } } diff --git a/server/src/server.rs b/server/src/server.rs index 40b168f7..88613ac2 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -1,13 +1,16 @@ use axum::{ body::Bytes, extract::{Json, Path, State}, - http::{header, HeaderValue, Method, StatusCode}, + http::{header, Method, StatusCode}, response::IntoResponse, routing::{get, post}, Router, }; -use bitcoin::{bip32::Xpriv, Network}; +use bitcoin::bip32::Xpriv; +use bitcoin::secp256k1::{self as secp, schnorr::Signature as SchnorrSignature, Message}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use shared::commitment::Commitment; use shared::{ClientAccount, Invoice, ProofData}; use std::collections::HashMap; use std::net::SocketAddr; @@ -15,13 +18,48 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use tokio::net::TcpListener; use tower_http::cors::CorsLayer; -use zkcoins_program::hash; use zkcoins_prover::Proof; use crate::account_server::{AccountServer, CoinProof}; use crate::publisher::create_and_broadcast_inscription; +use crate::username::UsernameStore; use crate::NETWORK_CONFIG; +/// Verify a Schnorr signature over send request fields. +/// Message = SHA256(account_address || recipient || amount || timestamp) +fn verify_send_signature(request: &SendCoinRequest) -> Result<(), &'static str> { + let signature_hex = request.signature.as_deref().ok_or("Missing signature")?; + let timestamp = request.timestamp.ok_or("Missing timestamp")?; + + // Reject requests older than 5 minutes + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now.abs_diff(timestamp) > 300 { + return Err("Request timestamp too old or in the future"); + } + + // Build the message: SHA256(account_address || recipient || amount || timestamp) + let mut hasher = Sha256::new(); + hasher.update(request.account_address.as_bytes()); + hasher.update(request.recipient.as_bytes()); + hasher.update(request.amount.to_le_bytes()); + hasher.update(timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let sig_bytes = hex::decode(signature_hex).map_err(|_| "Invalid signature hex")?; + let sig = + SchnorrSignature::from_slice(&sig_bytes).map_err(|_| "Invalid Schnorr signature format")?; + + let (xonly, _parity) = request.public_key.x_only_public_key(); + let secp = secp::Secp256k1::verification_only(); + + secp.verify_schnorr(&sig, &msg, &xonly) + .map_err(|_| "Signature verification failed") +} + /// Lock a mutex, recovering from poison if a previous holder panicked. /// This prevents cascade failures where one panic takes down all handlers. fn lock_or_recover(mutex: &Mutex) -> MutexGuard<'_, T> { @@ -37,21 +75,24 @@ struct AppState { account_server: Arc>, proof_store: Arc, minting_account: Arc>, + username_store: Arc>, accounts_path: String, + usernames_path: String, } // Response types for our API -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct BalanceResponse { balance: u64, + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct AddressesResponse { addresses: Vec, } -// TODO: Send multiple coins at once. #[derive(Deserialize)] pub struct SendCoinRequest { account_address: String, @@ -59,6 +100,9 @@ pub struct SendCoinRequest { amount: u64, public_key: bitcoin::secp256k1::PublicKey, next_public_key: bitcoin::secp256k1::PublicKey, + prev_commitment_pubkey: Option, + signature: Option, + timestamp: Option, } #[derive(Deserialize)] @@ -69,47 +113,144 @@ pub struct MintRequest { #[derive(Deserialize)] pub struct ReceiveCoinRequest { + #[allow(dead_code)] coin_proof: Proof, } -// Add a struct to store proofs temporarily +/// Persistent proof store — survives server restarts. +/// Each proof is stored as an individual file: /data/proofs/{id}.bin struct ProofStore { - proofs: Mutex>, + dir: String, next_id: AtomicU64, } impl ProofStore { - fn new() -> Self { + fn new(dir: &str) -> Self { + std::fs::create_dir_all(dir).ok(); + // Scan existing files to find the highest ID + let max_id = std::fs::read_dir(dir) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + e.file_name() + .to_str()? + .strip_suffix(".bin")? + .parse::() + .ok() + }) + .max() + .unwrap_or(0) + }) + .unwrap_or(0); + ProofStore { - proofs: Mutex::new(HashMap::new()), - next_id: AtomicU64::new(1), + dir: dir.to_string(), + next_id: AtomicU64::new(max_id + 1), + } + } + + /// Build a safe file path for a proof ID within the store directory. + /// The ID is always a server-generated u64, so path traversal is impossible, + /// but we canonicalize and validate anyway to satisfy static analysis. + fn proof_path(&self, id: u64) -> Option { + let base = std::path::Path::new(&self.dir).canonicalize().ok()?; + let candidate = base.join(format!("{}.bin", id)); + // Ensure the resolved path is inside the base directory. + if candidate.starts_with(&base) { + Some(candidate) + } else { + None } } fn add_proof(&self, proof_with_commitment: CoinProof) -> u64 { let id = self.next_id.fetch_add(1, Ordering::SeqCst); - let mut proofs = lock_or_recover(&self.proofs); - proofs.insert(id, proof_with_commitment); + let Some(path) = self.proof_path(id) else { + eprintln!("Failed to resolve proof path for {}", id); + return id; + }; + match bincode::serialize(&proof_with_commitment) { + Ok(bytes) => { + if let Err(e) = crate::atomic_write(path.to_str().unwrap_or(""), &bytes) { + eprintln!("Failed to persist proof {}: {}", id, e); + } + } + Err(e) => eprintln!("Failed to serialize proof {}: {}", id, e), + } id } fn get_proof(&self, id: u64) -> Option { - let proofs = lock_or_recover(&self.proofs); - proofs.get(&id).cloned() + let path = self.proof_path(id)?; + let bytes = std::fs::read(&path).ok()?; + bincode::deserialize(&bytes).ok() } } -#[derive(Serialize)] +#[derive(Serialize, Default)] pub struct SendCoinResponse { success: bool, - proof_id: Option, // Store a reference ID instead of the proof itself + #[serde(skip_serializing_if = "Option::is_none")] + proof_id: Option, + /// Hex-encoded hash fields the client needs to create a commitment (only set for user sends). + #[serde(skip_serializing_if = "Option::is_none")] + account_state_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_coins_root: Option, +} + +#[derive(Deserialize)] +pub struct CommitRequest { + proof_id: u64, + /// Hex-encoded compressed public key (33 bytes) that signed the commitment. + public_key: bitcoin::secp256k1::PublicKey, + /// Hex-encoded Schnorr signature (64 bytes). + signature: String, + /// Hex-encoded message that was signed (the concatenation of account_state_hash + output_coins_root). + message: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct InfoResponse { network: String, } +// --- Username & LNURL types --- + +#[derive(Deserialize)] +pub struct ClaimUsernameRequest { + username: String, + address: String, + public_key: bitcoin::secp256k1::PublicKey, + signature: String, + timestamp: u64, +} + +#[derive(Serialize, Deserialize)] +pub struct UsernameResponse { + username: String, + address: String, +} + +#[derive(Serialize, Deserialize)] +pub struct LnurlpResponse { + tag: String, + callback: String, + #[serde(rename = "minSendable")] + min_sendable: u64, + #[serde(rename = "maxSendable")] + max_sendable: u64, + metadata: String, +} + +#[derive(Serialize, Deserialize)] +pub struct LnurlErrorResponse { + status: String, + reason: String, +} + // Handler functions for our REST API async fn get_balance_handler( State(state): State, @@ -125,7 +266,10 @@ async fn get_balance_handler( Err(_) => { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(BalanceResponse { balance: 0 }), + Json(BalanceResponse { + balance: 0, + username: None, + }), ) } }; @@ -137,17 +281,36 @@ async fn get_balance_handler( } else { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(BalanceResponse { balance: 0 }), + Json(BalanceResponse { + balance: 0, + username: None, + }), ); } // Get balance for the specific account + let username = { + let username_store = lock_or_recover(&state.username_store); + username_store.get_username(&address).map(String::from) + }; match account_server.get_account_balance(&address) { - Ok(balance) => (StatusCode::OK, Json(BalanceResponse { balance })), - Err(_) => (StatusCode::NOT_FOUND, Json(BalanceResponse { balance: 0 })), + Ok(balance) => (StatusCode::OK, Json(BalanceResponse { balance, username })), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(BalanceResponse { + balance: 0, + username: None, + }), + ), } } else { - (StatusCode::NOT_FOUND, Json(BalanceResponse { balance: 0 })) + ( + StatusCode::NOT_FOUND, + Json(BalanceResponse { + balance: 0, + username: None, + }), + ) } } @@ -177,20 +340,14 @@ async fn receive_coin_handler( match account_server.receive_coin(coin_proof) { Ok(_) => Json(SendCoinResponse { success: true, - proof_id: None, - }), - Err(_) => Json(SendCoinResponse { - success: false, - proof_id: None, + ..Default::default() }), + Err(_) => Json(SendCoinResponse::default()), } } Err(e) => { eprintln!("Failed to deserialize proof with commitment: {}", e); - Json(SendCoinResponse { - success: false, - proof_id: None, - }) + Json(SendCoinResponse::default()) } } } @@ -200,16 +357,22 @@ async fn send_coin_handler( Json(request): Json, ) -> impl IntoResponse { println!("Received send post request..."); + + // Verify sender signature if provided (graceful: skip if not present for backwards compat) + if request.signature.is_some() { + if let Err(e) = verify_send_signature(&request) { + eprintln!("Signature verification failed: {}", e); + return (StatusCode::UNAUTHORIZED, Json(SendCoinResponse::default())); + } + } + // Create converted addresses (from_address and to_address) let from_address_vec = match hex::decode(request.account_address.trim_start_matches("0x")) { Ok(addr) => addr, Err(_) => { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - }), + Json(SendCoinResponse::default()), ) } }; @@ -218,10 +381,7 @@ async fn send_coin_handler( Err(_) => { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - }), + Json(SendCoinResponse::default()), ) } }; @@ -235,10 +395,7 @@ async fn send_coin_handler( } else { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - }), + Json(SendCoinResponse::default()), ); } @@ -246,47 +403,81 @@ async fn send_coin_handler( // Acquire the account_server lock only for the duration of sending coins. let send_result = { let mut account_server_lock = lock_or_recover(&state.account_server); - let result = account_server_lock.send_coins( + account_server_lock.send_coins( vec![Invoice::new(request.amount, to_address)], from_address, request.public_key, request.next_public_key, - ); - if result.is_ok() { - if let Err(e) = account_server_lock.save_to_file(&state.accounts_path) { - eprintln!("Failed to persist accounts after send: {}", e); - } - } - result + request.prev_commitment_pubkey, + ) + // NOTE: accounts are NOT saved here — proof must be persisted first }; - println!("Generated send_result: {:?}", send_result); + eprintln!( + "Send result: {}", + if send_result.is_ok() { "ok" } else { "err" } + ); - // Now that the account_server lock is dropped, we can await safely. match send_result { Ok(mut coin_proofs) => { - let commitment_data = bincode::serialize(&coin_proofs[0].commitment) - .expect("Failed to serialize commitment"); + // Extract proof data so the client can create a commitment + let (ash_hex, ocr_hex) = { + let proof_data = + bincode::deserialize::(&coin_proofs[0].proof.public_values.to_vec()); + match proof_data { + Ok(pd) => ( + Some(hex::encode(pd.account_state_hash)), + Some(hex::encode(pd.output_coins_root)), + ), + Err(e) => { + eprintln!("Failed to deserialize proof data: {}", e); + (None, None) + } + } + }; - println!( - "Sending commitment data with size: {} bytes", - commitment_data.len() - ); - println!("Commitment data hex: {}", hex::encode(&commitment_data)); + // If commitment is already set (e.g. mint flow), broadcast immediately + if let Some(commitment) = coin_proofs[0].commitment.as_ref() { + let commitment_data = + bincode::serialize(commitment).expect("Failed to serialize commitment"); + println!("Broadcasting commitment ({} bytes)", commitment_data.len()); + if let Err(err) = + create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await + { + eprintln!("Error broadcasting inscription: {}", err); + } + } - if let Err(err) = - create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await + // Persist proof FIRST (crash-safe: proof exists even if account save fails) + let proof_id = match coin_proofs.pop() { + Some(proof) => state.proof_store.add_proof(proof), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SendCoinResponse { + success: false, + proof_id: None, + account_state_hash: None, + output_coins_root: None, + }), + ); + } + }; + // Now persist accounts (proof is already safe on disk) { - eprintln!("Error broadcasting inscription: {}", err); + let account_server_lock = lock_or_recover(&state.account_server); + if let Err(e) = account_server_lock.save_to_file(&state.accounts_path) { + eprintln!("Failed to persist accounts after send: {}", e); + } } - // TODO: Handle all the coins_proofs - let proof_id = state.proof_store.add_proof(coin_proofs.pop().unwrap()); ( StatusCode::OK, Json(SendCoinResponse { success: true, proof_id: Some(proof_id), + account_state_hash: ash_hex, + output_coins_root: ocr_hex, }), ) } @@ -295,6 +486,8 @@ async fn send_coin_handler( Json(SendCoinResponse { success: false, proof_id: None, + account_state_hash: None, + output_coins_root: None, }), ), } @@ -310,10 +503,7 @@ async fn mint_handler( Err(_) => { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - }), + Json(SendCoinResponse::default()), ) } }; @@ -324,23 +514,25 @@ async fn mint_handler( } else { return ( StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - }), + Json(SendCoinResponse::default()), ); } // Generate keys and get necessary info while holding the minting_account lock briefly - let (minting_pubkey, next_minting_pubkey, num_pubkeys_before_mint) = { + let (minting_pubkey, next_minting_pubkey, prev_commitment_pubkey, num_pubkeys_before_mint) = { let minting_account_guard = lock_or_recover(&state.minting_account); let current_num_pubkeys = minting_account_guard.num_pubkeys; + let prev_pk = if current_num_pubkeys > 0 { + Some(minting_account_guard.generate_public_key(current_num_pubkeys - 1)) + } else { + None + }; ( minting_account_guard.generate_public_key(current_num_pubkeys), minting_account_guard.generate_public_key(current_num_pubkeys + 1), + prev_pk, current_num_pubkeys, ) - // minting_account_guard is dropped here, releasing the lock before any .await }; // Acquire the account_server lock only for the duration of sending coins. @@ -352,20 +544,23 @@ async fn mint_handler( eprintln!("Minting account not found: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(SendCoinResponse { success: false, proof_id: None }), + Json(SendCoinResponse::default()), ); } }; account_server_guard.send_coins( vec![Invoice::new(request.amount, account_address)], minting_address, - minting_pubkey, // Use the generated keys - next_minting_pubkey, // Use the generated keys + minting_pubkey, + next_minting_pubkey, + prev_commitment_pubkey, ) - // account_server_guard is dropped here }; - println!("Minting result: {:?}", send_result); + eprintln!( + "Mint result: {}", + if send_result.is_ok() { "ok" } else { "err" } + ); // Now that the locks are dropped, we can await safely. match send_result { Ok(mut coin_proofs) => { @@ -388,7 +583,7 @@ async fn mint_handler( eprintln!("Failed to deserialize proof data: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(SendCoinResponse { success: false, proof_id: None }), + Json(SendCoinResponse::default()), ); } }; @@ -399,8 +594,12 @@ async fn mint_handler( // minting_account_guard is dropped here } - let commitment_data = bincode::serialize(&coin_proofs[0].commitment) - .expect("Failed to serialize commitment"); + let commitment = coin_proofs[0] + .commitment + .as_ref() + .expect("Commitment must be set after mint"); + let commitment_data = + bincode::serialize(commitment).expect("Failed to serialize commitment"); println!( "Sending commitment data with size: {} bytes", @@ -412,34 +611,44 @@ async fn mint_handler( if let Err(err) = create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await { - eprintln!("Error broadcasting inscription: {}", err); + eprintln!("Error broadcasting mint inscription: {}", err); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(SendCoinResponse::default()), + ); } { let mut account_server_guard = lock_or_recover(&state.account_server); for coin_proof in &coin_proofs { - let _ = account_server_guard.receive_coin(coin_proof.clone()); + if let Err(e) = account_server_guard.receive_coin(coin_proof.clone()) { + eprintln!("Failed to receive minted coin: {}", e); + } } if let Err(e) = account_server_guard.save_to_file(&state.accounts_path) { eprintln!("Failed to persist accounts after mint: {}", e); } } - let proof_id = state.proof_store.add_proof(coin_proofs.pop().unwrap()); + let proof_id = match coin_proofs.pop() { + Some(proof) => state.proof_store.add_proof(proof), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SendCoinResponse::default()), + ); + } + }; ( StatusCode::OK, Json(SendCoinResponse { success: true, proof_id: Some(proof_id), + account_state_hash: None, + output_coins_root: None, }), ) } - Err(_) => ( - StatusCode::OK, - Json(SendCoinResponse { - success: false, - proof_id: None, - }), - ), + Err(_) => (StatusCode::OK, Json(SendCoinResponse::default())), } } @@ -474,17 +683,409 @@ async fn get_proof_handler( } } +/// Accepts a client-signed commitment for a previously generated proof. +/// Broadcasts the commitment as a Taproot inscription and delivers the coin to the recipient. +async fn commit_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + // Retrieve the stored coin proof + let coin_proof = match state.proof_store.get_proof(request.proof_id) { + Some(p) => p, + None => { + return ( + StatusCode::NOT_FOUND, + Json(SendCoinResponse { + success: false, + proof_id: None, + account_state_hash: None, + output_coins_root: None, + }), + ); + } + }; + + // Reconstruct the Commitment from the client-provided fields + let message_bytes = match hex::decode(&request.message) { + Ok(b) => b, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(SendCoinResponse { + success: false, + proof_id: None, + account_state_hash: None, + output_coins_root: None, + }), + ); + } + }; + let sig_bytes = match hex::decode(&request.signature) { + Ok(b) => b, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(SendCoinResponse { + success: false, + proof_id: None, + account_state_hash: None, + output_coins_root: None, + }), + ); + } + }; + let signature = match bitcoin::secp256k1::schnorr::Signature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(SendCoinResponse { + success: false, + proof_id: None, + account_state_hash: None, + output_coins_root: None, + }), + ); + } + }; + + let commitment = Commitment { + public_key: request.public_key, + signature, + message: message_bytes, + }; + + // Verify the commitment + if !commitment.verify() { + return ( + StatusCode::UNAUTHORIZED, + Json(SendCoinResponse { + success: false, + proof_id: None, + account_state_hash: None, + output_coins_root: None, + }), + ); + } + + // Broadcast the inscription + let commitment_data = bincode::serialize(&commitment).expect("Failed to serialize commitment"); + println!( + "Broadcasting user commitment ({} bytes)", + commitment_data.len() + ); + if let Err(err) = create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await { + eprintln!("Error broadcasting commit inscription: {}", err); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(SendCoinResponse::default()), + ); + } + + // Deliver the coin to the recipient + let mut updated_proof = coin_proof; + updated_proof.commitment = Some(commitment); + { + let mut account_server_guard = lock_or_recover(&state.account_server); + if let Err(e) = account_server_guard.receive_coin(updated_proof) { + eprintln!("Failed to receive coin after commit: {}", e); + } + if let Err(e) = account_server_guard.save_to_file(&state.accounts_path) { + eprintln!("Failed to persist accounts after commit: {}", e); + } + } + + ( + StatusCode::OK, + Json(SendCoinResponse { + success: true, + proof_id: Some(request.proof_id), + account_state_hash: None, + output_coins_root: None, + }), + ) +} + async fn info_handler() -> impl IntoResponse { Json(InfoResponse { network: NETWORK_CONFIG.network_name.clone(), }) } +// --- Username & LNURL handlers --- + +async fn claim_username_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + // Decode address + let address_vec = match hex::decode(request.address.trim_start_matches("0x")) { + Ok(a) => a, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Invalid address hex".into(), + }), + ) + .into_response() + } + }; + let mut address = [0u8; 32]; + if address_vec.len() != 32 { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Address must be 32 bytes".into(), + }), + ) + .into_response(); + } + address.copy_from_slice(&address_vec); + + // Verify public key matches address: sha256(compressed_pubkey) == address + let pk_hash: [u8; 32] = Sha256::digest(request.public_key.serialize()).into(); + if pk_hash != address { + return ( + StatusCode::UNAUTHORIZED, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Public key does not match address".into(), + }), + ) + .into_response(); + } + + // Verify timestamp freshness (5 min window) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now.abs_diff(request.timestamp) > 300 { + return ( + StatusCode::UNAUTHORIZED, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Timestamp too old or in the future".into(), + }), + ) + .into_response(); + } + + // Verify Schnorr signature over sha256("zkcoins:claim_username" || address_hex || username || timestamp_le) + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(request.address.as_bytes()); + hasher.update(request.username.as_bytes()); + hasher.update(request.timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let sig_bytes = match hex::decode(&request.signature) { + Ok(b) => b, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Invalid signature hex".into(), + }), + ) + .into_response() + } + }; + let sig = match SchnorrSignature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Invalid signature format".into(), + }), + ) + .into_response() + } + }; + let (xonly, _) = request.public_key.x_only_public_key(); + let secp = secp::Secp256k1::verification_only(); + if secp.verify_schnorr(&sig, &msg, &xonly).is_err() { + return ( + StatusCode::UNAUTHORIZED, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Signature verification failed".into(), + }), + ) + .into_response(); + } + + // Claim the username + let mut username_store = lock_or_recover(&state.username_store); + if let Err(e) = username_store.claim(&request.username, address) { + return ( + StatusCode::CONFLICT, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: e.into(), + }), + ) + .into_response(); + } + if let Err(e) = username_store.save_to_file(&state.usernames_path) { + eprintln!("Failed to persist usernames: {}", e); + } + + let normalized = request.username.to_lowercase(); + ( + StatusCode::OK, + Json(UsernameResponse { + username: normalized, + address: format!("0x{}", hex::encode(address)), + }), + ) + .into_response() +} + +/// Resolve an identifier to an address. Checks the username store first, +/// then falls back to hex-prefix matching against known account addresses. +fn resolve_identifier(state: &AppState, identifier: &str) -> Option<([u8; 32], String)> { + let normalized = identifier.to_lowercase(); + + // 1. Check custom username + let username_store = lock_or_recover(&state.username_store); + if let Some(address) = username_store.resolve(&normalized) { + return Some((address, normalized)); + } + drop(username_store); + + // 2. Check hex prefix against known addresses + let account_server = lock_or_recover(&state.account_server); + account_server + .get_addresses() + .into_iter() + .find(|addr| hex::encode(addr).starts_with(&normalized)) + .map(|addr| (addr, normalized)) +} + +async fn resolve_username_handler( + State(state): State, + Path(username): Path, +) -> impl IntoResponse { + match resolve_identifier(&state, &username) { + Some((address, resolved_name)) => ( + StatusCode::OK, + Json(UsernameResponse { + username: resolved_name, + address: format!("0x{}", hex::encode(address)), + }), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Username not found".into(), + }), + ) + .into_response(), + } +} + +async fn lnurlp_handler( + State(state): State, + Path(username): Path, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + if resolve_identifier(&state, &username).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "User not found".into(), + }), + ) + .into_response(); + } + + let host = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("api.zkcoins.app"); + let scheme = if host.contains("localhost") { + "http" + } else { + "https" + }; + let normalized = username.to_lowercase(); + let callback = format!("{}://{}/lnurl/pay/{}", scheme, host, normalized); + let metadata = format!( + "[[\"text/plain\",\"Pay {} on zkCoins\"],[\"text/identifier\",\"{}@zkcoins.app\"]]", + normalized, normalized + ); + + ( + StatusCode::OK, + Json(LnurlpResponse { + tag: "payRequest".into(), + callback, + min_sendable: 1_000, + max_sendable: 1_000_000_000_000, + metadata, + }), + ) + .into_response() +} + +async fn lnurl_callback_handler( + State(_state): State, + Path(_username): Path, +) -> impl IntoResponse { + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Lightning payments coming soon (Phase 2)".into(), + }) +} + +/// Build the full application router with all API routes, CORS, health check, and fallback. +/// Extracted so it can be reused in integration tests via `oneshot()`. +fn create_router(state: AppState) -> Router { + let cors = CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods([Method::GET, Method::POST]) + .allow_headers([header::CONTENT_TYPE]); + + Router::new() + .route("/health", get(|| async { "ok" })) + .route("/api/info", get(info_handler)) + .route("/api/balance", get(get_balance_handler)) + .route("/api/send", post(send_coin_handler)) + .route("/api/address", get(get_address_handler)) + .route("/api/receive", post(receive_coin_handler)) + .route("/api/proof/:id", get(get_proof_handler)) + .route("/api/mint", post(mint_handler)) + .route("/api/commit", post(commit_handler)) + .route("/api/username/claim", post(claim_username_handler)) + .route( + "/api/username/resolve/:username", + get(resolve_username_handler), + ) + .route("/.well-known/lnurlp/:username", get(lnurlp_handler)) + .route("/lnurl/pay/:username", get(lnurl_callback_handler)) + .with_state(state) + .fallback(|| async { StatusCode::NOT_FOUND }) + .layer(cors) +} + // Function to start the REST API server pub async fn start_rest_server( account_server: AccountServer, + username_store: UsernameStore, addr: &str, accounts_path: String, + usernames_path: String, ) -> anyhow::Result<()> { // Parse the address string into a SocketAddr let socket_addr = addr @@ -494,37 +1095,47 @@ pub async fn start_rest_server( // Wrap the account_server in an Arc for thread-safe sharing let shared_account_server = Arc::new(Mutex::new(account_server)); - // Create a proof store - let proof_store = Arc::new(ProofStore::new()); + // Create a persistent proof store + let proofs_dir = format!( + "{}/proofs", + std::path::Path::new(&accounts_path) + .parent() + .unwrap_or(std::path::Path::new(".")) + .display() + ); + let proof_store = Arc::new(ProofStore::new(&proofs_dir)); let minting_account = { let secret = include_bytes!("../minting_secret.bin"); - let private_key = - Xpriv::new_master(NETWORK_CONFIG.network(), secret).expect("Failed to create private key."); + let private_key = Xpriv::new_master(NETWORK_CONFIG.network(), secret) + .expect("Failed to create private key."); println!( "Set MINTING_ADDRESS to {:?}", &zkcoins_program::MINTING_ADDRESS ); - Arc::new(Mutex::new(ClientAccount { - address: hash(private_key.to_string().as_bytes()), - num_pubkeys: 0, - private_key, - })) + let minting_client = ClientAccount::new(private_key); + assert_eq!( + minting_client.address, + zkcoins_program::MINTING_ADDRESS, + "Minting account address mismatch — minting_secret.bin or MINTING_ADDRESS constant is wrong" + ); + Arc::new(Mutex::new(minting_client)) }; + let shared_username_store = Arc::new(Mutex::new(username_store)); + // Create the combined state using the AppState struct let state = AppState { account_server: shared_account_server, proof_store, minting_account, + username_store: shared_username_store, accounts_path, + usernames_path, }; { let mut account_server_guard = state.account_server.lock().unwrap(); - if account_server_guard - .get_minting_account_address() - .is_err() - { + if account_server_guard.get_minting_account_address().is_err() { let mut minting_server_account = crate::account_server::Account::new(); minting_server_account.balance = u64::MAX; account_server_guard @@ -535,39 +1146,7 @@ pub async fn start_rest_server( } } - // Create a router for API endpoints - let api_routes = Router::new() - .route("/info", get(info_handler)) - .route("/balance", get(get_balance_handler)) - .route("/send", post(send_coin_handler)) - .route("/address", get(get_address_handler)) - .route("/receive", post(receive_coin_handler)) - .route("/proof/{id}", get(get_proof_handler)) - .route("/mint", post(mint_handler)) - .with_state(state); - - // CORS: allow frontend origins - let cors = CorsLayer::new() - .allow_origin([ - "https://zkcoins.app" - .parse::() - .expect("valid origin"), - "https://dev.zkcoins.app" - .parse::() - .expect("valid origin"), - "http://localhost:3090" - .parse::() - .expect("valid origin"), - ]) - .allow_methods([Method::GET, Method::POST]) - .allow_headers([header::CONTENT_TYPE]); - - // Build our application with routes - let app = Router::new() - .route("/health", get(|| async { "ok" })) - .nest("/api", api_routes) - .fallback(|| async { StatusCode::NOT_FOUND }) - .layer(cors); + let app = create_router(state); // Run the server println!("REST server started at {}", socket_addr); @@ -577,7 +1156,8 @@ pub async fn start_rest_server( Ok(()) } -// Handler to serve the index.html file +// Handler to serve the index.html file (currently unused, kept for future use) +#[allow(dead_code)] async fn serve_index() -> impl IntoResponse { let current_dir = std::env::current_dir().unwrap_or_default(); let index_path = current_dir.join("..").join("client").join("index.html"); @@ -603,3 +1183,894 @@ async fn serve_index() -> impl IntoResponse { // http://myserver.com//balance // http://myserver.com//send // http://myserver.com//sign) + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use crate::account_server::{Account, AccountServer}; + use crate::state::State; + + /// Create a minimal AppState for testing. + /// The AccountServer is constructed with a real (mock) prover so that the + /// type system is satisfied, but we seed it with a minting account so that + /// balance / address queries work without needing the minting_secret.bin + /// flow. + fn test_state() -> AppState { + let state = Arc::new(Mutex::new(State::new())); + let mut account_server = AccountServer::new(Arc::clone(&state)); + + // Seed a minting account with max balance (mirrors production setup) + let mut minting_account = Account::new(); + minting_account.balance = u64::MAX; + account_server.import_account(zkcoins_program::MINTING_ADDRESS, minting_account); + + // Create a dummy minting ClientAccount from a deterministic key + let secret = include_bytes!("../minting_secret.bin"); + let private_key = bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Signet, secret) + .expect("Failed to create test private key"); + let minting_client = shared::ClientAccount::new(private_key); + + AppState { + account_server: Arc::new(Mutex::new(account_server)), + proof_store: Arc::new(ProofStore::new("/tmp/zkcoins-test-proofs")), + minting_account: Arc::new(Mutex::new(minting_client)), + username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), + accounts_path: String::new(), + usernames_path: String::new(), + } + } + + /// Helper: send a request through the router and return (status, body string). + async fn send_request(request: Request) -> (StatusCode, String) { + let app = create_router(test_state()); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + (status, body) + } + + // --- GET /health --- + + #[tokio::test] + async fn health_returns_ok() { + let req = Request::get("/health").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "ok"); + } + + // --- GET /api/info --- + + #[tokio::test] + async fn info_returns_network_name() { + let req = Request::get("/api/info").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let info: InfoResponse = serde_json::from_str(&body).expect("valid JSON"); + // The lazy_static defaults to "Mutinynet" when IS_MAINNET is unset + assert!(!info.network.is_empty(), "network name must not be empty"); + } + + // --- GET /api/balance --- + + #[tokio::test] + async fn balance_unknown_address_returns_not_found() { + // 32 zero bytes in hex = 64 hex chars + let address_hex = "00".repeat(32); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 0); + assert!(resp.username.is_none()); + } + + #[tokio::test] + async fn balance_minting_address_returns_max() { + let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, u64::MAX); + } + + #[tokio::test] + async fn balance_missing_address_param_returns_not_found() { + let req = Request::get("/api/balance").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 0); + assert!(resp.username.is_none()); + } + + #[tokio::test] + async fn balance_invalid_hex_returns_unprocessable() { + let req = Request::get("/api/balance?address=not_valid_hex") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn balance_wrong_length_returns_unprocessable() { + // 16 bytes = 32 hex chars, but the handler expects exactly 32 bytes + let short_hex = "ab".repeat(16); + let uri = format!("/api/balance?address={}", short_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + // --- GET /api/address --- + + #[tokio::test] + async fn address_returns_list() { + let req = Request::get("/api/address").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: AddressesResponse = serde_json::from_str(&body).expect("valid JSON"); + // The test state has the minting address seeded + assert!( + !resp.addresses.is_empty(), + "should contain at least the minting address" + ); + assert!( + resp.addresses[0].starts_with("0x"), + "addresses should be 0x-prefixed" + ); + } + + // --- POST /api/send with missing fields --- + + #[tokio::test] + async fn send_missing_body_returns_error() { + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + // Axum returns 422 when JSON deserialization fails (missing required fields) + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn send_invalid_json_returns_bad_request() { + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from("not json")) + .unwrap(); + let (status, _body) = send_request(req).await; + + // Axum returns 400 Bad Request for syntactically invalid JSON + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn send_no_content_type_returns_error() { + let req = Request::post("/api/send").body(Body::from("{}")).unwrap(); + let (status, _body) = send_request(req).await; + + // Axum returns 415 Unsupported Media Type when content-type is missing for Json extractor + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + // --- POST /api/mint with missing fields --- + + #[tokio::test] + async fn mint_missing_body_returns_error() { + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + // --- GET /api/proof/{id} for non-existent proof --- + + #[tokio::test] + async fn proof_not_found_returns_404() { + let req = Request::get("/api/proof/9999").body(Body::empty()).unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + } + + // --- POST /api/commit with missing fields --- + + #[tokio::test] + async fn commit_missing_body_returns_error() { + let req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + // --- Fallback for unknown routes --- + + #[tokio::test] + async fn unknown_route_returns_404() { + let req = Request::get("/does-not-exist").body(Body::empty()).unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + } + + // ======================================================================= + // Helper: send a request through a *shared* router (same AppState across + // calls) instead of creating a fresh test_state() for every request. + // ======================================================================= + async fn send_request_with_state( + state: AppState, + request: Request, + ) -> (StatusCode, String) { + let app = create_router(state); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + (status, body) + } + + // --- GET /api/username/resolve/{username} --- + + #[tokio::test] + async fn resolve_unknown_username_returns_404() { + let req = Request::get("/api/username/resolve/nonexistent") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + + let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!(resp.reason.contains("not found")); + } + + #[tokio::test] + async fn resolve_minting_address_by_hex_prefix() { + // The minting address starts with "af53a1" — a short prefix is enough + // for resolve_identifier to match via hex-prefix fallback. + let full_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + let prefix = &full_hex[..8]; // first 8 hex chars + + let uri = format!("/api/username/resolve/{}", prefix); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: UsernameResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.address, format!("0x{}", full_hex)); + assert_eq!(resp.username, prefix); + } + + // --- POST /api/username/claim --- + + #[tokio::test] + async fn claim_username_empty_body_returns_422() { + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn claim_username_no_content_type_returns_415() { + let req = Request::post("/api/username/claim") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + // --- GET /.well-known/lnurlp/{username} --- + + #[tokio::test] + async fn lnurlp_unknown_user_returns_404() { + let req = Request::get("/.well-known/lnurlp/nobody") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + + let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!(resp.reason.contains("not found")); + } + + #[tokio::test] + async fn lnurlp_known_address_returns_pay_request() { + // The minting address is resolvable by hex prefix through resolve_identifier. + let full_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + let prefix = &full_hex[..8]; + + let uri = format!("/.well-known/lnurlp/{}", prefix); + let req = Request::get(&uri) + .header("host", "api.zkcoins.app") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: LnurlpResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.tag, "payRequest"); + assert!( + resp.callback.contains(prefix), + "callback should include the identifier" + ); + assert_eq!(resp.min_sendable, 1_000); + assert_eq!(resp.max_sendable, 1_000_000_000_000); + assert!(resp.metadata.contains("zkCoins")); + } + + // --- GET /lnurl/pay/{username} --- + + #[tokio::test] + async fn lnurl_pay_callback_returns_phase2_error() { + let req = Request::get("/lnurl/pay/someone") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!( + resp.reason.contains("Phase 2"), + "should mention Phase 2: {}", + resp.reason + ); + } + + // --- Balance includes username field --- + + #[tokio::test] + async fn balance_minting_address_has_no_username() { + let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + // username should be absent (skip_serializing_if = None) + let raw: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!( + raw.get("username").is_none() || raw["username"].is_null(), + "minting address without a claimed username should have no username field" + ); + } + + #[tokio::test] + async fn balance_includes_username_when_claimed() { + let state = test_state(); + + // Manually claim a username for the minting address + { + let mut username_store = state.username_store.lock().unwrap(); + username_store + .claim("satoshi", zkcoins_program::MINTING_ADDRESS) + .expect("claim should succeed"); + } + + let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, u64::MAX); + assert_eq!(resp.username, Some("satoshi".to_string())); + } + + // --- Concurrent balance reads --- + + #[tokio::test] + async fn concurrent_balance_reads_are_consistent() { + let state = test_state(); + let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + let uri = format!("/api/balance?address={}", address_hex); + + // Spawn many concurrent balance requests against the same shared state. + let mut handles = vec![]; + for _ in 0..20 { + let s = state.clone(); + let u = uri.clone(); + handles.push(tokio::spawn(async move { + let req = Request::get(&u).body(Body::empty()).unwrap(); + send_request_with_state(s, req).await + })); + } + + for handle in handles { + let (status, body) = handle.await.expect("task should not panic"); + assert_eq!(status, StatusCode::OK); + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!( + resp.balance, + u64::MAX, + "every concurrent read must see the same minting balance" + ); + } + } + + // --- Concurrent mixed reads and username operations --- + + #[tokio::test] + async fn concurrent_reads_with_username_claim() { + let state = test_state(); + let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); + + // Claim a username through the store directly (bypasses signature validation) + { + let mut store = state.username_store.lock().unwrap(); + store + .claim("testuser", zkcoins_program::MINTING_ADDRESS) + .unwrap(); + } + + // Spawn concurrent balance + resolve requests + let mut handles = vec![]; + + for i in 0..10 { + let s = state.clone(); + let hex = address_hex.clone(); + handles.push(tokio::spawn(async move { + if i % 2 == 0 { + // Balance request + let req = Request::get(&format!("/api/balance?address={}", hex)) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(s, req).await; + assert_eq!(status, StatusCode::OK); + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, u64::MAX); + assert_eq!(resp.username, Some("testuser".to_string())); + } else { + // Resolve request + let req = Request::get("/api/username/resolve/testuser") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(s, req).await; + assert_eq!(status, StatusCode::OK); + let resp: UsernameResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.username, "testuser"); + assert_eq!(resp.address, format!("0x{}", hex)); + } + })); + } + + for handle in handles { + handle.await.expect("task should not panic"); + } + } + + // --- POST /api/commit with non-existent proof_id --- + + #[tokio::test] + async fn commit_nonexistent_proof_id_returns_404() { + let state = test_state(); + let body = serde_json::json!({ + "proof_id": 999999, + "public_key": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "signature": "00".repeat(64), + "message": "00".repeat(32), + }); + let req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + } + + // --- POST /api/commit with valid proof_id but invalid signature --- + + #[tokio::test] + async fn commit_invalid_signature_returns_error() { + // Submit a commit with a fabricated proof_id that does not exist but with + // a structurally valid body — the handler should return 404 (proof not found). + let commit_body = serde_json::json!({ + "proof_id": 99999, + "public_key": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "signature": "ab".repeat(64), + "message": "cd".repeat(32), + }); + let req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&commit_body).unwrap())) + .unwrap(); + let (status, _) = send_request(req).await; + + assert_eq!( + status, + StatusCode::NOT_FOUND, + "commit with non-existent proof_id must return 404" + ); + } + + // --- verify_send_signature tests --- + + #[test] + fn send_signature_rejects_missing_signature() { + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: None, + timestamp: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing signature")); + } + + #[test] + fn send_signature_rejects_missing_timestamp() { + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: Some("ab".repeat(64)), + timestamp: None, + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing timestamp")); + } + + #[test] + fn send_signature_rejects_expired_timestamp() { + let old_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 600; // 10 minutes ago + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: Some("ab".repeat(64)), + timestamp: Some(old_timestamp), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("timestamp")); + } + + #[test] + fn send_signature_rejects_invalid_hex() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: Some("not_valid_hex".to_string()), + timestamp: Some(now), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid signature hex")); + } + + #[test] + fn send_signature_rejects_wrong_signature() { + use bitcoin::secp256k1::SecretKey; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Sign a DIFFERENT message than what verify_send_signature expects + let wrong_msg = Message::from_digest([0u8; 32]); + let (xonly, _) = public_key.x_only_public_key(); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&wrong_msg, &keypair); + + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key, + next_public_key: public_key, + prev_commitment_pubkey: None, + signature: Some(hex::encode(sig.serialize())), + timestamp: Some(now), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Signature verification failed")); + } + + // --- POST /api/username/claim with valid Schnorr signature --- + + #[tokio::test] + async fn claim_username_with_valid_signature() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[7u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + // address = sha256(compressed_pubkey) + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "testclaim"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Build claim message: sha256("zkcoins:claim_username" || address_hex || username || timestamp_le) + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + // Import the address into the account_server so resolve_identifier can find it + let state = test_state(); + { + let mut account_server = state.account_server.lock().unwrap(); + account_server.import_account(address, Account::new()); + } + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::OK, + "Claim should succeed: {}", + resp_body + ); + + let resp: UsernameResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.username, username); + assert_eq!(resp.address, format!("0x{}", address_hex)); + } + + #[tokio::test] + async fn claim_username_wrong_pubkey() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[8u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + // Use a DIFFERENT address that does NOT match sha256(pubkey) + let wrong_address: [u8; 32] = [0xAA; 32]; + let address_hex = hex::encode(wrong_address); + + let username = "wrongpk"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Sign with the correct message format but the address doesn't match the pubkey + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Claim with mismatched pubkey/address must be rejected" + ); + } + + #[tokio::test] + async fn claim_username_expired_timestamp() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[9u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "expiredts"; + // Timestamp 10 minutes in the past (exceeds 5-min window) + let expired_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 600; + + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(expired_timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": expired_timestamp, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Claim with expired timestamp must be rejected" + ); + } + + #[test] + fn send_signature_accepts_valid_signature() { + use bitcoin::secp256k1::SecretKey; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + let account_address = "0x".to_string() + &hex::encode([1u8; 32]); + let recipient = "0x".to_string() + &hex::encode([2u8; 32]); + let amount: u64 = 100; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Build the exact same message as verify_send_signature + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let request = SendCoinRequest { + account_address, + recipient, + amount, + public_key, + next_public_key: public_key, + prev_commitment_pubkey: None, + signature: Some(hex::encode(sig.serialize())), + timestamp: Some(now), + }; + assert!(verify_send_signature(&request).is_ok()); + } +} diff --git a/server/src/state.rs b/server/src/state.rs index 9234b094..12ca6568 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,13 +1,15 @@ -use shared::commitment::Commitment; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use zkcoins_program::merkle::merkle_mountain_range::{MMRProof, MerkleMountainRange}; -use zkcoins_program::merkle::sparse_merkle_tree::{load_merkle_tree, save_merkle_tree, InclusionProof, SparseMerkleTree}; -use zkcoins_program::merkle::{HashDigest, ZERO_HASH}; +use shared::commitment::Commitment; use std::collections::HashMap; use std::io; +use zkcoins_program::merkle::merkle_mountain_range::{MMRProof, MerkleMountainRange}; +use zkcoins_program::merkle::sparse_merkle_tree::{ + load_merkle_tree, save_merkle_tree, InclusionProof, SparseMerkleTree, +}; +use zkcoins_program::merkle::{HashDigest, ZERO_HASH}; /// State stores both a Sparse Merkle Tree (for individual commitments) /// and a Merkle Mountain Range (for accumulating SMT roots). @@ -70,7 +72,8 @@ impl State { // Store the mapping of previous MMR root to (SMT root, leaf index) let leaf_index = self.mmr.leaf_count(); - self.root_indices.insert(prev_mmr_root, (smt_root, leaf_index)); + self.root_indices + .insert(prev_mmr_root, (smt_root, leaf_index)); // 4. Append the new leaf to the MMR self.mmr.append(leaf); @@ -93,7 +96,7 @@ impl State { match self.root_indices.get(&prev_mmr_root) { // Get the inclusion proof for this index from the MMR Some(&(smt_root, index)) => self.mmr.get_proof(index).map(|proof| (smt_root, proof)), - None => Err("Couldn't find MMR inclusion proof") + None => Err("Couldn't find MMR inclusion proof"), } } @@ -144,7 +147,7 @@ impl State { // Save prev_mmr_root to a separate file let prev_root_path = format!("{}.prev_root", mmr_path); - std::fs::write(prev_root_path, self.prev_mmr_root)?; + crate::atomic_write(&prev_root_path, &self.prev_mmr_root)?; Ok(()) } @@ -186,8 +189,8 @@ mod tests { use super::*; use bitcoin::hashes::Hash; use bitcoin::secp256k1::{Secp256k1, SecretKey}; - use zkcoins_program::merkle::{hash_concat, HASH_SIZE}; use std::str::FromStr; + use zkcoins_program::merkle::{hash_concat, HASH_SIZE}; // Helper function to create a test commitment with a given message fn create_test_commitment(message: &[u8], key_hex: &str) -> Commitment { @@ -242,7 +245,9 @@ mod tests { let root1 = state.update(&[commitments[0].clone()]).unwrap(); // Then update with the other two - let root2 = state.update(&[commitments[1].clone(), commitments[2].clone()]).unwrap(); + let root2 = state + .update(&[commitments[1].clone(), commitments[2].clone()]) + .unwrap(); // The roots should be different after each update assert_ne!(root1, root2); @@ -422,7 +427,10 @@ mod tests { ); let result = state.get_commitment_proof(&non_existent.public_key); - assert!(result.is_err(), "Should return Err for non-existent commitment"); + assert!( + result.is_err(), + "Should return Err for non-existent commitment" + ); } #[test] diff --git a/server/src/username.rs b/server/src/username.rs new file mode 100644 index 00000000..632f27ad --- /dev/null +++ b/server/src/username.rs @@ -0,0 +1,150 @@ +use serde::{Deserialize, Serialize}; +use shared::Address; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct UsernameStore { + usernames: HashMap, +} + +impl UsernameStore { + pub fn new() -> Self { + Self::default() + } + + pub fn claim(&mut self, username: &str, address: Address) -> Result<(), &'static str> { + let normalized = username.to_lowercase(); + + if normalized.is_empty() || normalized.len() > 64 { + return Err("Username must be 1-64 characters"); + } + if !normalized + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err("Username may only contain a-z, 0-9, -, _, ."); + } + + if self.usernames.contains_key(&normalized) { + return Err("Username already taken"); + } + + if self.usernames.values().any(|a| *a == address) { + return Err("Address already has a username"); + } + + self.usernames.insert(normalized, address); + Ok(()) + } + + pub fn resolve(&self, username: &str) -> Option
{ + self.usernames.get(&username.to_lowercase()).copied() + } + + pub fn get_username(&self, address: &Address) -> Option<&str> { + self.usernames + .iter() + .find(|(_, a)| *a == address) + .map(|(name, _)| name.as_str()) + } + + pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { + let bytes = bincode::serialize(&self.usernames) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + crate::atomic_write(path, &bytes) + } + + pub fn load_from_file(path: &str) -> std::io::Result { + let bytes = std::fs::read(path)?; + let usernames: HashMap = bincode::deserialize(&bytes) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + Ok(UsernameStore { usernames }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claim_and_resolve() { + let mut store = UsernameStore::new(); + let address = [1u8; 32]; + + store.claim("Alice", address).unwrap(); + assert_eq!(store.resolve("alice"), Some(address)); + assert_eq!(store.resolve("Alice"), Some(address)); + assert_eq!(store.get_username(&address), Some("alice")); + } + + #[test] + fn duplicate_username_rejected() { + let mut store = UsernameStore::new(); + store.claim("alice", [1u8; 32]).unwrap(); + assert!(store.claim("alice", [2u8; 32]).is_err()); + } + + #[test] + fn duplicate_address_rejected() { + let mut store = UsernameStore::new(); + let address = [1u8; 32]; + store.claim("alice", address).unwrap(); + assert!(store.claim("bob", address).is_err()); + } + + #[test] + fn invalid_username_rejected() { + let mut store = UsernameStore::new(); + assert!(store.claim("", [1u8; 32]).is_err()); + assert!(store.claim("hello world", [2u8; 32]).is_err()); + assert!(store.claim("hello@world", [3u8; 32]).is_err()); + assert!(store.claim(&"a".repeat(65), [4u8; 32]).is_err()); + } + + #[test] + fn valid_usernames_accepted() { + let mut store = UsernameStore::new(); + store.claim("alice", [1u8; 32]).unwrap(); + store.claim("bob-99", [2u8; 32]).unwrap(); + store.claim("carol_x", [3u8; 32]).unwrap(); + store.claim("dave.btc", [4u8; 32]).unwrap(); + } + + #[test] + fn save_and_load_roundtrip() { + let path = "/tmp/zkcoins-test-usernames.bin"; + let mut store = UsernameStore::new(); + store.claim("alice", [1u8; 32]).unwrap(); + store.claim("bob", [2u8; 32]).unwrap(); + + store.save_to_file(path).unwrap(); + let loaded = UsernameStore::load_from_file(path).unwrap(); + + assert_eq!(loaded.resolve("alice"), Some([1u8; 32])); + assert_eq!(loaded.resolve("bob"), Some([2u8; 32])); + assert_eq!(loaded.get_username(&[1u8; 32]), Some("alice")); + assert_eq!(loaded.resolve("nonexistent"), None); + + std::fs::remove_file(path).ok(); + } + + #[test] + fn resolve_is_case_insensitive() { + let mut store = UsernameStore::new(); + let address = [5u8; 32]; + store.claim("Alice", address).unwrap(); + + // Resolve with different casings + assert_eq!(store.resolve("alice"), Some(address)); + assert_eq!(store.resolve("ALICE"), Some(address)); + assert_eq!(store.resolve("Alice"), Some(address)); + assert_eq!(store.resolve("aLiCe"), Some(address)); + } + + #[test] + fn get_username_returns_none_for_unknown() { + let store = UsernameStore::new(); + let unknown_address = [99u8; 32]; + assert_eq!(store.get_username(&unknown_address), None); + } +} diff --git a/shared/src/commitment.rs b/shared/src/commitment.rs index 52555122..d3ec4e01 100644 --- a/shared/src/commitment.rs +++ b/shared/src/commitment.rs @@ -1,10 +1,10 @@ use bitcoin::secp256k1::{ - self, schnorr::Signature, Keypair, Message, PublicKey, Secp256k1, SecretKey + self, schnorr::Signature, Keypair, Message, PublicKey, Secp256k1, SecretKey, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use zkcoins_program::merkle::HashDigest; use std::fmt; +use zkcoins_program::merkle::HashDigest; use crate::SECP256K1; diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 63a851da..6394a9bd 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,8 +1,19 @@ -use bitcoin::{bip32::{ChildNumber, Xpriv, Xpub}, key::{rand::{rngs::OsRng, RngCore}, Secp256k1}, secp256k1::{All, PublicKey, SecretKey}, Network}; +use bitcoin::{ + bip32::{ChildNumber, Xpriv, Xpub}, + key::{ + rand::{rngs::OsRng, RngCore}, + Secp256k1, + }, + secp256k1::{All, PublicKey, SecretKey}, + Network, +}; use commitment::Commitment; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; -use zkcoins_program::{merkle::{hash_concat, HashDigest}, AccountState, Amount}; +use zkcoins_program::{ + merkle::{hash_concat, HashDigest}, + AccountState, Amount, +}; pub mod commitment; pub use zkcoins_program::ProofData; @@ -55,18 +66,18 @@ impl ClientAccount { .private_key } - pub fn create_commitment(&self, account_state_hash: &HashDigest, output_coins_root: &HashDigest) -> Commitment { + pub fn create_commitment( + &self, + account_state_hash: &HashDigest, + output_coins_root: &HashDigest, + ) -> Commitment { Commitment::new( &self.current_private_key(), - hash_concat( - account_state_hash, - output_coins_root, - ) - .to_vec(), + hash_concat(account_state_hash, output_coins_root).to_vec(), ) .expect("Should be able to create commitment") } - + pub fn generate_public_key(&self, index: u32) -> PublicKey { // WARNING: LEAKING THE MASTER PUBLIC KEY IS EQUIVALENT TO LEAKING THE PRIVATE KEY! Xpub::from_priv(&SECP256K1, &self.private_key) @@ -81,11 +92,7 @@ impl ClientAccount { num_pubkeys: 0, private_key, }; - let account = AccountState::new( - client_account.generate_public_key(0) - .serialize() - .to_vec(), - ); + let account = AccountState::new(client_account.generate_public_key(0).serialize().to_vec()); client_account.address = account.owner; client_account }