From 413540ddba06d05e5d570991f1ceac0429d0533e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Thir=C3=A9?= Date: Fri, 17 Apr 2026 23:51:46 +0200 Subject: [PATCH 1/6] fix(ci): repair OCaml and cross-impl interop jobs - ocaml_unit_tests.sh: dune is installed via opam but the shell step did not have the opam environment activated; prefix with opam exec -- - cross_impl_interop: the test calls dune at runtime but was in the Rust-only job which has no OCaml; move it to the ocaml job (which already has OCaml) and add a Rust toolchain step there - Remove cross_impl_interop from the rust-and-cairo job Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/unit-tests.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 94e81fa..3e3f9f3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -45,7 +45,6 @@ jobs: cargo +nightly-2025-07-14 test -p tzel-verifier --lib cargo test --lib -p tzel-services cargo test --test integration -p tzel-services - cargo test --test cross_impl_interop -p tzel-services cargo test --manifest-path services/tzel/Cargo.toml --bin tzel-operator cargo +nightly-2025-07-14 test -p tzel-rollup-kernel --lib cargo +nightly-2025-07-14 test -p tzel-rollup-kernel --test bridge_flow @@ -88,5 +87,11 @@ jobs: run: | opam install -y dune alcotest cstruct ctypes ctypes-foreign hex mirage-crypto yojson + - name: Set up Rust stable + uses: dtolnay/rust-toolchain@stable + - name: Run OCaml unit tests - run: ./scripts/ocaml_unit_tests.sh + run: opam exec -- ./scripts/ocaml_unit_tests.sh + + - name: Run cross-implementation interop test + run: opam exec -- cargo test --test cross_impl_interop -p tzel-services From ea0e062b4492a211bf6ee88ba39536cc12485e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Thir=C3=A9?= Date: Sat, 18 Apr 2026 00:27:41 +0200 Subject: [PATCH 2/6] fix(ci): build libmlkem768 before OCaml tests The ML-KEM native C library (vendor/mlkem-native) is not committed to git and must be compiled before dune can link the OCaml executables. Add a `make lib` step and set LIBRARY_PATH for the cross_impl_interop cargo test which runs dune at runtime outside the shell script. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/unit-tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3e3f9f3..a09593e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -87,6 +87,9 @@ jobs: run: | opam install -y dune alcotest cstruct ctypes ctypes-foreign hex mirage-crypto yojson + - name: Build ML-KEM native library + run: make -C ocaml/vendor/mlkem-native lib + - name: Set up Rust stable uses: dtolnay/rust-toolchain@stable @@ -94,4 +97,6 @@ jobs: run: opam exec -- ./scripts/ocaml_unit_tests.sh - name: Run cross-implementation interop test + env: + LIBRARY_PATH: ${{ github.workspace }}/ocaml/vendor/mlkem-native/test/build run: opam exec -- cargo test --test cross_impl_interop -p tzel-services From 8cedf429b6ab36eb3e74a4d183d0bfa25cb6ce09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Thir=C3=A9?= Date: Sat, 18 Apr 2026 00:56:05 +0200 Subject: [PATCH 3/6] fix(ci): set LIBRARY_PATH at job level for ocaml job Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/unit-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a09593e..d8ad902 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -69,6 +69,8 @@ jobs: ocaml: runs-on: ubuntu-latest + env: + LIBRARY_PATH: ${{ github.workspace }}/ocaml/vendor/mlkem-native/test/build steps: - name: Check out repository uses: actions/checkout@v4 @@ -97,6 +99,4 @@ jobs: run: opam exec -- ./scripts/ocaml_unit_tests.sh - name: Run cross-implementation interop test - env: - LIBRARY_PATH: ${{ github.workspace }}/ocaml/vendor/mlkem-native/test/build run: opam exec -- cargo test --test cross_impl_interop -p tzel-services From feba10a008036071c52b014ed05b04b2635609a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Thir=C3=A9?= Date: Fri, 17 Apr 2026 23:28:01 +0200 Subject: [PATCH 4/6] feat(wallet): add wallet-server HTTP API and web dapp Exposes shield/transfer/unshield/scan/balance/address over a local REST API so any HTTP client (including the bundled Vite/React dapp) can interact with a private TzEL wallet without touching the CLI directly. - wallet-server binary: Axum HTTP server, supports --trust-me-bro (skip proofs) and --proving-service= for STARK proof generation - web/: Vite + React dapp connecting to wallet-server in real mode, with mock mode for UI development without a live node - core: add bech32 encoding for PaymentAddress; PartialEq/Eq on PaymentAddress; remove unused verify_wots_signature_against_leaf - docs/wallet_server.md: manual testing guide Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 16 +- apps/wallet/Cargo.toml | 8 + apps/wallet/src/bin/wallet-server.rs | 3 + apps/wallet/src/lib.rs | 1298 ++++++++++++++----- core/Cargo.toml | 1 + core/src/lib.rs | 92 +- docs/wallet_server.md | 150 +++ web/.gitignore | 3 + web/index.html | 15 + web/package-lock.json | 1729 ++++++++++++++++++++++++++ web/package.json | 21 + web/src/App.css | 630 ++++++++++ web/src/App.tsx | 875 +++++++++++++ web/src/main.tsx | 10 + web/src/mockService.ts | 107 ++ web/src/realService.ts | 75 ++ web/src/types.ts | 36 + web/src/useAddressBook.ts | 47 + web/tsconfig.json | 17 + web/vite.config.ts | 20 + 20 files changed, 4783 insertions(+), 370 deletions(-) create mode 100644 apps/wallet/src/bin/wallet-server.rs create mode 100644 docs/wallet_server.md create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.css create mode 100644 web/src/App.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/mockService.ts create mode 100644 web/src/realService.ts create mode 100644 web/src/types.ts create mode 100644 web/src/useAddressBook.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/Cargo.lock b/Cargo.lock index 6ce1e71..9228936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bigdecimal" version = "0.3.1" @@ -1440,7 +1446,7 @@ version = "0.1.0" source = "git+https://github.com/starkware-libs/stwo-circuits?rev=2591775#2591775ae8fd7634eda7b77c471f87c163f65eb1" dependencies = [ "blake2", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap", "itertools 0.12.1", "num-traits", @@ -1453,7 +1459,7 @@ version = "0.1.0" source = "git+https://github.com/starkware-libs/stwo-circuits?rev=2591775#2591775ae8fd7634eda7b77c471f87c163f65eb1" dependencies = [ "circuits", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap", "itertools 0.12.1", "num-traits", @@ -2316,7 +2322,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "serde", ] [[package]] @@ -4595,7 +4600,7 @@ dependencies = [ "dashmap", "educe 0.5.11", "fnv", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hex", "indexmap", "itertools 0.12.1", @@ -4654,7 +4659,7 @@ name = "stwo-constraint-framework" version = "2.1.0" source = "git+https://github.com/starkware-libs/stwo?rev=aeceb74c#aeceb74c58184d7886ebd7f34a7453fee714ca40" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", "itertools 0.12.1", "num-traits", "rand 0.8.5", @@ -5226,6 +5231,7 @@ dependencies = [ name = "tzel-core" version = "0.1.0" dependencies = [ + "bech32", "blake2s_simd", "chacha20poly1305", "hex", diff --git a/apps/wallet/Cargo.toml b/apps/wallet/Cargo.toml index 308544e..e044460 100644 --- a/apps/wallet/Cargo.toml +++ b/apps/wallet/Cargo.toml @@ -3,10 +3,18 @@ name = "tzel-wallet-app" version = "0.1.0" edition = "2021" +[lib] +name = "tzel_wallet_app" +path = "src/lib.rs" + [[bin]] name = "sp-client" path = "src/bin/sp-client.rs" +[[bin]] +name = "wallet-server" +path = "src/bin/wallet-server.rs" + [[bin]] name = "tzel-wallet" path = "src/bin/tzel-wallet.rs" diff --git a/apps/wallet/src/bin/wallet-server.rs b/apps/wallet/src/bin/wallet-server.rs new file mode 100644 index 0000000..3f5e8b1 --- /dev/null +++ b/apps/wallet/src/bin/wallet-server.rs @@ -0,0 +1,3 @@ +fn main() { + tzel_wallet_app::wallet_server_entry(); +} diff --git a/apps/wallet/src/lib.rs b/apps/wallet/src/lib.rs index 6bfc721..609390c 100644 --- a/apps/wallet/src/lib.rs +++ b/apps/wallet/src/lib.rs @@ -22,6 +22,10 @@ use tzel_verifier::{encode_verify_meta, ProofBundle as VerifyProofBundle}; const XMSS_BDS_K: usize = 2; +fn is_zero_u32(v: &u32) -> bool { + *v == 0 +} + #[derive(Clone, Serialize, Deserialize)] struct FeltSlot { present: bool, @@ -442,10 +446,53 @@ struct WalletAddressState { auth_pub_seed: F, #[serde(with = "hex_f")] nk_tag: F, - bds: XmssBdsState, + #[serde(default, skip_serializing_if = "Option::is_none")] + bds: Option, + /// Legacy migration-only field from the old wallet format. + #[serde(default, skip_serializing_if = "is_zero_u32")] + next_auth_index: u32, + /// Legacy migration-only field from the old wallet format. + #[serde(default, with = "hex_f_vec", skip_serializing_if = "Vec::is_empty")] + next_auth_path: Vec, } impl WalletAddressState { + fn ensure_bds(&mut self, ask_j: &F) -> Result<(), String> { + if self.bds.is_some() { + return Ok(()); + } + self.ensure_bds_with(ask_j, |ask_j, pub_seed, next_auth_index| { + XmssBdsState::from_index(ask_j, pub_seed, next_auth_index) + }) + } + + fn ensure_bds_with(&mut self, ask_j: &F, rebuild: R) -> Result<(), String> + where + R: FnOnce(&F, &F, u32) -> Result<(XmssBdsState, F), String>, + { + if self.bds.is_some() { + return Ok(()); + } + let (state, root) = rebuild(ask_j, &self.auth_pub_seed, self.next_auth_index)?; + if root != self.auth_root { + return Err(format!( + "rebuilt XMSS root mismatch for address {}", + self.index + )); + } + if !self.next_auth_path.is_empty() && state.current_path() != self.next_auth_path.as_slice() + { + return Err(format!( + "rebuilt XMSS path mismatch for address {} at index {}", + self.index, self.next_auth_index + )); + } + self.bds = Some(state); + self.next_auth_index = 0; + self.next_auth_path.clear(); + Ok(()) + } + fn payment_address(&self, ek_v: &Ek, ek_d: &Ek) -> PaymentAddress { PaymentAddress { d_j: self.d_j, @@ -462,6 +509,12 @@ impl WalletAddressState { struct WalletFile { #[serde(with = "hex_f")] master_sk: F, + /// Legacy global KEM seeds — ignored when per-address derivation is available. + /// Kept for backwards compatibility during wallet migration. + #[serde(default, with = "hex_bytes")] + kem_seed_v: Vec, + #[serde(default, with = "hex_bytes")] + kem_seed_d: Vec, #[serde(default)] addresses: Vec, addr_counter: u32, @@ -602,12 +655,12 @@ impl WalletFile { fn derive_address_state( &self, j: u32, - next_wots_index: u32, + next_auth_index: u32, ) -> Result { #[cfg(test)] panic!( - "unexpected XMSS address derivation for j={} next_wots_index={} — default tests must use fixed prederived wallet/address fixtures", - j, next_wots_index + "unexpected XMSS address derivation for j={} next_auth_index={} — default tests must use fixed prederived wallet/address fixtures", + j, next_auth_index ); #[cfg(not(test))] @@ -619,7 +672,7 @@ impl WalletFile { #[cfg(not(test))] let auth_pub_seed = derive_auth_pub_seed(&ask_j); #[cfg(not(test))] - let (bds, auth_root) = XmssBdsState::from_index(&ask_j, &auth_pub_seed, next_wots_index)?; + let (bds, auth_root) = XmssBdsState::from_index(&ask_j, &auth_pub_seed, next_auth_index)?; #[cfg(not(test))] let nk_spend = derive_nk_spend(&acc.nk, &d_j); #[cfg(not(test))] @@ -632,20 +685,37 @@ impl WalletFile { auth_root, auth_pub_seed, nk_tag, - bds, + bds: Some(bds), + next_auth_index: 0, + next_auth_path: Vec::new(), }) } fn materialize_addresses(&mut self) -> Result<(), String> { + if self.addresses.len() == self.addr_counter as usize { + let ask_base = self.account().ask_base; + for addr in &mut self.addresses { + let ask_j = derive_ask(&ask_base, addr.index); + addr.ensure_bds(&ask_j)?; + if let Some(bds) = &addr.bds { + self.wots_key_indices.insert(addr.index, bds.next_index); + } + } + return Ok(()); + } let existing = self.addresses.len() as u32; for j in existing..self.addr_counter { - let next_wots_index = *self.wots_key_indices.get(&j).unwrap_or(&0); + let next_auth_index = *self.wots_key_indices.get(&j).unwrap_or(&0); self.addresses - .push(self.derive_address_state(j, next_wots_index)?); + .push(self.derive_address_state(j, next_auth_index)?); } - for addr in &self.addresses { - self.wots_key_indices - .insert(addr.index, addr.bds.next_index); + let ask_base = self.account().ask_base; + for addr in &mut self.addresses { + let ask_j = derive_ask(&ask_base, addr.index); + addr.ensure_bds(&ask_j)?; + if let Some(bds) = &addr.bds { + self.wots_key_indices.insert(addr.index, bds.next_index); + } } Ok(()) } @@ -657,6 +727,16 @@ impl WalletFile { .0 } + /// Legacy global KEM keys used by pre-migration wallets. + /// Returns None when the wallet was created after the per-address migration. + fn legacy_kem_keys(&self) -> Option<(Ek, Dk, Ek, Dk)> { + let seed_v: [u8; 64] = self.kem_seed_v.as_slice().try_into().ok()?; + let seed_d: [u8; 64] = self.kem_seed_d.as_slice().try_into().ok()?; + let (ek_v, dk_v) = kem_keygen_from_seed(&seed_v); + let (ek_d, dk_d) = kem_keygen_from_seed(&seed_d); + Some((ek_v, dk_v, ek_d, dk_d)) + } + /// Per-address KEM keys derived from incoming_seed + address index. /// Each address j gets unique (ek_v_j, dk_v_j, ek_d_j, dk_d_j) so that /// addresses from the same wallet are unlinkable by their public keys. @@ -694,12 +774,30 @@ impl WalletFile { }) } - /// Recover a note from the notes feed using the current per-address KEM keys. + /// Recover a note from the notes feed using either: + /// 1. Legacy global KEM keys (for pre-migration wallets), or + /// 2. Current per-address KEM keys. fn try_recover_note(&self, nm: &NoteMemo) -> Option { let acc = self.account(); + // Legacy compatibility: old wallets used one global ML-KEM keypair + // for all addresses. Keep scanning those notes until users migrate. + if let Some((_, dk_v_legacy, _, dk_d_legacy)) = self.legacy_kem_keys() { + if detect(&nm.enc, &dk_d_legacy) { + if let Some((v, rseed, _memo)) = decrypt_memo(&nm.enc, &dk_v_legacy) { + for addr in &self.addresses { + if let Some(note) = + self.recover_note_for_address(&acc, addr, v, rseed, nm.cm, nm.index) + { + return Some(note); + } + } + } + } + } + for addr in &self.addresses { - let (_, dk_v_j, _, dk_d_j) = self.kem_keys(addr.index); + let (_, dk_v_j, _, dk_d_j) = derive_kem_keys(&acc.incoming_seed, addr.index); if !detect(&nm.enc, &dk_d_j) { continue; } @@ -739,7 +837,11 @@ impl WalletFile { .get_mut(addr_index as usize) .ok_or_else(|| format!("missing address record {}", addr_index))?; let ask_j = derive_ask(&ask_base, addr_index); - let bds = &mut addr.bds; + addr.ensure_bds(&ask_j)?; + let bds = addr + .bds + .as_mut() + .ok_or_else(|| format!("missing XMSS traversal state for address {}", addr_index))?; let key_idx = bds.next_index; if (key_idx as usize) >= AUTH_TREE_SIZE { return Err(format!( @@ -800,7 +902,11 @@ impl WalletFile { fn wallet_xmss_floor(&self) -> WalletXmssFloor { let mut wots_key_indices = self.wots_key_indices.clone(); for addr in &self.addresses { - let next_index = addr.bds.next_index; + let next_index = addr + .bds + .as_ref() + .map(|bds| bds.next_index) + .unwrap_or(addr.next_auth_index); wots_key_indices .entry(addr.index) .and_modify(|current| *current = (*current).max(next_index)) @@ -1116,7 +1222,9 @@ fn save_private_json(path: &str, value: &T, label: &str) -> Result let data = serde_json::to_string_pretty(value).map_err(|e| format!("serialize {}: {}", label, e))?; let output_path = std::path::Path::new(path); - let (tmp, mut file) = create_private_temp_file(output_path, label)?; + let tmp = std::path::PathBuf::from(format!("{}.tmp", path)); + let mut file = + std::fs::File::create(&tmp).map_err(|e| format!("create {} tmp: {}", label, e))?; file.write_all(data.as_bytes()) .map_err(|e| format!("write {} tmp: {}", label, e))?; file.sync_all() @@ -1132,7 +1240,8 @@ fn save_wallet(path: &str, w: &WalletFile) -> Result<(), String> { // Durable write: fsync temp file, rename atomically, then fsync the parent // directory so one-time WOTS state survives crashes before submit returns. let wallet_path = std::path::Path::new(path); - let (tmp, mut file) = create_private_temp_file(wallet_path, "wallet")?; + let tmp = std::path::PathBuf::from(format!("{}.tmp", path)); + let mut file = std::fs::File::create(&tmp).map_err(|e| format!("create tmp: {}", e))?; file.write_all(data.as_bytes()) .map_err(|e| format!("write tmp: {}", e))?; file.sync_all().map_err(|e| format!("fsync tmp: {}", e))?; @@ -1155,7 +1264,8 @@ fn save_wallet_xmss_floor(path: &str, floor: &WalletXmssFloor) -> Result<(), Str let data = serde_json::to_string_pretty(floor).map_err(|e| format!("serialize floor: {}", e))?; let floor_path = wallet_xmss_floor_path(path); - let (tmp, mut file) = create_private_temp_file(&floor_path, "wallet xmss floor")?; + let tmp = PathBuf::from(format!("{}.tmp", floor_path.display())); + let mut file = std::fs::File::create(&tmp).map_err(|e| format!("create floor tmp: {}", e))?; file.write_all(data.as_bytes()) .map_err(|e| format!("write floor tmp: {}", e))?; file.sync_all() @@ -1176,42 +1286,16 @@ fn current_wallet_wots_floor(wallet: &WalletFile, addr_index: u32) -> u32 { .addresses .iter() .find(|addr| addr.index == addr_index) - .map(|addr| addr.bds.next_index) + .map(|addr| { + addr.bds + .as_ref() + .map(|bds| bds.next_index) + .unwrap_or(addr.next_auth_index) + }) }) .unwrap_or(0) } -fn create_private_temp_file( - output_path: &std::path::Path, - label: &str, -) -> Result<(PathBuf, std::fs::File), String> { - let parent = output_path - .parent() - .unwrap_or_else(|| std::path::Path::new(".")); - let base_name = output_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("wallet"); - for attempt in 0..16u32 { - let tmp = parent.join(format!( - ".{}.tmp.{}.{}.{}", - base_name, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| format!("system clock error: {}", e))? - .as_nanos(), - attempt - )); - match create_private_file(&tmp) { - Ok(file) => return Ok((tmp, file)), - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(err) => return Err(format!("create {} tmp: {}", label, err)), - } - } - Err(format!("create {} tmp: too many collisions", label)) -} - fn enforce_wallet_xmss_floor(path: &str, wallet: &WalletFile) -> Result<(), String> { let floor_path = wallet_xmss_floor_path(path); let Ok(data) = std::fs::read_to_string(&floor_path) else { @@ -1232,7 +1316,7 @@ fn enforce_wallet_xmss_floor(path: &str, wallet: &WalletFile) -> Result<(), Stri let current_next = current_wallet_wots_floor(wallet, *addr_index); if current_next < *required_next { return Err(format!( - "wallet appears to be restored from a stale backup: address {} next_wots_index {} is behind durable XMSS floor {}", + "wallet appears to be restored from a stale backup: address {} next_auth_index {} is behind durable XMSS floor {}", addr_index, current_next, required_next )); } @@ -1240,25 +1324,6 @@ fn enforce_wallet_xmss_floor(path: &str, wallet: &WalletFile) -> Result<(), Stri Ok(()) } -#[cfg(unix)] -fn create_private_file(path: &std::path::Path) -> Result { - use std::os::unix::fs::OpenOptionsExt; - - std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(path) -} - -#[cfg(not(unix))] -fn create_private_file(path: &std::path::Path) -> Result { - std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path) -} - #[cfg(unix)] fn set_wallet_permissions(path: &std::path::Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; @@ -1333,28 +1398,6 @@ fn post_json Deserialize<'de>>( .map_err(|e| format!("parse response: {}", e)) } -fn post_json_with_bearer Deserialize<'de>>( - url: &str, - body: &Req, - bearer_token: Option<&str>, -) -> Result { - let mut req = ureq::post(url); - if let Some(token) = bearer_token { - req = req.header("Authorization", &format!("Bearer {}", token)); - } - let resp = req - .send_json(serde_json::to_value(body).unwrap()) - .map_err(|e| format!("HTTP error: {}", e))?; - let status = resp.status(); - if status != 200 { - let body = resp.into_body().read_to_string().unwrap_or_default(); - return Err(format!("HTTP {}: {}", status, body)); - } - resp.into_body() - .read_json() - .map_err(|e| format!("parse response: {}", e)) -} - fn get_json Deserialize<'de>>(url: &str) -> Result { let resp = ureq::get(url) .call() @@ -1364,20 +1407,6 @@ fn get_json Deserialize<'de>>(url: &str) -> Result .map_err(|e| format!("parse response: {}", e)) } -fn get_json_with_bearer Deserialize<'de>>( - url: &str, - bearer_token: Option<&str>, -) -> Result { - let mut req = ureq::get(url); - if let Some(token) = bearer_token { - req = req.header("Authorization", &format!("Bearer {}", token)); - } - let resp = req.call().map_err(|e| format!("HTTP error: {}", e))?; - resp.into_body() - .read_json() - .map_err(|e| format!("parse response: {}", e)) -} - fn get_text(url: &str) -> Result { let resp = ureq::get(url) .call() @@ -1411,8 +1440,6 @@ struct WalletNetworkProfile { bridge_ticketer: String, #[serde(default, skip_serializing_if = "Option::is_none")] operator_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - operator_bearer_token: Option, source_alias: String, public_account: String, #[serde(default = "default_octez_client_bin")] @@ -1444,7 +1471,6 @@ fn shadownet_profile( rollup_address: String, bridge_ticketer: String, operator_url: Option, - operator_bearer_token: Option, source_alias: String, public_account: Option, octez_client_dir: Option, @@ -1459,7 +1485,6 @@ fn shadownet_profile( rollup_address, bridge_ticketer, operator_url, - operator_bearer_token, public_account: public_account.unwrap_or_else(|| source_alias.clone()), source_alias, octez_client_bin: octez_client_bin.unwrap_or_else(default_octez_client_bin), @@ -1475,41 +1500,11 @@ fn load_network_profile(path: &Path) -> Result { serde_json::from_str(&data).map_err(|e| format!("parse network profile: {}", e)) } -fn validate_network_profile(profile: &WalletNetworkProfile) -> Result<(), String> { - let has_operator_url = profile.operator_url.is_some(); - let has_operator_token = profile - .operator_bearer_token - .as_ref() - .map(|token| !token.trim().is_empty()) - .unwrap_or(false); - match (has_operator_url, has_operator_token) { - (true, false) => { - Err("operator_url requires operator_bearer_token; pass both or neither".into()) - } - (false, true) => { - Err("operator_bearer_token requires operator_url; pass both or neither".into()) - } - _ => Ok(()), - } -} - -fn redacted_network_profile(profile: &WalletNetworkProfile) -> WalletNetworkProfile { - let mut redacted = profile.clone(); - if redacted.operator_bearer_token.is_some() { - redacted.operator_bearer_token = Some("".into()); - } - redacted -} - -fn display_network_profile_json(profile: &WalletNetworkProfile) -> String { - serde_json::to_string_pretty(&redacted_network_profile(profile)).unwrap() -} - fn save_network_profile(path: &Path, profile: &WalletNetworkProfile) -> Result<(), String> { - validate_network_profile(profile)?; let data = serde_json::to_string_pretty(profile).map_err(|e| format!("serialize profile: {}", e))?; - let (tmp, mut file) = create_private_temp_file(path, "network profile")?; + let tmp = PathBuf::from(format!("{}.tmp", path.display())); + let mut file = std::fs::File::create(&tmp).map_err(|e| format!("create profile tmp: {}", e))?; file.write_all(data.as_bytes()) .map_err(|e| format!("write profile tmp: {}", e))?; file.sync_all() @@ -1533,7 +1528,6 @@ fn load_required_network_profile(wallet_path: &str) -> Result RollupRpc<'a> { if let Some(operator_url) = &self.profile.operator_url { return submit_kernel_message_via_operator( operator_url, - self.profile.operator_bearer_token.as_deref(), &self.profile.rollup_address, kernel_message_kind(message), payload, @@ -1969,20 +1962,18 @@ fn kernel_message_kind(message: &KernelInboxMessage) -> RollupSubmissionKind { fn submit_kernel_message_via_operator( operator_url: &str, - operator_bearer_token: Option<&str>, rollup_address: &str, kind: RollupSubmissionKind, payload: Vec, ) -> Result { let base = operator_url.trim_end_matches('/'); - let resp: SubmitRollupMessageResp = post_json_with_bearer( + let resp: SubmitRollupMessageResp = post_json( &format!("{}/v1/rollup/submissions", base), &SubmitRollupMessageReq { kind, rollup_address: rollup_address.to_string(), payload, }, - operator_bearer_token, )?; let submission = resp.submission; Ok(RollupSubmissionReceipt { @@ -2000,14 +1991,10 @@ fn submit_kernel_message_via_operator( fn load_operator_submission( operator_url: &str, - operator_bearer_token: Option<&str>, submission_id: &str, ) -> Result { let base = operator_url.trim_end_matches('/'); - get_json_with_bearer( - &format!("{}/v1/rollup/submissions/{}", base, submission_id), - operator_bearer_token, - ) + get_json(&format!("{}/v1/rollup/submissions/{}", base, submission_id)) } fn format_rollup_submission(submission: &RollupSubmission) -> String { @@ -2289,6 +2276,8 @@ struct ProveConfig { skip_proof: bool, reprove_bin: String, executables_dir: String, + /// If set, delegate proof generation to this HTTP service (POST /prove). + proving_service_url: Option, } impl ProveConfig { @@ -2299,6 +2288,8 @@ impl ProveConfig { "WARNING: Transaction has NO cryptographic guarantee. DO NOT use in production." ); Ok(Proof::TrustMeBro) + } else if let Some(url) = &self.proving_service_url { + generate_proof_http(url, circuit, args) } else { generate_proof(&self.reprove_bin, &self.executables_dir, circuit, args) } @@ -2319,6 +2310,7 @@ fn run(cli: Cli) -> Result<(), String> { skip_proof: cli.trust_me_bro, reprove_bin: cli.reprove_bin, executables_dir: cli.executables_dir, + proving_service_url: None, }; match cli.cmd { Cmd::Keygen => cmd_keygen(&cli.wallet), @@ -2504,10 +2496,8 @@ enum UserProfileCmd { rollup_address: String, #[arg(long)] bridge_ticketer: String, - #[arg(long, requires = "operator_bearer_token")] + #[arg(long)] operator_url: Option, - #[arg(long, requires = "operator_url")] - operator_bearer_token: Option, #[arg(long)] source_alias: String, #[arg(long)] @@ -2658,6 +2648,7 @@ fn run_user(cli: UserCli) -> Result<(), String> { skip_proof: false, reprove_bin: cli.reprove_bin, executables_dir: cli.executables_dir, + proving_service_url: None, }; match cli.cmd { @@ -2734,7 +2725,6 @@ fn run_user_profile(wallet_path: &str, cmd: UserProfileCmd) -> Result<(), String rollup_address, bridge_ticketer, operator_url, - operator_bearer_token, source_alias, public_account, octez_client_dir, @@ -2755,7 +2745,6 @@ fn run_user_profile(wallet_path: &str, cmd: UserProfileCmd) -> Result<(), String rollup_address, bridge_ticketer, operator_url, - operator_bearer_token, source_alias, public_account, octez_client_dir, @@ -2766,12 +2755,12 @@ fn run_user_profile(wallet_path: &str, cmd: UserProfileCmd) -> Result<(), String ); save_network_profile(&path, &profile)?; println!("Saved {} profile to {}", profile.network, path.display()); - println!("{}", display_network_profile_json(&profile)); + println!("{}", serde_json::to_string_pretty(&profile).unwrap()); Ok(()) } UserProfileCmd::Show => { let profile = load_network_profile(&path)?; - println!("{}", display_network_profile_json(&profile)); + println!("{}", serde_json::to_string_pretty(&profile).unwrap()); Ok(()) } } @@ -2864,6 +2853,28 @@ fn generate_proof( }) } +fn generate_proof_http(url: &str, circuit: &str, args: &[String]) -> Result { + eprintln!("Generating proof via proving-service for {}...", circuit); + let bundle: VerifyProofBundle = post_json( + &format!("{}/prove", url), + &serde_json::json!({ "circuit": circuit, "args": args }), + ) + .map_err(|e| format!("proving-service: {}", e))?; + eprintln!( + "Proof received: {} KB, {} outputs", + bundle.proof_bytes.len() / 1024, + bundle.output_preimage.len() + ); + Ok(Proof::Stark { + proof_bytes: bundle.proof_bytes, + output_preimage: bundle.output_preimage, + verify_meta: bundle + .verify_meta + .map(|meta| encode_verify_meta(&meta)) + .transpose()?, + }) +} + fn persist_wallet_and_make_proof( path: &str, w: &WalletFile, @@ -2887,6 +2898,8 @@ fn cmd_keygen(path: &str) -> Result<(), String> { let w = WalletFile { master_sk, + kem_seed_v: vec![], // legacy, unused — keys derived per-address from incoming_seed + kem_seed_d: vec![], addresses: vec![], addr_counter: 0, notes: vec![], @@ -3208,7 +3221,6 @@ mod tests { bridge_ticketer: "KT1Jg4fj5wwnKHuW8aa9uDX6dRYBdjXhm2sJ".into(), public_account: "alice".into(), operator_url: None, - operator_bearer_token: None, source_alias: "alice".into(), octez_client_bin: "octez-client".into(), octez_client_dir: None, @@ -3218,12 +3230,12 @@ mod tests { } } - fn rebuild_address_state(master_sk: &F, j: u32, next_wots_index: u32) -> WalletAddressState { + fn rebuild_address_state(master_sk: &F, j: u32, next_auth_index: u32) -> WalletAddressState { let acc = derive_account(master_sk); let d_j = derive_address(&acc.incoming_seed, j); let ask_j = derive_ask(&acc.ask_base, j); let auth_pub_seed = derive_auth_pub_seed(&ask_j); - let (bds, auth_root) = XmssBdsState::from_index(&ask_j, &auth_pub_seed, next_wots_index) + let (bds, auth_root) = XmssBdsState::from_index(&ask_j, &auth_pub_seed, next_auth_index) .expect("fixture XMSS rebuild should succeed"); let nk_spend = derive_nk_spend(&acc.nk, &d_j); let nk_tag = derive_nk_tag(&nk_spend); @@ -3234,7 +3246,9 @@ mod tests { auth_root, auth_pub_seed, nk_tag, - bds, + bds: Some(bds), + next_auth_index: 0, + next_auth_path: Vec::new(), } } @@ -3286,11 +3300,19 @@ mod tests { xmss_tree_node_hash(pub_seed, height - 1, start_idx >> height, &left, &right) } - pub(super) fn test_wallet(addr_counter: u32) -> WalletFile { + pub(super) fn test_wallet( + addr_counter: u32, + legacy: Option<([u8; 64], [u8; 64])>, + ) -> WalletFile { let base = base_test_wallet(); + let (kem_seed_v, kem_seed_d) = legacy + .map(|(v, d)| (v.to_vec(), d.to_vec())) + .unwrap_or_else(|| (vec![], vec![])); let cached = std::cmp::min(addr_counter as usize, base.addresses.len()); let mut wallet = WalletFile { master_sk: base.master_sk, + kem_seed_v, + kem_seed_d, addresses: base.addresses[..cached].to_vec(), addr_counter, notes: vec![], @@ -3307,7 +3329,7 @@ mod tests { } fn wallet_with_single_note(note_value: u64) -> (WalletFile, F) { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let addr = w.addresses[0].clone(); let acc = w.account(); let nk_sp = derive_nk_spend(&acc.nk, &addr.d_j); @@ -3382,7 +3404,7 @@ mod tests { ) { let total = values.iter().copied().sum::(); let amount = 1 + (total / 2); - let mut w = test_wallet(0); + let mut w = test_wallet(0, None); w.notes = values.iter().enumerate().map(|(i, v)| Note { nk_spend: ZERO, nk_tag: ZERO, @@ -3410,16 +3432,24 @@ mod tests { #[test] fn test_xmss_bds_advances_fixture_state_without_rebuild() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let initial_root = w.addresses[0].auth_root; - let initial_path = w.addresses[0].bds.current_path().to_vec(); + let initial_path = w.addresses[0] + .bds + .as_ref() + .expect("fixture should include BDS state") + .current_path() + .to_vec(); assert_eq!(initial_path.len(), AUTH_DEPTH); let first_idx = w.next_wots_key(0); assert_eq!(first_idx, 0); assert_eq!(w.wots_key_indices.get(&0), Some(&1)); assert_eq!(w.addresses[0].auth_root, initial_root); - let after_first = &w.addresses[0].bds; + let after_first = w.addresses[0] + .bds + .as_ref() + .expect("BDS should remain populated after first advance"); assert_eq!(after_first.next_index, 1); assert_eq!(after_first.current_path().len(), AUTH_DEPTH); assert_ne!(after_first.current_path(), initial_path.as_slice()); @@ -3429,7 +3459,10 @@ mod tests { assert_eq!(second_idx, 1); assert_eq!(w.wots_key_indices.get(&0), Some(&2)); assert_eq!(w.addresses[0].auth_root, initial_root); - let after_second = &w.addresses[0].bds; + let after_second = w.addresses[0] + .bds + .as_ref() + .expect("BDS should remain populated after second advance"); assert_eq!(after_second.next_index, 2); assert_eq!(after_second.current_path().len(), AUTH_DEPTH); assert_ne!(after_second.current_path(), path_after_first.as_slice()); @@ -3463,7 +3496,7 @@ mod tests { #[test] fn test_export_detect_uses_detect_root_not_incoming_seed() { - let w = test_wallet(0); + let w = test_wallet(0, None); let acc = w.account(); let detect_root = derive_detect_root(&acc.incoming_seed); assert_ne!( @@ -3474,7 +3507,7 @@ mod tests { #[test] fn test_view_export_includes_address_metadata() { - let w = test_wallet(2); + let w = test_wallet(2, None); let material = WatchKeyMaterial::from_view_wallet(&w); let WatchKeyMaterial::View { incoming_seed, @@ -3497,7 +3530,7 @@ mod tests { #[test] fn test_detect_material_matches_wallet_note() { - let w = test_wallet(1); + let w = test_wallet(1, None); let material = WatchKeyMaterial::from_detect_wallet(&w); let WatchKeyMaterial::Detect { detect_root, @@ -3517,7 +3550,7 @@ mod tests { #[test] fn test_view_material_recovers_and_validates_note() { - let w = test_wallet(1); + let w = test_wallet(1, None); let material = WatchKeyMaterial::from_view_wallet(&w); let WatchKeyMaterial::View { incoming_seed, @@ -3540,7 +3573,7 @@ mod tests { #[test] fn test_view_material_rejects_wrong_commitment() { - let w = test_wallet(1); + let w = test_wallet(1, None); let material = WatchKeyMaterial::from_view_wallet(&w); let WatchKeyMaterial::View { incoming_seed, @@ -3564,7 +3597,7 @@ mod tests { mut rseed in any::<[u8; 32]>(), ) { rseed[31] &= 0x07; - let w = test_wallet(1); + let w = test_wallet(1, None); let material = WatchKeyMaterial::from_view_wallet(&w); let WatchKeyMaterial::View { incoming_seed, @@ -3634,7 +3667,7 @@ mod tests { #[test] fn test_apply_watch_feed_detect_deduplicates_and_advances_cursor() { - let w = test_wallet(1); + let w = test_wallet(1, None); let mut state = WatchWalletFile::from_material(WatchKeyMaterial::from_detect_wallet(&w)); let note = note_memo_for_wallet_address(&w, 0, 12, felt_tag(b"watch-detect-feed"), None); let feed = NotesFeedResp { @@ -3656,7 +3689,7 @@ mod tests { #[test] fn test_apply_watch_feed_view_tracks_incoming_total() { - let w = test_wallet(1); + let w = test_wallet(1, None); let mut state = WatchWalletFile::from_material(WatchKeyMaterial::from_view_wallet(&w)); let note_1 = note_memo_for_wallet_address(&w, 0, 12, felt_tag(b"watch-view-1"), None); let note_2 = @@ -3682,7 +3715,7 @@ mod tests { #[test] fn test_apply_watch_feed_view_is_idempotent() { - let w = test_wallet(1); + let w = test_wallet(1, None); let mut state = WatchWalletFile::from_material(WatchKeyMaterial::from_view_wallet(&w)); let note = note_memo_for_wallet_address(&w, 0, 27, felt_tag(b"watch-view-repeat"), Some(b"dup")); @@ -3714,7 +3747,7 @@ mod tests { let watch_path_str = watch_path.to_str().unwrap(); let material_path_str = material_path.to_str().unwrap(); - let w = test_wallet(2); + let w = test_wallet(2, None); save_wallet(wallet_path_str, &w).expect("save wallet"); cmd_export_view(wallet_path_str, Some(material_path_str)).expect("export view"); cmd_watch_init(watch_path_str, material_path_str, false).expect("init watch wallet"); @@ -3748,7 +3781,7 @@ mod tests { let watch_path_str = watch_path.to_str().unwrap(); let material_path_str = material_path.to_str().unwrap(); - let w = test_wallet(3); + let w = test_wallet(3, None); save_wallet(wallet_path_str, &w).expect("save wallet"); cmd_export_detect(wallet_path_str, Some(material_path_str)).expect("export detect"); cmd_watch_init(watch_path_str, material_path_str, false).expect("init watch wallet"); @@ -3784,7 +3817,7 @@ mod tests { let view_path_str = view_path.to_str().unwrap(); let detect_path_str = detect_path.to_str().unwrap(); - let w = test_wallet(2); + let w = test_wallet(2, None); save_wallet(wallet_path_str, &w).expect("save wallet"); cmd_export_view(wallet_path_str, Some(view_path_str)).expect("export view"); cmd_export_detect(wallet_path_str, Some(detect_path_str)).expect("export detect"); @@ -3811,7 +3844,7 @@ mod tests { let watch_path_str = watch_path.to_str().unwrap(); let material_path_str = material_path.to_str().unwrap(); - let w = test_wallet(1); + let w = test_wallet(1, None); let note = note_memo_for_wallet_address(&w, 0, 91, felt_tag(b"watch-service-view"), Some(b"hi")); let encoded = canonical_wire::encode_published_note(¬e.cm, ¬e.enc) @@ -3876,7 +3909,7 @@ mod tests { let watch_path_str = watch_path.to_str().unwrap(); let material_path_str = material_path.to_str().unwrap(); - let w = test_wallet(1); + let w = test_wallet(1, None); let note = note_memo_for_wallet_address(&w, 0, 73, felt_tag(b"watch-service-detect"), None); let encoded = canonical_wire::encode_published_note(¬e.cm, ¬e.enc) .expect("published note should encode"); @@ -3935,7 +3968,7 @@ mod tests { let watch_path_str = watch_path.to_str().unwrap(); let material_path_str = material_path.to_str().unwrap(); - let w = test_wallet(1); + let w = test_wallet(1, None); save_wallet(wallet_path_str, &w).expect("save wallet"); cmd_export_view(wallet_path_str, Some(material_path_str)).expect("export view"); cmd_watch_init(watch_path_str, material_path_str, false).expect("init watch wallet"); @@ -3956,15 +3989,48 @@ mod tests { let wallet_path = dir.path().join("wallet.json"); let wallet_path_str = wallet_path.to_str().unwrap(); - save_wallet(wallet_path_str, &test_wallet(1)).expect("save private wallet"); + save_wallet(wallet_path_str, &test_wallet(1, None)).expect("save private wallet"); let err = validate_detection_service_wallet(wallet_path_str) .expect_err("private spending wallet must not validate as watch wallet"); assert!(err.contains("parse watch wallet")); } + #[test] + fn test_legacy_kem_keys_absent_for_migrated_wallet() { + let w = test_wallet(0, None); + assert!(w.legacy_kem_keys().is_none()); + } + + #[test] + fn test_legacy_kem_keys_are_recovered_from_seed_material() { + let legacy_v = [0x33; 64]; + let legacy_d = [0x44; 64]; + let w = test_wallet(0, Some((legacy_v, legacy_d))); + let (ek_v1, dk_v1, ek_d1, dk_d1) = w.legacy_kem_keys().expect("legacy keys"); + let (ek_v2, dk_v2) = kem_keygen_from_seed(&legacy_v); + let (ek_d2, dk_d2) = kem_keygen_from_seed(&legacy_d); + + assert_eq!(ek_v1.to_bytes(), ek_v2.to_bytes()); + assert_eq!(dk_v1.to_bytes(), dk_v2.to_bytes()); + assert_eq!(ek_d1.to_bytes(), ek_d2.to_bytes()); + assert_eq!(dk_d1.to_bytes(), dk_d2.to_bytes()); + } + + #[test] + fn test_legacy_kem_keys_reject_incomplete_seed_material() { + let mut w = test_wallet(0, None); + w.kem_seed_v = vec![0x11; 63]; + w.kem_seed_d = vec![0x22; 64]; + + assert!( + w.legacy_kem_keys().is_none(), + "legacy KEM recovery must reject malformed seed lengths" + ); + } + #[test] fn test_per_address_kem_keys_are_deterministic_and_distinct() { - let w = test_wallet(0); + let w = test_wallet(0, None); let (ek_v0_a, dk_v0_a, ek_d0_a, dk_d0_a) = w.kem_keys(0); let (ek_v0_b, dk_v0_b, ek_d0_b, dk_d0_b) = w.kem_keys(0); let (ek_v1, dk_v1, ek_d1, dk_d1) = w.kem_keys(1); @@ -3981,7 +4047,7 @@ mod tests { #[test] fn test_try_recover_note_new_per_address_wallet() { - let w = test_wallet(1); + let w = test_wallet(1, None); let acc = w.account(); let addr = &w.addresses[0]; let nk_sp = derive_nk_spend(&acc.nk, &addr.d_j); @@ -4003,9 +4069,50 @@ mod tests { assert_eq!(note.cm, cm); } + #[test] + fn test_try_recover_note_legacy_wallet() { + let legacy_v = [0x11; 64]; + let legacy_d = [0x22; 64]; + let w = test_wallet(1, Some((legacy_v, legacy_d))); + let acc = w.account(); + let addr = &w.addresses[0]; + let nk_sp = derive_nk_spend(&acc.nk, &addr.d_j); + let nk_tg = derive_nk_tag(&nk_sp); + let otag = owner_tag(&addr.auth_root, &addr.auth_pub_seed, &nk_tg); + let rseed = random_felt(); + let rcm = derive_rcm(&rseed); + let cm = commit(&addr.d_j, 91, &rcm, &otag); + let (ek_v, _dk_v, ek_d, _dk_d) = w.legacy_kem_keys().expect("legacy keys"); + let enc = encrypt_note(91, &rseed, Some(b"legacy"), &ek_v, &ek_d); + let nm = NoteMemo { index: 2, cm, enc }; + + let note = w.try_recover_note(&nm).expect("legacy note should recover"); + assert_eq!(note.index, 2); + assert_eq!(note.addr_index, 0); + assert_eq!(note.v, 91); + assert_eq!(note.cm, cm); + } + + #[test] + fn test_try_recover_note_migrated_wallet_accepts_per_address_notes_even_with_legacy_seeds() { + let legacy_v = [0x21; 64]; + let legacy_d = [0x43; 64]; + let w = test_wallet(1, Some((legacy_v, legacy_d))); + let rseed = felt_tag(b"wallet-note-per-address-with-legacy"); + let nm = note_memo_for_wallet_address(&w, 0, 41, rseed, None); + + let note = w + .try_recover_note(&nm) + .expect("migrated wallet should still recover per-address notes"); + + assert_eq!(note.addr_index, 0); + assert_eq!(note.v, 41); + assert_eq!(note.cm, nm.cm); + } + #[test] fn test_try_recover_note_rejects_phantom_note_with_wrong_commitment() { - let w = test_wallet(1); + let w = test_wallet(1, None); let mut rseed = ZERO; rseed[0] = 0x55; let mut nm = note_memo_for_wallet_address(&w, 0, 77, rseed, Some(b"phantom")); @@ -4018,7 +4125,7 @@ mod tests { #[test] fn test_try_recover_note_rejects_wrong_owner_metadata_even_with_valid_decryption() { - let w = test_wallet(1); + let w = test_wallet(1, None); let d_j = w.addresses[0].d_j; let (ek_v, _, ek_d, _) = w.kem_keys(0); let mut other_master_sk = ZERO; @@ -4051,7 +4158,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let wallet_path = dir.path().join("wallet.json"); let wallet_path_str = wallet_path.to_str().unwrap(); - let w = test_wallet(1); + let w = test_wallet(1, None); save_wallet(wallet_path_str, &w).expect("wallet should save"); let loaded = load_wallet(wallet_path_str).expect("wallet should load"); @@ -4066,7 +4173,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let wallet_path = dir.path().join("wallet.json"); let wallet_path_str = wallet_path.to_str().unwrap(); - let w = test_wallet(1); + let w = test_wallet(1, None); save_wallet(wallet_path_str, &w).expect("wallet should save"); @@ -4079,7 +4186,7 @@ mod tests { assert_eq!(floor.addr_counter, w.addr_counter); assert_eq!( floor.wots_key_indices.get(&0), - Some(&w.addresses[0].bds.next_index) + Some(&w.addresses[0].bds.as_ref().unwrap().next_index) ); } @@ -4090,7 +4197,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let wallet_path = dir.path().join("wallet.json"); - let w = test_wallet(1); + let w = test_wallet(1, None); save_wallet(wallet_path.to_str().unwrap(), &w).expect("wallet should save"); @@ -4102,20 +4209,6 @@ mod tests { assert_eq!(mode, 0o600); } - #[cfg(unix)] - #[test] - fn test_private_temp_files_start_with_private_mode() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let wallet_path = dir.path().join("wallet.json"); - let (tmp_path, _file) = - create_private_temp_file(&wallet_path, "wallet").expect("create private temp file"); - - let mode = std::fs::metadata(&tmp_path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - } - #[test] fn test_load_wallet_rejects_invalid_json() { let dir = tempfile::tempdir().unwrap(); @@ -4147,7 +4240,7 @@ mod tests { let wallet_path_str = wallet_path.to_str().unwrap(); let backup_path = dir.path().join("wallet-backup.json"); - let original = test_wallet(1); + let original = test_wallet(1, None); save_wallet(wallet_path_str, &original).expect("save original wallet"); std::fs::copy(&wallet_path, &backup_path).expect("copy backup"); @@ -4240,7 +4333,7 @@ mod tests { #[test] fn test_next_address_derivation_is_isolated_per_index() { - let w = test_wallet(3); + let w = test_wallet(3, None); let state0 = w.addresses[0].clone(); let (ek_v0, _, ek_d0, _) = w.kem_keys(state0.index); let state1 = w.addresses[1].clone(); @@ -4259,6 +4352,8 @@ mod tests { let base = base_test_wallet(); let mut wallet = WalletFile { master_sk: base.master_sk, + kem_seed_v: vec![], + kem_seed_d: vec![], addresses: base.addresses[..2].to_vec(), addr_counter: 0, notes: vec![], @@ -4283,6 +4378,8 @@ mod tests { let base = base_test_wallet(); let mut wallet = WalletFile { master_sk: base.master_sk, + kem_seed_v: vec![], + kem_seed_d: vec![], addresses: base.addresses[..2].to_vec(), addr_counter: 2, notes: vec![], @@ -4300,7 +4397,7 @@ mod tests { #[test] fn test_materialize_addresses_refreshes_wots_index_after_fixture_state_advance() { - let mut wallet = test_wallet(1); + let mut wallet = test_wallet(1, None); assert_eq!(wallet.next_wots_key(0), 0); wallet.wots_key_indices.clear(); @@ -4311,6 +4408,133 @@ mod tests { assert_eq!(wallet.wots_key_indices.get(&0), Some(&1)); } + #[test] + fn test_ensure_bds_rebuild_clears_legacy_fields_small_depth() { + let acc = derive_account(&felt_tag(b"wallet-small-bds")); + let d_j = derive_address(&acc.incoming_seed, 0); + let ask_j = derive_ask(&acc.ask_base, 0); + let auth_pub_seed = derive_auth_pub_seed(&ask_j); + let next_auth_index = 5; + let (rebuilt_state, rebuilt_root) = + XmssBdsState::from_index_with_params(&ask_j, &auth_pub_seed, next_auth_index, 6, 2) + .expect("small-depth BDS state should rebuild"); + + let mut addr = WalletAddressState { + index: 0, + d_j, + auth_root: rebuilt_root, + auth_pub_seed, + nk_tag: derive_nk_tag(&derive_nk_spend(&acc.nk, &d_j)), + bds: None, + next_auth_index, + next_auth_path: rebuilt_state.current_path().to_vec(), + }; + + addr.ensure_bds_with(&ask_j, |_, _, idx| { + XmssBdsState::from_index_with_params(&ask_j, &auth_pub_seed, idx, 6, 2) + }) + .expect("legacy small-depth address state should rebuild"); + + let restored = addr.bds.as_ref().expect("BDS state should be restored"); + assert_eq!(restored.next_index, rebuilt_state.next_index); + assert_eq!(restored.current_path(), rebuilt_state.current_path()); + assert_eq!(addr.next_auth_index, 0); + assert!(addr.next_auth_path.is_empty()); + } + + #[test] + fn test_ensure_bds_with_rejects_root_mismatch() { + let acc = derive_account(&felt_tag(b"wallet-small-root-mismatch")); + let d_j = derive_address(&acc.incoming_seed, 0); + let ask_j = derive_ask(&acc.ask_base, 0); + let auth_pub_seed = derive_auth_pub_seed(&ask_j); + let next_auth_index = 3; + let (rebuilt_state, rebuilt_root) = + XmssBdsState::from_index_with_params(&ask_j, &auth_pub_seed, next_auth_index, 6, 2) + .expect("small-depth BDS state should rebuild"); + let mut wrong_root = rebuilt_root; + wrong_root[0] ^= 0x01; + + let mut addr = WalletAddressState { + index: 0, + d_j, + auth_root: wrong_root, + auth_pub_seed, + nk_tag: derive_nk_tag(&derive_nk_spend(&acc.nk, &d_j)), + bds: None, + next_auth_index, + next_auth_path: rebuilt_state.current_path().to_vec(), + }; + + let err = addr + .ensure_bds_with(&ask_j, |_, _, idx| { + XmssBdsState::from_index_with_params(&ask_j, &auth_pub_seed, idx, 6, 2) + }) + .expect_err("root mismatch should be rejected"); + assert!(err.contains("rebuilt XMSS root mismatch")); + assert!(addr.bds.is_none()); + assert_eq!(addr.next_auth_index, next_auth_index); + assert_eq!(addr.next_auth_path, rebuilt_state.current_path().to_vec()); + } + + #[test] + fn test_ensure_bds_with_rejects_path_mismatch() { + let acc = derive_account(&felt_tag(b"wallet-small-path-mismatch")); + let d_j = derive_address(&acc.incoming_seed, 0); + let ask_j = derive_ask(&acc.ask_base, 0); + let auth_pub_seed = derive_auth_pub_seed(&ask_j); + let next_auth_index = 4; + let (rebuilt_state, rebuilt_root) = + XmssBdsState::from_index_with_params(&ask_j, &auth_pub_seed, next_auth_index, 6, 2) + .expect("small-depth BDS state should rebuild"); + let mut wrong_path = rebuilt_state.current_path().to_vec(); + wrong_path[0][0] ^= 0x01; + + let mut addr = WalletAddressState { + index: 0, + d_j, + auth_root: rebuilt_root, + auth_pub_seed, + nk_tag: derive_nk_tag(&derive_nk_spend(&acc.nk, &d_j)), + bds: None, + next_auth_index, + next_auth_path: wrong_path, + }; + + let err = addr + .ensure_bds_with(&ask_j, |_, _, idx| { + XmssBdsState::from_index_with_params(&ask_j, &auth_pub_seed, idx, 6, 2) + }) + .expect_err("path mismatch should be rejected"); + assert!(err.contains("rebuilt XMSS path mismatch")); + assert!(addr.bds.is_none()); + assert_eq!(addr.next_auth_index, next_auth_index); + } + + #[test] + fn test_ensure_bds_with_short_circuits_when_state_is_already_present() { + let ask_j = felt_tag(b"wallet-ensure-bds-ignored"); + let mut addr = test_wallet(1, None).addresses[0].clone(); + let original = addr + .bds + .clone() + .expect("fixture address should include BDS"); + let stale_path = vec![felt_tag(b"stale-legacy-path")]; + addr.next_auth_index = 99; + addr.next_auth_path = stale_path.clone(); + + addr.ensure_bds_with(&ask_j, |_, _, _| -> Result<(XmssBdsState, F), String> { + panic!("ensure_bds_with should not rebuild when BDS state is already populated"); + }) + .expect("existing BDS state should short-circuit"); + + let restored = addr.bds.as_ref().expect("BDS state should remain present"); + assert_eq!(restored.next_index, original.next_index); + assert_eq!(restored.current_path(), original.current_path()); + assert_eq!(addr.next_auth_index, 99); + assert_eq!(addr.next_auth_path, stale_path); + } + fn recompute_xmss_root_from_path(leaf: F, key_idx: u32, pub_seed: &F, siblings: &[F]) -> F { let mut current = leaf; let mut idx = key_idx; @@ -4440,7 +4664,7 @@ mod tests { #[test] fn test_reserve_next_auth_returns_path_bound_to_auth_root() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let acc = w.account(); let ask_j = derive_ask(&acc.ask_base, 0); let msg_hash = felt_tag(b"wallet-reserve-auth"); @@ -4459,7 +4683,7 @@ mod tests { #[test] fn test_reserve_next_auth_rejects_missing_address() { - let mut w = test_wallet(0); + let mut w = test_wallet(0, None); let err = w .reserve_next_auth(0) .expect_err("missing address record should error"); @@ -4468,14 +4692,14 @@ mod tests { #[test] fn test_reserve_next_auth_rejects_exhausted_tree() { - let mut w = test_wallet(1); - w.addresses[0].bds = XmssBdsState { + let mut w = test_wallet(1, None); + w.addresses[0].bds = Some(XmssBdsState { next_index: AUTH_TREE_SIZE as u32, auth_path: vec![], keep: vec![], treehash: vec![], retain: vec![], - }; + }); let err = w .reserve_next_auth(0) @@ -4485,7 +4709,7 @@ mod tests { #[test] fn test_next_wots_key_is_monotonic() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); assert_eq!(w.next_wots_key(0), 0); assert_eq!(w.next_wots_key(0), 1); assert_eq!(w.next_wots_key(0), 2); @@ -4493,7 +4717,7 @@ mod tests { #[test] fn test_select_notes_rejects_insufficient_funds() { - let mut w = test_wallet(0); + let mut w = test_wallet(0, None); w.notes = vec![ Note { nk_spend: ZERO, @@ -4525,7 +4749,7 @@ mod tests { #[test] fn test_select_notes_prefers_single_large_note_when_sufficient() { - let mut w = test_wallet(0); + let mut w = test_wallet(0, None); w.notes = vec![ Note { nk_spend: ZERO, @@ -4557,7 +4781,7 @@ mod tests { #[test] fn test_select_notes_skips_pending_spends() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let note_0 = wallet_note_for_address(&w, 0, 40, felt_tag(b"pending-note-0"), 0); let note_1 = wallet_note_for_address(&w, 0, 25, felt_tag(b"pending-note-1"), 1); let pending_nf = note_nullifier(¬e_0); @@ -4583,7 +4807,7 @@ mod tests { #[test] fn test_apply_scan_feed_deduplicates_new_notes_and_prunes_spent_ones() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let existing = wallet_note_for_address(&w, 0, 40, felt_tag(b"wallet-scan-existing"), 5); let spent_nf = nullifier(&existing.nk_spend, &existing.cm, existing.index as u64); w.notes.push(existing.clone()); @@ -4639,7 +4863,7 @@ mod tests { #[test] fn test_apply_scan_feed_clears_confirmed_pending_spends() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let existing = wallet_note_for_address(&w, 0, 40, felt_tag(b"wallet-scan-pending"), 5); let spent_nf = note_nullifier(&existing); w.notes.push(existing); @@ -4660,7 +4884,7 @@ mod tests { #[test] fn test_apply_scan_feed_drops_newly_recovered_note_if_already_nullified() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let new_rseed = felt_tag(b"wallet-scan-new-spent"); let new_nm = note_memo_for_wallet_address(&w, 0, 19, new_rseed, None); let recovered = w @@ -4691,9 +4915,9 @@ mod tests { #[test] fn test_next_wots_key_exhausts_at_last_leaf() { - let mut w = test_wallet(1); + let mut w = test_wallet(1, None); let last_idx = (AUTH_TREE_SIZE - 1) as u32; - w.addresses[0].bds = XmssBdsState { + w.addresses[0].bds = Some(XmssBdsState { next_index: last_idx, auth_path: vec![ZERO; AUTH_DEPTH], keep: vec![FeltSlot::none(); AUTH_DEPTH], @@ -4701,7 +4925,9 @@ mod tests { .map(TreeHashState::new) .collect(), retain: vec![RetainLevel::default(); AUTH_DEPTH], - }; + }); + w.addresses[0].next_auth_index = 0; + w.addresses[0].next_auth_path.clear(); w.wots_key_indices.insert(0, last_idx); assert_eq!(w.next_wots_key(0), last_idx); let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -4718,7 +4944,7 @@ mod tests { let recipient_path = dir.path().join("recipient.json"); let recipient_path_str = recipient_path.to_str().unwrap(); - let mut w = test_wallet(2); + let mut w = test_wallet(2, None); w.addr_counter = 1; w.notes = vec![ wallet_note_for_address(&w, 0, 40, felt_tag(b"wallet-transfer-note-0"), 7), @@ -4816,7 +5042,7 @@ mod tests { let wallet_path = dir.path().join("wallet.json"); let wallet_path_str = wallet_path.to_str().unwrap(); - let mut w = test_wallet(2); + let mut w = test_wallet(2, None); w.addr_counter = 1; w.notes = vec![ wallet_note_for_address(&w, 0, 35, felt_tag(b"wallet-unshield-note-0"), 5), @@ -4893,7 +5119,7 @@ mod tests { let (mut w, cm) = wallet_with_single_note(50); w.addr_counter = 2; - w.addresses = test_wallet(2).addresses; + w.addresses = test_wallet(2, None).addresses; save_wallet(wallet_path_str, &w).expect("wallet should save"); let mut loaded = load_wallet(wallet_path_str).expect("wallet should reload"); let _key_idx = loaded.next_wots_key(0); @@ -4934,7 +5160,7 @@ mod tests { let (mut w, cm) = wallet_with_single_note(50); w.addr_counter = 2; - w.addresses = test_wallet(2).addresses; + w.addresses = test_wallet(2, None).addresses; save_wallet(wallet_path_str, &w).expect("wallet should save"); let mut loaded = load_wallet(wallet_path_str).expect("wallet should reload"); let _key_idx = loaded.next_wots_key(0); @@ -5177,11 +5403,7 @@ fn cmd_operator_status(profile: &WalletNetworkProfile, submission_id: &str) -> R .operator_url .as_deref() .ok_or_else(|| "this wallet profile has no operator_url configured".to_string())?; - let resp = load_operator_submission( - operator_url, - profile.operator_bearer_token.as_deref(), - submission_id, - )?; + let resp = load_operator_submission(operator_url, submission_id)?; println!("{}", format_rollup_submission(&resp.submission)); Ok(()) } @@ -6384,6 +6606,220 @@ fn prepare_unshield_skip_proof( }) } +fn prepare_transfer_with_proof( + w: &mut WalletFile, + path: &str, + ledger: &str, + root: F, + recipient: &PaymentAddress, + amount: u64, + memo: Option<&str>, + pc: &ProveConfig, +) -> Result { + let selected = w.select_notes(amount)?; + let sum_in: u128 = selected.iter().map(|&i| w.notes[i].v as u128).sum(); + let change = (sum_in - amount as u128) as u64; + let nullifiers: Vec = selected + .iter() + .map(|&i| { let n = &w.notes[i]; nullifier(&n.nk_spend, &n.cm, n.index as u64) }) + .collect(); + + let rseed_1 = random_felt(); + let rcm_1 = derive_rcm(&rseed_1); + let ek_v_recv = ml_kem::ml_kem_768::EncapsulationKey::new( + recipient.ek_v.as_slice().try_into().map_err(|_| "bad ek_v")?, + ).map_err(|_| "invalid ek_v")?; + let ek_d_recv = ml_kem::ml_kem_768::EncapsulationKey::new( + recipient.ek_d.as_slice().try_into().map_err(|_| "bad ek_d")?, + ).map_err(|_| "invalid ek_d")?; + let otag_1 = owner_tag(&recipient.auth_root, &recipient.auth_pub_seed, &recipient.nk_tag); + let cm_1 = commit(&recipient.d_j, amount, &rcm_1, &otag_1); + let memo_bytes = memo.map(str::as_bytes); + let enc_1 = encrypt_note(amount, &rseed_1, memo_bytes, &ek_v_recv, &ek_d_recv); + + let (change_state, _) = w.next_address()?; + let (ek_v_c, _, ek_d_c, _) = w.kem_keys(change_state.index); + let rseed_2 = random_felt(); + let rcm_2 = derive_rcm(&rseed_2); + let otag_2 = owner_tag(&change_state.auth_root, &change_state.auth_pub_seed, &change_state.nk_tag); + let cm_2 = commit(&change_state.d_j, change, &rcm_2, &otag_2); + let enc_2 = encrypt_note(change, &rseed_2, None, &ek_v_c, &ek_d_c); + + let cfg: ConfigResp = get_json(&format!("{}/config", ledger))?; + let auth_domain = cfg.auth_domain; + let n = selected.len(); + let mut cm_paths: Vec> = vec![]; + let mut auth_paths: Vec> = vec![]; + let mut wots_sigs: Vec> = vec![]; + let mut auth_pub_seeds: Vec = vec![]; + let mut wots_key_indices: Vec = vec![]; + + let nfs_for_sh: Vec = selected + .iter() + .map(|&i| { let n = &w.notes[i]; nullifier(&n.nk_spend, &n.cm, n.index as u64) }) + .collect(); + let mh_1 = memo_ct_hash(&enc_1); + let mh_2 = memo_ct_hash(&enc_2); + let sighash = transfer_sighash(&auth_domain, &root, &nfs_for_sh, &cm_1, &cm_2, &mh_1, &mh_2); + + let selected_notes: Vec<(usize, u32, F)> = selected + .iter() + .map(|&i| (w.notes[i].index, w.notes[i].addr_index, w.notes[i].auth_root)) + .collect(); + for &(tree_idx, addr_idx, stored_auth_root) in &selected_notes { + let path_resp: MerklePathResp = get_json(&format!("{}/tree/path/{}", ledger, tree_idx))?; + ensure_path_matches_root(&path_resp.root, &root, tree_idx)?; + cm_paths.push(path_resp.siblings); + let ask_j = derive_ask(&w.account().ask_base, addr_idx); + let (key_idx, auth_root, auth_pub_seed, apath) = w.reserve_next_auth(addr_idx)?; + if auth_root != stored_auth_root { + return Err(format!("auth_root mismatch for note at tree index {}", tree_idx)); + } + auth_paths.push(apath); + auth_pub_seeds.push(auth_pub_seed); + let (sig, _, _) = wots_sign(&ask_j, key_idx, &sighash); + wots_sigs.push(sig); + wots_key_indices.push(key_idx); + } + + let total_fields = 3 + 9 * n + n * DEPTH + n * AUTH_DEPTH + n * WOTS_CHAINS + 16; + let mut args: Vec = vec![ + felt_u64_to_hex(total_fields as u64), + felt_u64_to_hex(n as u64), + felt_to_hex(&auth_domain), + felt_to_hex(&root), + ]; + for (idx, &si) in selected.iter().enumerate() { + let note = &w.notes[si]; + let nf = nullifier(¬e.nk_spend, ¬e.cm, note.index as u64); + args.extend([ + felt_to_hex(&nf), felt_to_hex(¬e.nk_spend), felt_to_hex(¬e.auth_root), + felt_to_hex(&auth_pub_seeds[idx]), felt_u64_to_hex(wots_key_indices[idx] as u64), + felt_to_hex(¬e.d_j), felt_u64_to_hex(note.v), + felt_to_hex(¬e.rseed), felt_u64_to_hex(note.index as u64), + ]); + } + for path in &cm_paths { for s in path { args.push(felt_to_hex(s)); } } + for path in &auth_paths { for s in path { args.push(felt_to_hex(s)); } } + for sig in &wots_sigs { for s in sig { args.push(felt_to_hex(s)); } } + args.extend([ + felt_to_hex(&cm_1), felt_to_hex(&recipient.d_j), felt_u64_to_hex(amount), + felt_to_hex(&rseed_1), felt_to_hex(&recipient.auth_root), + felt_to_hex(&recipient.auth_pub_seed), felt_to_hex(&recipient.nk_tag), + felt_to_hex(&mh_1), + felt_to_hex(&cm_2), felt_to_hex(&change_state.d_j), felt_u64_to_hex(change), + felt_to_hex(&rseed_2), felt_to_hex(&change_state.auth_root), + felt_to_hex(&change_state.auth_pub_seed), felt_to_hex(&change_state.nk_tag), + felt_to_hex(&mh_2), + ]); + + let proof = persist_wallet_and_make_proof(path, w, pc, "run_transfer", &args)?; + Ok(PreparedTransferSubmit { selected, change, req: TransferReq { root, nullifiers, cm_1, cm_2, enc_1, enc_2, proof } }) +} + +fn prepare_unshield_with_proof( + w: &mut WalletFile, + path: &str, + ledger: &str, + root: F, + amount: u64, + recipient: &str, + pc: &ProveConfig, +) -> Result { + let selected = w.select_notes(amount)?; + let sum_in: u128 = selected.iter().map(|&i| w.notes[i].v as u128).sum(); + let change = (sum_in - amount as u128) as u64; + let nullifiers: Vec = selected + .iter() + .map(|&i| { let n = &w.notes[i]; nullifier(&n.nk_spend, &n.cm, n.index as u64) }) + .collect(); + + let (cm_change, enc_change, change_data) = if change > 0 { + let (change_state, _) = w.next_address()?; + let (ek_v_c, _, ek_d_c, _) = w.kem_keys(change_state.index); + let rseed_c = random_felt(); + let rcm_c = derive_rcm(&rseed_c); + let otag_c = owner_tag(&change_state.auth_root, &change_state.auth_pub_seed, &change_state.nk_tag); + let cm = commit(&change_state.d_j, change, &rcm_c, &otag_c); + let enc = encrypt_note(change, &rseed_c, None, &ek_v_c, &ek_d_c); + let mh = memo_ct_hash(&enc); + let cd = ChangeData { d_j: change_state.d_j, rseed: rseed_c, auth_root: change_state.auth_root, auth_pub_seed: change_state.auth_pub_seed, nk_tag: change_state.nk_tag, mh }; + (cm, Some(enc), Some(cd)) + } else { + (ZERO, None, None) + }; + + let cfg: ConfigResp = get_json(&format!("{}/config", ledger))?; + let auth_domain = cfg.auth_domain; + let n = selected.len(); + let mut cm_paths: Vec> = vec![]; + let mut auth_paths: Vec> = vec![]; + let mut wots_sigs: Vec> = vec![]; + let mut auth_pub_seeds: Vec = vec![]; + let mut wots_key_indices: Vec = vec![]; + + let recipient_f = hash(recipient.as_bytes()); + let mh_change_f = change_data.as_ref().map(|cd| cd.mh).unwrap_or(ZERO); + let nfs_for_sh: Vec = selected + .iter() + .map(|&i| { let n = &w.notes[i]; nullifier(&n.nk_spend, &n.cm, n.index as u64) }) + .collect(); + let sighash = unshield_sighash(&auth_domain, &root, &nfs_for_sh, amount, &recipient_f, &cm_change, &mh_change_f); + + let selected_notes: Vec<(usize, u32, F)> = selected + .iter() + .map(|&i| (w.notes[i].index, w.notes[i].addr_index, w.notes[i].auth_root)) + .collect(); + for &(tree_idx, addr_idx, stored_auth_root) in &selected_notes { + let path_resp: MerklePathResp = get_json(&format!("{}/tree/path/{}", ledger, tree_idx))?; + ensure_path_matches_root(&path_resp.root, &root, tree_idx)?; + cm_paths.push(path_resp.siblings); + let ask_j = derive_ask(&w.account().ask_base, addr_idx); + let (key_idx, auth_root, auth_pub_seed, apath) = w.reserve_next_auth(addr_idx)?; + if auth_root != stored_auth_root { + return Err(format!("auth_root mismatch for note at tree index {}", tree_idx)); + } + auth_paths.push(apath); + auth_pub_seeds.push(auth_pub_seed); + let (sig, _, _) = wots_sign(&ask_j, key_idx, &sighash); + wots_sigs.push(sig); + wots_key_indices.push(key_idx); + } + + let total = 5 + 9 * n + n * DEPTH + n * AUTH_DEPTH + n * WOTS_CHAINS + 8; + let mut args: Vec = vec![ + felt_u64_to_hex(total as u64), felt_u64_to_hex(n as u64), + felt_to_hex(&auth_domain), felt_to_hex(&root), + felt_u64_to_hex(amount), felt_to_hex(&recipient_f), + ]; + for (idx, &si) in selected.iter().enumerate() { + let note = &w.notes[si]; + let nf = nullifier(¬e.nk_spend, ¬e.cm, note.index as u64); + args.extend([ + felt_to_hex(&nf), felt_to_hex(¬e.nk_spend), felt_to_hex(¬e.auth_root), + felt_to_hex(&auth_pub_seeds[idx]), felt_u64_to_hex(wots_key_indices[idx] as u64), + felt_to_hex(¬e.d_j), felt_u64_to_hex(note.v), + felt_to_hex(¬e.rseed), felt_u64_to_hex(note.index as u64), + ]); + } + for path in &cm_paths { for s in path { args.push(felt_to_hex(s)); } } + for path in &auth_paths { for s in path { args.push(felt_to_hex(s)); } } + for sig in &wots_sigs { for s in sig { args.push(felt_to_hex(s)); } } + args.push(felt_u64_to_hex(if change > 0 { 1 } else { 0 })); + if let Some(cd) = &change_data { + args.extend([ + felt_to_hex(&cd.d_j), felt_u64_to_hex(change), felt_to_hex(&cd.rseed), + felt_to_hex(&cd.auth_root), felt_to_hex(&cd.auth_pub_seed), + felt_to_hex(&cd.nk_tag), felt_to_hex(&cd.mh), + ]); + } else { + args.extend(std::iter::repeat("0x0".to_string()).take(7)); + } + + let proof = persist_wallet_and_make_proof(path, w, pc, "run_unshield", &args)?; + Ok(PreparedUnshieldSubmit { selected, change, req: UnshieldReq { root, nullifiers, v_pub: amount, recipient: recipient.into(), cm_change, enc_change, proof } }) +} + fn finalize_successful_spend( path: &str, w: &mut WalletFile, @@ -6422,7 +6858,6 @@ mod network_profile_tests { "sr1ExampleRollup".into(), "KT1ExampleTicketer".into(), Some("https://operator.shadownet.example".into()), - Some("operator-secret".into()), "alice".into(), Some("tz1alicepublicaccount".into()), Some("/tmp/octez-client".into()), @@ -6448,7 +6883,6 @@ mod network_profile_tests { "sr1SavedRollup".into(), "KT1SavedTicketer".into(), None, - None, "bootstrap1".into(), None, None, @@ -6464,110 +6898,6 @@ mod network_profile_tests { assert_eq!(loaded.public_account, "bootstrap1"); } - #[test] - fn save_network_profile_rejects_operator_url_without_token() { - let dir = tempfile::tempdir().expect("tempdir"); - let profile_path = dir.path().join("wallet.network.json"); - let profile = shadownet_profile( - "https://saved-rollup.example".into(), - "sr1SavedRollup".into(), - "KT1SavedTicketer".into(), - Some("https://operator.shadownet.example".into()), - None, - "bootstrap1".into(), - None, - None, - None, - None, - None, - None, - ); - - let err = save_network_profile(&profile_path, &profile).unwrap_err(); - assert!(err.contains("operator_url requires operator_bearer_token")); - } - - #[test] - fn load_required_network_profile_rejects_saved_operator_url_without_token() { - let dir = tempfile::tempdir().expect("tempdir"); - let wallet_path = dir.path().join("wallet.json"); - let wallet_path_str = wallet_path.to_str().unwrap(); - let profile_path = default_network_profile_path(wallet_path_str); - let bad_profile = serde_json::json!({ - "network": "shadownet", - "rollup_node_url": "https://saved-rollup.example", - "rollup_address": "sr1SavedRollup", - "bridge_ticketer": "KT1SavedTicketer", - "operator_url": "https://operator.shadownet.example", - "source_alias": "bootstrap1", - "public_account": "bootstrap1", - "octez_client_bin": "octez-client", - "burn_cap": "1" - }); - std::fs::write( - &profile_path, - serde_json::to_string_pretty(&bad_profile).unwrap(), - ) - .expect("write bad profile"); - - let err = load_required_network_profile(wallet_path_str).unwrap_err(); - assert!(err.contains("operator_url requires operator_bearer_token")); - } - - #[test] - fn display_network_profile_redacts_operator_bearer_token() { - let profile = shadownet_profile( - "https://saved-rollup.example".into(), - "sr1SavedRollup".into(), - "KT1SavedTicketer".into(), - Some("https://operator.shadownet.example".into()), - Some("operator-secret".into()), - "bootstrap1".into(), - None, - None, - None, - None, - None, - None, - ); - - let displayed = display_network_profile_json(&profile); - assert!(displayed.contains("\"operator_bearer_token\": \"\"")); - assert!(!displayed.contains("operator-secret")); - } - - #[cfg(unix)] - #[test] - fn network_profile_is_saved_with_private_file_mode() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("tempdir"); - let profile_path = dir.path().join("wallet.network.json"); - let profile = shadownet_profile( - "https://saved-rollup.example".into(), - "sr1SavedRollup".into(), - "KT1SavedTicketer".into(), - Some("https://operator.shadownet.example".into()), - Some("operator-secret".into()), - "bootstrap1".into(), - None, - None, - None, - None, - None, - None, - ); - - save_network_profile(&profile_path, &profile).expect("save profile"); - - let mode = std::fs::metadata(&profile_path) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - } - #[test] fn parse_rollup_rpc_bytes_accepts_json_hex_and_utf8() { assert_eq!( @@ -6583,7 +6913,7 @@ mod network_profile_tests { #[test] fn rollup_rpc_load_notes_since_reads_chunked_note_payloads() { - let wallet = super::tests::test_wallet(1); + let wallet = super::tests::test_wallet(1, None); let mut rseed = ZERO; rseed[0] = 0x55; let note_memo = @@ -6707,3 +7037,287 @@ mod network_profile_tests { assert_eq!(mutez_to_tez_string(2_000_000), "2"); } } + +// ═══════════════════════════════════════════════════════════════════════ +// Wallet HTTP server (trust-me-bro mode) +// ═══════════════════════════════════════════════════════════════════════ + +pub fn wallet_server_entry() { + use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, + }; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + // Parse args — supports both `--flag value` and `--flag=value` forms. + let args: Vec = std::env::args().collect(); + let mut wallet_path = "wallet.json".to_string(); + let mut ledger_url = "http://localhost:8080".to_string(); + let mut port: u16 = 8081; + let mut trust_me_bro = false; + let mut proving_service: Option = None; + + let mut i = 1; + while i < args.len() { + let arg = args[i].as_str(); + if let Some(v) = arg.strip_prefix("--wallet=") { + wallet_path = v.to_string(); + } else if arg == "--wallet" { + i += 1; if i < args.len() { wallet_path = args[i].clone(); } + } else if let Some(v) = arg.strip_prefix("--ledger=") { + ledger_url = v.to_string(); + } else if arg == "--ledger" { + i += 1; if i < args.len() { ledger_url = args[i].clone(); } + } else if let Some(v) = arg.strip_prefix("--port=") { + port = v.parse().unwrap_or(8081); + } else if arg == "--port" { + i += 1; if i < args.len() { port = args[i].parse().unwrap_or(8081); } + } else if arg == "--trust-me-bro" { + trust_me_bro = true; + } else if let Some(v) = arg.strip_prefix("--proving-service=") { + proving_service = Some(v.to_string()); + } else if arg == "--proving-service" { + i += 1; if i < args.len() { proving_service = Some(args[i].clone()); } + } + i += 1; + } + + let pc = ProveConfig { + skip_proof: trust_me_bro, + reprove_bin: String::new(), + executables_dir: String::new(), + proving_service_url: proving_service, + }; + + // Create wallet if it does not exist. + if !std::path::Path::new(&wallet_path).exists() { + cmd_keygen(&wallet_path).expect("failed to create wallet"); + } + + let wallet = load_wallet(&wallet_path).expect("failed to load wallet"); + + type WalletState = Arc>; + + async fn balance_handler( + State(st): State, + ) -> Result, (StatusCode, String)> { + let guard = st.lock().unwrap(); + let (ref w, _, _, _) = *guard; + let bal = w.available_balance(); + Ok(Json(serde_json::json!({ "private_balance": bal }))) + } + + async fn address_handler( + State(st): State, + ) -> Result, (StatusCode, String)> { + let mut guard = st.lock().unwrap(); + let (ref mut w, ref path, _, _) = *guard; + let (_, addr) = w.next_address().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(addr)) + } + + #[derive(serde::Deserialize)] + struct ShieldBody { + sender: String, + amount: u64, + } + + async fn shield_handler( + State(st): State, + Json(body): Json, + ) -> Result, (StatusCode, String)> { + tokio::task::block_in_place(|| { + let mut guard = st.lock().unwrap(); + let (ref mut w, ref path, ref ledger, ref pc) = *guard; + + let (_state, address) = w + .next_address() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + let (proof, shield_cm, shield_enc) = if pc.skip_proof { + (Proof::TrustMeBro, ZERO, None) + } else { + let rseed = random_felt(); + let rcm = derive_rcm(&rseed); + let otag = owner_tag(&address.auth_root, &address.auth_pub_seed, &address.nk_tag); + let cm = commit(&address.d_j, body.amount, &rcm, &otag); + let sender_f = hash(body.sender.as_bytes()); + let ek_v_recv = ml_kem::ml_kem_768::EncapsulationKey::new( + address.ek_v.as_slice().try_into() + .map_err(|_| (StatusCode::BAD_REQUEST, "bad ek_v length".to_string()))?, + ).map_err(|_| (StatusCode::BAD_REQUEST, "invalid ek_v".to_string()))?; + let ek_d_recv = ml_kem::ml_kem_768::EncapsulationKey::new( + address.ek_d.as_slice().try_into() + .map_err(|_| (StatusCode::BAD_REQUEST, "bad ek_d length".to_string()))?, + ).map_err(|_| (StatusCode::BAD_REQUEST, "invalid ek_d".to_string()))?; + let enc = encrypt_note(body.amount, &rseed, None, &ek_v_recv, &ek_d_recv); + let memo_ct_hash_f = memo_ct_hash(&enc); + let args: Vec = vec![ + felt_u64_to_hex(9), + felt_u64_to_hex(body.amount), + felt_to_hex(&cm), + felt_to_hex(&sender_f), + felt_to_hex(&memo_ct_hash_f), + felt_to_hex(&address.auth_root), + felt_to_hex(&address.auth_pub_seed), + felt_to_hex(&address.nk_tag), + felt_to_hex(&address.d_j), + felt_to_hex(&rseed), + ]; + let proof = pc.make_proof("run_shield", &args) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + (proof, cm, Some(enc)) + }; + + let req = ShieldReq { + sender: body.sender, + v: body.amount, + address, + memo: None, + proof, + client_cm: shield_cm, + client_enc: shield_enc, + }; + + let resp: ShieldResp = post_json(&format!("{}/shield", ledger), &req) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + // Save only after successful POST so a failed shield doesn't + // permanently consume an address slot on disk. + save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(resp)) + }) + } + + #[derive(serde::Deserialize)] + struct TransferBody { + to: PaymentAddress, + amount: u64, + } + + async fn transfer_handler( + State(st): State, + Json(body): Json, + ) -> Result, (StatusCode, String)> { + tokio::task::block_in_place(|| { + let mut guard = st.lock().unwrap(); + let (ref mut w, ref path, ref ledger, ref pc) = *guard; + + let tree_info: TreeInfoResp = get_json(&format!("{}/tree", ledger)) + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + let root = tree_info.root; + + let prepared = if pc.skip_proof { + prepare_transfer_skip_proof(w, root, &body.to, body.amount, None) + .map_err(|e| (StatusCode::BAD_REQUEST, e))? + } else { + prepare_transfer_with_proof(w, path, ledger, root, &body.to, body.amount, None, pc) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))? + }; + + save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + let resp: TransferResp = post_json(&format!("{}/transfer", ledger), &prepared.req) + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + + finalize_successful_spend(path, w, &prepared.selected) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + Ok(Json(resp)) + }) + } + + #[derive(serde::Deserialize)] + struct UnshieldBody { + recipient: String, + amount: u64, + } + + async fn unshield_handler( + State(st): State, + Json(body): Json, + ) -> Result, (StatusCode, String)> { + tokio::task::block_in_place(|| { + let mut guard = st.lock().unwrap(); + let (ref mut w, ref path, ref ledger, ref pc) = *guard; + + let tree_info: TreeInfoResp = get_json(&format!("{}/tree", ledger)) + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + let root = tree_info.root; + + let prepared = if pc.skip_proof { + prepare_unshield_skip_proof(w, root, body.amount, &body.recipient) + .map_err(|e| (StatusCode::BAD_REQUEST, e))? + } else { + prepare_unshield_with_proof(w, path, ledger, root, body.amount, &body.recipient, pc) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))? + }; + + save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + let resp: UnshieldResp = post_json(&format!("{}/unshield", ledger), &prepared.req) + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + + finalize_successful_spend(path, w, &prepared.selected) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + Ok(Json(resp)) + }) + } + + async fn scan_handler( + State(st): State, + ) -> Result, (StatusCode, String)> { + tokio::task::block_in_place(|| { + let mut guard = st.lock().unwrap(); + let (ref mut w, ref path, ref ledger, _) = *guard; + + let url = format!("{}/notes?cursor={}", ledger, w.scanned); + let feed: NotesFeedResp = + get_json(&url).map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + let nf_resp: NullifiersResp = + get_json(&format!("{}/nullifiers", ledger)) + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + + let summary = apply_scan_feed(w, &feed, nf_resp.nullifiers); + save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + Ok(Json(serde_json::json!({ + "found": summary.found, + "spent": summary.spent, + }))) + }) + } + + let mode = if trust_me_bro { + "trust-me-bro".to_string() + } else if let Some(ref url) = pc.proving_service_url { + format!("proving-service={}", url) + } else { + "trust-me-bro (default)".to_string() + }; + + let state: WalletState = Arc::new(Mutex::new((wallet, wallet_path, ledger_url, pc))); + + let app = Router::new() + .route("/balance", get(balance_handler)) + .route("/address", get(address_handler)) + .route("/shield", post(shield_handler)) + .route("/transfer", post(transfer_handler)) + .route("/unshield", post(unshield_handler)) + .route("/scan", post(scan_handler)) + .with_state(state); + + eprintln!("wallet-server listening on 0.0.0.0:{} [{}]", port, mode); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await.unwrap(); + axum::serve(listener, app).await.unwrap(); + }); +} diff --git a/core/Cargo.toml b/core/Cargo.toml index 3a2cf74..0bb2c29 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -8,6 +8,7 @@ name = "tzel_core" path = "src/lib.rs" [dependencies] +bech32 = "0.11" blake2s_simd = "1.0" hex = "0.4" chacha20poly1305 = "0.10" diff --git a/core/src/lib.rs b/core/src/lib.rs index d8b5865..03b1c67 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -627,28 +627,6 @@ pub fn recover_wots_pk(msg_hash: &F, pub_seed: &F, key_idx: u32, sig: &[F]) -> V .collect() } -pub fn verify_wots_signature_against_leaf( - msg_hash: &F, - pub_seed: &F, - key_idx: u32, - sig: &[F], - expected_leaf: &F, -) -> Result<(), String> { - if sig.len() != WOTS_CHAINS { - return Err(format!( - "bad WOTS signature length: got {}, expected {}", - sig.len(), - WOTS_CHAINS - )); - } - let recovered_pk = recover_wots_pk(msg_hash, pub_seed, key_idx, sig); - let recovered_leaf = wots_pk_to_leaf(pub_seed, key_idx, &recovered_pk); - if &recovered_leaf != expected_leaf { - return Err("configuration signature verification failed".into()); - } - Ok(()) -} - pub fn xmss_subtree_root(ask_j: &F, pub_seed: &F, start: u32, height: usize) -> F { if height == AUTH_DEPTH && start == 0 { assert_full_xmss_rebuild_allowed("xmss_subtree_root"); @@ -1298,7 +1276,7 @@ pub struct DepositReq { pub type FundReq = DepositReq; /// Payment address — everything a sender needs to create a note for the recipient. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentAddress { #[serde(with = "hex_f")] pub d_j: F, @@ -1314,6 +1292,56 @@ pub struct PaymentAddress { pub ek_d: Vec, } +impl PaymentAddress { + pub fn to_bech32m(&self) -> String { + let mut payload = Vec::with_capacity(4 * 32 + self.ek_v.len() + self.ek_d.len()); + payload.extend_from_slice(&self.d_j); + payload.extend_from_slice(&self.auth_root); + payload.extend_from_slice(&self.auth_pub_seed); + payload.extend_from_slice(&self.nk_tag); + payload.extend_from_slice(&self.ek_v); + payload.extend_from_slice(&self.ek_d); + let hrp = bech32::Hrp::parse("tzel").expect("valid hrp"); + bech32::encode::(hrp, &payload).expect("bech32 encode") + } + + pub fn from_bech32m(s: &str) -> Result { + use bech32::primitives::decode::CheckedHrpstring; + let checked = CheckedHrpstring::new::(s) + .map_err(|e| e.to_string())?; + let hrp = checked.hrp(); + if hrp != bech32::Hrp::parse("tzel").expect("valid hrp") { + return Err(format!("unexpected hrp: {}", hrp)); + } + let payload: Vec = checked.byte_iter().collect(); + let min_len = 4 * 32; + if payload.len() < min_len { + return Err(format!("payload too short: {} bytes", payload.len())); + } + let mut off = 0; + let read_f = |buf: &[u8], o: &mut usize| -> Result { + if buf.len() < *o + 32 { + return Err("payload truncated".into()); + } + let mut f = [0u8; 32]; + f.copy_from_slice(&buf[*o..*o + 32]); + *o += 32; + Ok(f) + }; + let d_j = read_f(&payload, &mut off)?; + let auth_root = read_f(&payload, &mut off)?; + let auth_pub_seed = read_f(&payload, &mut off)?; + let nk_tag = read_f(&payload, &mut off)?; + let ek_len = (payload.len() - off) / 2; + if ek_len * 2 + off != payload.len() { + return Err("payload length is not symmetric for ek_v/ek_d".into()); + } + let ek_v = payload[off..off + ek_len].to_vec(); + let ek_d = payload[off + ek_len..].to_vec(); + Ok(PaymentAddress { d_j, auth_root, auth_pub_seed, nk_tag, ek_v, ek_d }) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ShieldReq { pub sender: String, @@ -2933,4 +2961,22 @@ mod tests { assert_eq!(ledger.root_history.len(), 3); assert_eq!(ledger.valid_roots.len(), 3); } + + #[test] + fn test_payment_address_bech32m_roundtrip() { + let seed = [0xab_u8; 32]; + let (ek_v, _dk_v, ek_d, _dk_d) = derive_kem_keys(&seed, 0); + let addr = PaymentAddress { + d_j: [0x01; 32], + auth_root: [0x02; 32], + auth_pub_seed: [0x03; 32], + nk_tag: [0x04; 32], + ek_v: ek_v.to_bytes().to_vec(), + ek_d: ek_d.to_bytes().to_vec(), + }; + let encoded = addr.to_bech32m(); + assert!(encoded.starts_with("tzel1")); + let decoded = PaymentAddress::from_bech32m(&encoded).unwrap(); + assert_eq!(addr, decoded); + } } diff --git a/docs/wallet_server.md b/docs/wallet_server.md new file mode 100644 index 0000000..d96693b --- /dev/null +++ b/docs/wallet_server.md @@ -0,0 +1,150 @@ +# wallet-server + +HTTP server exposing a private wallet over a local REST API. Designed to be used by the TzEL web dapp or any HTTP client. + +## Building + +```bash +cargo build -p tzel-wallet-app --bin wallet-server --release +``` + +## Running + +```bash +wallet-server \ + --wallet=/path/to/wallet.json \ + --ledger=http://:8787 \ + --port=8081 \ + --trust-me-bro # skip STARK proofs (demo/dev only) +``` + +With a real proving service: + +```bash +wallet-server \ + --wallet=/path/to/wallet.json \ + --ledger=http://:8787 \ + --port=8081 \ + --proving-service=http://:9000 +``` + +A fresh wallet is created automatically if the file does not exist. + +## API + +### `GET /balance` + +Returns the available private balance. + +```json +{ "private_balance": 1000000 } +``` + +### `GET /wallet/address` + +Generates (or returns) the next unused payment address. The address is a JSON blob containing the KEM public keys and diversifier needed for a sender to encrypt a note to this wallet. + +### `POST /scan` + +Pulls new notes from the ledger and updates the local wallet state. + +```bash +curl -X POST http://localhost:8081/scan +``` + +### `POST /shield` + +Shields funds from a public L1 address into the private pool. + +```json +{ "sender": "tz1...", "amount": 1000000 } +``` + +### `POST /transfer` + +Transfers private funds to another payment address. + +```json +{ + "to": { "d_j": "0x...", "auth_root": "0x...", "auth_pub_seed": "0x...", "nk_tag": "0x...", "ek_v": "0x...", "ek_d": "0x..." }, + "amount": 500000 +} +``` + +### `POST /unshield` + +Withdraws private funds to a public L1 address. + +```json +{ "recipient": "tz1...", "amount": 500000 } +``` + +## Testing manually + +### Prerequisites + +- A running `tzel-operator` connected to an L1 node (or use the `--trust-me-bro` flag to skip proofs) +- A funded L1 address in the octez-client keychain (for shield) + +### Step-by-step + +1. **Start the wallet server** + +```bash +wallet-server --wallet=/tmp/test-wallet.json --ledger=http://localhost:8787 --port=8081 --trust-me-bro +``` + +2. **Check balance** (should be 0 on a fresh wallet) + +```bash +curl http://localhost:8081/balance +``` + +3. **Get a payment address** + +```bash +curl http://localhost:8081/wallet/address +``` + +4. **Shield funds** + +```bash +curl -X POST http://localhost:8081/shield \ + -H 'Content-Type: application/json' \ + -d '{"sender":"tz1youraddress","amount":1000000}' +``` + +5. **Scan to detect incoming notes** + +```bash +curl -X POST http://localhost:8081/scan +curl http://localhost:8081/balance # should now show the shielded amount +``` + +6. **Transfer to another wallet** (obtain a payment address from another wallet-server instance first) + +```bash +curl -X POST http://localhost:8081/transfer \ + -H 'Content-Type: application/json' \ + -d '{"to":{...payment address JSON...},"amount":400000}' +``` + +7. **Unshield to L1** + +```bash +curl -X POST http://localhost:8081/unshield \ + -H 'Content-Type: application/json' \ + -d '{"recipient":"tz1destinationaddress","amount":300000}' +``` + +## Web dapp + +The `web/` directory contains a Vite/React frontend that connects to a running `wallet-server`. + +```bash +cd web +npm install +npm run dev # starts on http://localhost:5173 +``` + +Set the wallet server URL in the UI (defaults to `http://localhost:8081`). diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3ff38cc --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..860237f --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + TzEL — Private Transactions Demo + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..04630d6 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1729 @@ +{ + "name": "tzel-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tzel-demo", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c91e50c --- /dev/null +++ b/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "tzel-demo", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..01ebd36 --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,630 @@ +/* ─── Design tokens ──────────────────────────────────────────── */ +:root { + --bg: #0b0d14; + --surface: #111827; + --surface-2: #1a2235; + --border: #1f2d44; + --border-light: #2a3d5a; + --accent: #7c3aed; + --accent-light: #a78bfa; + --accent-glow: rgba(124, 58, 237, 0.2); + --green: #10b981; + --green-dim: rgba(16, 185, 129, 0.15); + --red: #f87171; + --red-dim: rgba(248, 113, 113, 0.15); + --amber: #fbbf24; + --amber-dim: rgba(251, 191, 36, 0.15); + --blue: #60a5fa; + --text: #e2e8f0; + --text-muted: #94a3b8; + --text-dim: #475569; + --mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --sans: 'Inter', system-ui, sans-serif; + --radius: 8px; + --radius-lg: 12px; +} + +/* ─── Reset ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +#root { min-height: 100vh; } +button { cursor: pointer; border: none; font-family: inherit; } +input { font-family: inherit; } +code { font-family: var(--mono); } +.mono { font-family: var(--mono); } +.dim { color: var(--text-dim); } + +/* ─── App shell ──────────────────────────────────────────────── */ +.app { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 680px; + margin: 0 auto; + padding: 0 1.25rem 3rem; + gap: 1rem; +} + +/* ─── Header ─────────────────────────────────────────────────── */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0 0.75rem; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + gap: 0.5rem; +} +.header-left { display: flex; align-items: baseline; gap: 0.6rem; } +.logo { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.03em; + background: linear-gradient(135deg, var(--accent-light), #60a5fa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.logo-sub { font-size: 0.8rem; color: var(--text-dim); } +.header-right { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } +.mock-badge { + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; + background: var(--amber-dim); + color: var(--amber); + border: 1px solid rgba(251, 191, 36, 0.3); + padding: 0.2rem 0.55rem; + border-radius: 999px; +} +.mode-toggle { + font-size: 0.78rem; + font-weight: 500; + color: var(--text-muted); + background: var(--surface); + border: 1px solid var(--border); + padding: 0.35rem 0.8rem; + border-radius: var(--radius); + transition: border-color 0.15s, color 0.15s; +} +.mode-toggle:hover { border-color: var(--border-light); color: var(--text); } + +/* ─── Speed control ──────────────────────────────────────────── */ +.speed-control { + display: flex; + align-items: center; + gap: 0.2rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2rem 0.4rem; +} +.speed-label { font-size: 0.72rem; color: var(--text-dim); padding: 0 0.3rem; white-space: nowrap; } +.speed-btn { + font-size: 0.72rem; font-weight: 500; + padding: 0.2rem 0.55rem; + border-radius: calc(var(--radius) - 2px); + background: transparent; color: var(--text-dim); + transition: all 0.15s; +} +.speed-btn:hover:not(:disabled) { color: var(--text); background: var(--surface-2); } +.speed-btn.active { background: var(--accent-glow); color: var(--accent-light); font-weight: 600; } +.speed-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ─── Balance hero ───────────────────────────────────────────── */ +.balance-hero { + display: flex; + align-items: stretch; + gap: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-top: 0.5rem; +} +.balance-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem 1rem; + gap: 0.3rem; +} +.public-card { background: linear-gradient(160deg, #0f1520, #111827); } +.private-card { background: linear-gradient(160deg, #0a1a10, #0d1d14); } +.balance-card-icon { font-size: 1.4rem; } +.balance-card-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); } +.balance-card-amount { + font-size: 2.2rem; + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1; + margin: 0.25rem 0; +} +.private-card .balance-card-amount { color: var(--green); } +.tez-unit { font-size: 1.2rem; font-weight: 500; color: var(--text-muted); } +.balance-card-note { font-size: 0.72rem; color: var(--text-dim); } +.balance-divider { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0 0.25rem; + width: 32px; + flex-shrink: 0; +} +.balance-divider-line { flex: 1; width: 1px; background: var(--border); } +.balance-divider-icon { font-size: 1rem; } + +/* ─── Action bar ─────────────────────────────────────────────── */ +.action-bar { + display: flex; + gap: 0.5rem; + align-items: center; +} +.action-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 0.85rem 0.5rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 500; + transition: all 0.15s; +} +.action-btn-icon { font-size: 1.2rem; } +.action-btn:hover:not(:disabled) { border-color: var(--border-light); color: var(--text); background: var(--surface-2); } +.action-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.action-btn-shield.active { border-color: var(--blue); background: rgba(96,165,250,0.08); color: var(--blue); } +.action-btn-transfer.active { border-color: var(--accent-light); background: var(--accent-glow); color: var(--accent-light); } +.action-btn-unshield.active { border-color: var(--green); background: var(--green-dim); color: var(--green); } +.reset-btn-small { + width: 36px; height: 36px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 1rem; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: all 0.15s; + align-self: center; +} +.reset-btn-small:hover { color: var(--text); border-color: var(--border-light); } + +/* ─── Action form ────────────────────────────────────────────── */ +.action-form { + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; + animation: form-slide 0.2s ease-out; +} +@keyframes form-slide { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} +.action-form-title { font-weight: 600; font-size: 0.9rem; } +.form-field { display: flex; flex-direction: column; gap: 0.4rem; } +.form-field label { font-size: 0.78rem; color: var(--text-muted); } +.form-hint { color: var(--text-dim); font-weight: 400; margin-left: 0.5rem; } +.form-input { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.55rem 0.75rem; + color: var(--text); + font-size: 0.9rem; + width: 100%; + transition: border-color 0.15s; +} +.form-input:focus { outline: none; border-color: var(--accent); } +.form-input[type=number]::-webkit-inner-spin-button { opacity: 0.4; } +.amount-input-row { display: flex; align-items: center; gap: 0.5rem; } +.amount-unit { color: var(--text-dim); font-weight: 500; } +.max-btn { + font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; + padding: 0.3rem 0.6rem; + border-radius: var(--radius); + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-muted); + transition: all 0.15s; + flex-shrink: 0; +} +.max-btn:hover { color: var(--text); border-color: var(--border-light); } +.form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } +.cancel-btn { + padding: 0.55rem 1.1rem; + border-radius: var(--radius); + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.85rem; + transition: all 0.15s; +} +.cancel-btn:hover { border-color: var(--border-light); color: var(--text); } +.confirm-btn { + padding: 0.55rem 1.3rem; + border-radius: var(--radius); + background: var(--accent); + color: white; + font-size: 0.85rem; + font-weight: 600; + transition: background 0.15s, opacity 0.15s; +} +.confirm-btn:hover:not(:disabled) { background: #6d28d9; } +.confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ─── Proving overlay ────────────────────────────────────────── */ +.proving-overlay { + background: var(--surface); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: 1.5rem 1.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + box-shadow: 0 0 30px var(--accent-glow); + animation: pulse-border 1.5s ease-in-out infinite; +} +@keyframes pulse-border { + 0%, 100% { box-shadow: 0 0 15px var(--accent-glow); } + 50% { box-shadow: 0 0 35px rgba(124, 58, 237, 0.4); } +} +.proving-overlay.injecting { border-color: var(--amber); box-shadow: 0 0 30px rgba(251,191,36,0.15); animation: none; } +.proving-overlay.verifying { border-color: var(--green); box-shadow: 0 0 30px rgba(16,185,129,0.2); animation: none; } +.proving-title { font-weight: 600; font-size: 0.95rem; color: var(--accent-light); } +.proving-overlay.injecting .proving-title { color: var(--amber); } +.proving-overlay.verifying .proving-title { color: var(--green); } +.proving-phases { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; } +.phase { color: var(--text-dim); } +.phase.active { color: var(--accent-light); font-weight: 600; } +.phase.done { color: var(--green); } +.phase-arrow { color: var(--text-dim); } +.phase-arrow.dim { opacity: 0.3; } +.proving-bar-track { + width: 100%; max-width: 380px; + height: 6px; background: var(--surface-2); border-radius: 999px; overflow: hidden; +} +.proving-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-light)); + border-radius: 999px; + transition: width 0.1s linear; + position: relative; overflow: hidden; +} +.proving-bar-fill::after { + content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: shimmer 1.2s linear infinite; +} +@keyframes shimmer { from { left: -100%; } to { left: 200%; } } +.proving-detail { font-size: 0.72rem; } +.proving-spinner { + width: 28px; height: 28px; + border: 3px solid var(--border); + border-top-color: var(--amber); + border-radius: 50%; + animation: spin 1s linear infinite; +} +.proving-spinner.fast { border-top-color: var(--green); animation-duration: 0.4s; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ─── Proof banner ───────────────────────────────────────────── */ +.proof-banner { + display: flex; align-items: center; + background: var(--surface); + border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: var(--radius-lg); + padding: 0.85rem 1rem; + animation: banner-appear 0.4s ease-out; + flex-wrap: wrap; +} +@keyframes banner-appear { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.proof-stat { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding: 0 1rem; flex: 1; } +.proof-icon { font-size: 1rem; color: var(--green); font-weight: bold; } +.proof-label { font-size: 0.65rem; color: var(--text-dim); text-align: center; } +.proof-value { font-size: 1rem; font-weight: 700; color: var(--green); font-variant-numeric: tabular-nums; } +.proof-divider { width: 1px; height: 2.2rem; background: var(--border); flex-shrink: 0; } +.pq-badge { + font-size: 0.62rem; font-weight: 700; letter-spacing: 0.1em; + background: linear-gradient(135deg, var(--accent-glow), rgba(96,165,250,0.1)); + color: var(--accent-light); + border: 1px solid rgba(167, 139, 250, 0.4); + padding: 0.3rem 0.6rem; + border-radius: var(--radius); +} + +/* ─── Section title ──────────────────────────────────────────── */ +.section-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + margin-top: 0.25rem; +} + +/* ─── Transaction history ────────────────────────────────────── */ +.tx-history { display: flex; flex-direction: column; gap: 0.5rem; } +.tx-history.empty { padding: 1rem 0; } +.tx-empty-state { font-size: 0.85rem; color: var(--text-dim); font-style: italic; text-align: center; } +.tx-row { + display: flex; + align-items: flex-start; + gap: 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + animation: tx-appear 0.3s ease-out; +} +@keyframes tx-appear { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.tx-icon { + width: 28px; height: 28px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 0.85rem; font-weight: 700; + flex-shrink: 0; margin-top: 1px; +} +.tx-icon-shield { background: rgba(96,165,250,0.15); color: var(--blue); } +.tx-icon-transfer { background: var(--accent-glow); color: var(--accent-light); } +.tx-icon-unshield { background: var(--green-dim); color: var(--green); } +.tx-body { flex: 1; display: flex; flex-direction: column; gap: 0.3rem; } +.tx-main { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; } +.tx-label { font-size: 0.85rem; font-weight: 500; } +.tx-amount { font-size: 0.9rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.tx-time { font-size: 0.72rem; color: var(--text-dim); white-space: nowrap; margin-top: 2px; } +.proof-tag { display: flex; align-items: center; gap: 0.4rem; } +.proof-tag-check { color: var(--green); font-size: 0.75rem; } +.proof-tag-text { font-size: 0.72rem; color: var(--text-dim); } +.pq-tag { + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; + color: var(--accent-light); + background: var(--accent-glow); + border: 1px solid rgba(167,139,250,0.3); + padding: 0.1rem 0.35rem; + border-radius: 4px; +} + +/* ─── Address book / recipient field ────────────────────────── */ +.recipient-row { + display: flex; + align-items: center; + gap: 0.6rem; +} +.form-select { + flex: 1; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.55rem 0.75rem; + color: var(--text); + font-size: 0.9rem; + font-family: inherit; + transition: border-color 0.15s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2394a3b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; + cursor: pointer; +} +.form-select:focus { outline: none; border-color: var(--accent); } +.form-select option { background: var(--surface-2); color: var(--text); } +.add-contact-link { + background: transparent; + border: none; + color: var(--accent-light); + font-size: 0.78rem; + font-weight: 500; + padding: 0.2rem 0; + white-space: nowrap; + text-decoration: underline; + text-underline-offset: 2px; + flex-shrink: 0; + transition: color 0.15s; +} +.add-contact-link:hover { color: #c4b5fd; } +.add-contact-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; + padding: 0.75rem; + background: rgba(124, 58, 237, 0.05); + border: 1px solid rgba(124, 58, 237, 0.2); + border-radius: var(--radius); + animation: form-slide 0.15s ease-out; +} +.file-pick-area { display: flex; flex-direction: column; gap: 0.3rem; } +.file-pick-label { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.85rem; + background: var(--surface-2); + border: 1px dashed var(--border-light); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.82rem; + color: var(--text-muted); + transition: border-color 0.15s, color 0.15s; +} +.file-pick-label:hover { border-color: var(--accent); color: var(--text); } +.file-pick-icon { font-size: 1rem; flex-shrink: 0; } +.file-pick-input { display: none; } +.file-error { font-size: 0.75rem; color: var(--red); padding-left: 0.25rem; } +.file-ok { font-size: 0.75rem; color: var(--green); padding-left: 0.25rem; } + +.save-contact-btn { + align-self: flex-end; + padding: 0.4rem 0.9rem; + border-radius: var(--radius); + background: var(--accent-glow); + border: 1px solid rgba(167, 139, 250, 0.4); + color: var(--accent-light); + font-size: 0.8rem; + font-weight: 600; + transition: background 0.15s, border-color 0.15s; +} +.save-contact-btn:hover:not(:disabled) { background: rgba(124, 58, 237, 0.25); border-color: var(--accent-light); } +.save-contact-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ─── Chain view ─────────────────────────────────────────────── */ +.chain-view { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.chain-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--surface); + color: var(--text-dim); + font-size: 0.8rem; + font-weight: 500; + transition: color 0.15s; +} +.chain-toggle:hover { color: var(--text-muted); } +.chain-content { + background: var(--surface); + border-top: 1px solid var(--border); + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.chain-note { + font-size: 0.78rem; + color: var(--text-dim); + font-style: italic; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.25rem; + line-height: 1.55; +} +.chain-row { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; font-size: 0.82rem; } +.chain-key { color: var(--text-dim); } +.chain-val { color: var(--text-muted); font-variant-numeric: tabular-nums; } +.chain-nullifier { font-size: 0.72rem; color: var(--text-dim); padding-left: 0.5rem; font-family: var(--mono); } + +/* ─── Real mode ──────────────────────────────────────────────── */ +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +.real-conn-errors { + display: flex; flex-direction: column; gap: 0.4rem; + margin-bottom: 0.5rem; +} +.real-conn-error { + font-size: 0.8rem; + padding: 0.5rem 0.75rem; + background: rgba(248,113,113,0.08); + border: 1px solid rgba(248,113,113,0.2); + border-radius: var(--radius); + color: var(--text-muted); + display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; +} +.conn-svc { + font-weight: 600; + color: var(--red); + font-family: var(--mono); + font-size: 0.78rem; +} +.conn-hint { color: var(--text-dim); } +.conn-hint code { + color: var(--accent-light); background: var(--accent-glow); + padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; +} + +.real-op-error { + background: rgba(248,113,113,0.1); + border: 1px solid rgba(248,113,113,0.3); + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.85rem; + color: var(--red); + display: flex; align-items: center; gap: 0.75rem; +} +.real-op-error .error-icon { font-size: 1rem; } +.real-op-error .dismiss-btn { + margin-left: auto; background: none; border: none; color: var(--text-dim); + cursor: pointer; font-size: 0.9rem; padding: 0.1rem 0.3rem; +} +.real-op-error .dismiss-btn:hover { color: var(--text); } + +.form-hint-list { + margin-top: 0.4rem; + display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; + font-size: 0.78rem; color: var(--text-dim); +} +.addr-chip { + background: var(--surface-2); border: 1px solid var(--border-light); + border-radius: 20px; padding: 0.15rem 0.6rem; + font-family: var(--mono); font-size: 0.75rem; color: var(--accent-light); + cursor: pointer; white-space: nowrap; +} +.addr-chip:hover { border-color: var(--accent-light); background: var(--accent-glow); } +.addr-chip-bal { color: var(--text-muted); font-family: var(--sans); } + +/* ─── Receive address panel ────────────────────────────────── */ +.receive-section { margin: 0.5rem 0 0.25rem; } +.receive-toggle { + background: none; border: 1px solid var(--border-light); + border-radius: var(--radius); padding: 0.4rem 0.85rem; + color: var(--text-muted); font-size: 0.82rem; cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} +.receive-toggle:hover:not(:disabled) { border-color: var(--green); color: var(--green); } +.receive-toggle:disabled { opacity: 0.5; cursor: not-allowed; } +.receive-card { + margin-top: 0.6rem; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 0.9rem 1rem; display: flex; + flex-direction: column; gap: 0.6rem; +} +.receive-card-preview { + font-size: 0.72rem; color: var(--text-dim); + word-break: break-all; user-select: all; +} +.receive-card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.receive-action-btn { + background: var(--surface); border: 1px solid var(--border-light); + border-radius: var(--radius); padding: 0.3rem 0.7rem; + color: var(--text-muted); font-size: 0.8rem; cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} +.receive-action-btn:hover:not(:disabled) { border-color: var(--accent-light); color: var(--accent-light); } +.receive-action-btn.dim { color: var(--text-dim); } +.receive-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.receive-card-hint { font-size: 0.73rem; color: var(--text-dim); } diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..a6416d0 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,875 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import type { MockWalletState, TxType, ProofInfo, Contact } from './types' +import { + INITIAL_STATE, PROOF_SPEED_PRESETS, INJECT_DELAY_MS, VERIFY_DELAY_MS, + privateBalance, applyShield, applyTransfer, applyUnshield, + type ProofSpeed, +} from './mockService' +import { + fetchLedgerState, type RealLedgerState, + getWalletBalance, getWalletAddress, + walletShield, walletTransfer, walletUnshield, walletScan, +} from './realService' +import { useAddressBook } from './useAddressBook' + +type Mode = 'mock' | 'real' +type ProvingPhase = 'idle' | 'generating' | 'injecting' | 'verifying' | 'done' + +function truncateHex(hex: string, head = 10, tail = 8): string { + if (hex.length <= head + tail + 3) return hex + return `${hex.slice(0, head)}…${hex.slice(-tail)}` +} + +function relativeTime(ts: number): string { + const sec = Math.floor((Date.now() - ts) / 1000) + if (sec < 5) return 'just now' + if (sec < 60) return `${sec}s ago` + if (sec < 3600) return `${Math.floor(sec / 60)}m ago` + return `${Math.floor(sec / 3600)}h ago` +} + +const TX_ICONS: Record = { shield: '↓', transfer: '→', unshield: '↑' } +const TX_LABELS: Record string> = { + shield: () => 'Shielded', + transfer: (r) => `Sent to ${r ?? 'unknown'}`, + unshield: () => 'Withdrawn to public', +} + +/* ─── Proof animation ─────────────────────────────────────────── */ + +function ProvingOverlay({ phase, progress, proofDelayMs }: { + phase: ProvingPhase + progress: number + proofDelayMs: number +}) { + const pct = Math.round(progress * 100) + + if (phase === 'generating') { + const elapsed = Math.round((progress * proofDelayMs) / 1000) + return ( +
+
+ Generating proof + + Inject + + Verify +
+
Generating STARK proof…
+
+
+
+
+ BLAKE2s · ML-KEM-768 · WOTS+ · Stwo prover  ·  {elapsed}s elapsed +
+
+ ) + } + if (phase === 'injecting') { + return ( +
+
+ ✓ Proof generated + + Inject + + Verify +
+
Submitting transaction to ledger…
+
+
sending proof + nullifiers + commitments
+
+ ) + } + if (phase === 'verifying') { + return ( +
+
+ ✓ Proof generated + + ✓ Injected + + Verify +
+
Ledger verifying STARK proof…
+
+
checking nullifiers · updating Merkle tree
+
+ ) + } + return null +} + +/* ─── Balance hero ────────────────────────────────────────────── */ + +function BalanceHero({ pub, priv }: { pub: number | null; priv: number }) { + return ( +
+
+
🌐
+
Public balance
+
+ {pub === null ? : <>{pub} } +
+
{pub === null ? 'not tracked' : 'visible on-chain'}
+
+
+
+
🔒
+
+
+
+
🔐
+
Private balance
+
{priv}
+
only you can see this
+
+
+ ) +} + +/* ─── Action form ─────────────────────────────────────────────── */ + +function ActionForm({ type, maxAmount, contacts, mode, senderOptions, onConfirm, onCancel, onAddContact }: { + type: TxType + maxAmount?: number + contacts: Contact[] + mode: Mode + senderOptions?: Record + onConfirm: (amount: number, alias?: string, tz1?: string) => void + onCancel: () => void + onAddContact: (c: Contact) => void +}) { + const [amount, setAmount] = useState('') + const [selectedAlias, setSelectedAlias] = useState(contacts[0]?.alias ?? '') + const [tz1Input, setTz1Input] = useState('') + const [showAddForm, setShowAddForm] = useState(false) + const [newAlias, setNewAlias] = useState('') + const [newAddress, setNewAddress] = useState('') + const [fileError, setFileError] = useState(null) + const [fileName, setFileName] = useState(null) + + const needsTz1 = mode === 'real' && (type === 'shield' || type === 'unshield') + const parsed = parseFloat(amount) + const validAmount = !isNaN(parsed) && parsed > 0 && (maxAmount === undefined || parsed <= maxAmount) + const valid = validAmount + && (type !== 'transfer' || selectedAlias !== '') + && (!needsTz1 || /^tz[123]/.test(tz1Input.trim())) + + const titles: Record = { + shield: '↓ Shield — public → private', + transfer: '→ Transfer — private → recipient', + unshield: '↑ Unshield — private → public', + } + const hints: Record = { + shield: maxAmount !== undefined ? `max ${maxAmount} ꜩ (public balance)` : 'from public balance', + transfer: maxAmount !== undefined ? `max ${maxAmount} ꜩ (private balance)` : 'from private balance', + unshield: maxAmount !== undefined ? `max ${maxAmount} ꜩ (private balance)` : 'from private balance', + } + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setFileError(null) + setFileName(file.name) + const reader = new FileReader() + reader.onload = (ev) => { + const text = ev.target?.result as string + try { + const parsed = JSON.parse(text) + const required = ['d_j', 'auth_root', 'auth_pub_seed', 'nk_tag', 'ek_v'] + const missing = required.filter(k => !(k in parsed)) + if (missing.length > 0) { + setFileError(`Missing fields: ${missing.join(', ')}`) + setNewAddress('') + return + } + setNewAddress(text) + if (!newAlias && file.name.endsWith('.json')) { + setNewAlias(file.name.replace(/\.json$/, '')) + } + } catch { + setFileError('Invalid JSON file') + setNewAddress('') + } + } + reader.readAsText(file) + } + + const handleSaveContact = () => { + if (!newAlias.trim() || !newAddress.trim()) return + const c: Contact = { alias: newAlias.trim(), address: newAddress.trim() } + onAddContact(c) + setSelectedAlias(c.alias) + setNewAlias('') + setNewAddress('') + setFileName(null) + setFileError(null) + setShowAddForm(false) + } + + const handleConfirm = () => { + if (!valid) return + if (type === 'transfer') { + onConfirm(parsed, selectedAlias, undefined) + } else if (needsTz1) { + onConfirm(parsed, undefined, tz1Input.trim()) + } else { + onConfirm(parsed, undefined, undefined) + } + } + + return ( +
+
{titles[type]}
+ {type === 'transfer' && ( +
+ +
+ + +
+ {showAddForm && ( +
+
+ + {fileError &&
{fileError}
} + {newAddress && !fileError && ( +
✓ Valid payment address
+ )} +
+ setNewAlias(e.target.value)} + /> + +
+ )} +
+ )} + {needsTz1 && ( +
+ + setTz1Input(e.target.value)} + placeholder="tz1…" + autoFocus + /> + {type === 'shield' && senderOptions && ( + + {Object.entries(senderOptions).map(([addr, bal]) => ( + + ))} + + )} + {type === 'shield' && senderOptions && Object.keys(senderOptions).length > 0 && ( +
+ Funded: {Object.entries(senderOptions).map(([addr, bal]) => ( + + ))} +
+ )} +
+ )} +
+ +
+ setAmount(e.target.value)} + placeholder="0" + autoFocus={!needsTz1} + /> + + {maxAmount !== undefined && ( + + )} +
+
+
+ + +
+
+ ) +} + +/* ─── Transaction history ─────────────────────────────────────── */ + +function ProofTag({ proof }: { proof: ProofInfo | null }) { + if (!proof) { + return ( +
+ + trust-me-bro +
+ ) + } + return ( +
+ + STARK · {proof.sizeKb} KB · {(proof.generationMs / 1000).toFixed(1)}s + PQ +
+ ) +} + +function TxHistory({ history }: { history: MockWalletState['history'] }) { + const [, tick] = useState(0) + useEffect(() => { + const id = setInterval(() => tick(t => t + 1), 15000) + return () => clearInterval(id) + }, []) + + if (history.length === 0) { + return ( +
+
No transactions yet
+
+ ) + } + + return ( +
+ {history.map(tx => ( +
+
{TX_ICONS[tx.type]}
+
+
+ {TX_LABELS[tx.type](tx.recipient)} + {tx.amount} ꜩ +
+ +
+
{relativeTime(tx.timestamp)}
+
+ ))} +
+ ) +} + +/* ─── Chain view (collapsible) ────────────────────────────────── */ + +function ChainView({ wallet }: { wallet: MockWalletState }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open && ( +
+
+ An on-chain observer sees only opaque commitments — zero information about amounts, senders, or recipients. +
+
+ Your public balance + {wallet.publicBalance} ꜩ +
+
+ Private pool + ??? ꜩ +
+
+ Commitments in tree + {wallet.merkleSize} +
+
+ Merkle root + {truncateHex(wallet.merkleRoot)} +
+
+ Spent nullifiers + {wallet.nullifiers.length} +
+ {wallet.nullifiers.slice(0, 3).map(n => ( +
{truncateHex(n)}
+ ))} + {wallet.nullifiers.length > 3 && ( +
+{wallet.nullifiers.length - 3} more
+ )} +
+ )} +
+ ) +} + +/* ─── App ─────────────────────────────────────────────────────── */ + +export default function App() { + const [mode, setMode] = useState('mock') + const [wallet, setWallet] = useState(INITIAL_STATE) + const { contacts, addContact } = useAddressBook() + const [phase, setPhase] = useState('idle') + const [proofProgress, setProofProgress] = useState(0) + const [activeAction, setActiveAction] = useState(null) + const [pendingProof, setPendingProof] = useState(null) + const [proofSpeed, setProofSpeed] = useState('Fast') + const [realState, setRealState] = useState(null) + const [realError, setRealError] = useState(null) + const [realPrivBalance, setRealPrivBalance] = useState(null) + const [realWalletError, setRealWalletError] = useState(null) + const [realTxHistory, setRealTxHistory] = useState([]) + const [realPending, setRealPending] = useState(false) + const [realOpError, setRealOpError] = useState(null) + const [myAddress, setMyAddress] = useState | null>(null) + const [myAddressLoading, setMyAddressLoading] = useState(false) + const [showReceive, setShowReceive] = useState(false) + const rafRef = useRef(null) + const startRef = useRef(0) + const pendingApplyRef = useRef<(() => { state: MockWalletState; proof: ProofInfo }) | null>(null) + + const pubBalance = wallet.publicBalance + const privBalance = privateBalance(wallet) + const proofDelayMs = PROOF_SPEED_PRESETS.find(p => p.label === proofSpeed)!.ms + const isBusy = phase === 'generating' || phase === 'injecting' || phase === 'verifying' + + useEffect(() => { + if (mode !== 'real') return + let cancelled = false + + const pollLedger = async () => { + try { + const s = await fetchLedgerState() + if (!cancelled) { setRealState(s); setRealError(null) } + } catch (e) { + if (!cancelled) setRealError(e instanceof Error ? e.message : 'unknown error') + } + } + + const pollWallet = async () => { + try { + const bal = await getWalletBalance() + if (!cancelled) { setRealPrivBalance(bal); setRealWalletError(null) } + } catch (e) { + if (!cancelled) setRealWalletError(e instanceof Error ? e.message : 'unknown error') + } + } + + walletScan().catch(() => {}) + pollLedger() + pollWallet() + + const ledgerId = setInterval(pollLedger, 3000) + const walletId = setInterval(pollWallet, 5000) + return () => { cancelled = true; clearInterval(ledgerId); clearInterval(walletId) } + }, [mode]) + + const startProving = useCallback((applyFn: () => { state: MockWalletState; proof: ProofInfo }) => { + pendingApplyRef.current = applyFn + setPhase('generating') + setProofProgress(0) + setPendingProof(null) + startRef.current = performance.now() + + const animate = () => { + const progress = Math.min((performance.now() - startRef.current) / proofDelayMs, 1) + setProofProgress(progress) + if (progress < 1) { + rafRef.current = requestAnimationFrame(animate) + } else { + setPhase('injecting') + setTimeout(() => { + setPhase('verifying') + setTimeout(() => { + const result = pendingApplyRef.current!() + setWallet(result.state) + setPendingProof(result.proof) + setPhase('done') + }, VERIFY_DELAY_MS) + }, INJECT_DELAY_MS) + } + } + rafRef.current = requestAnimationFrame(animate) + }, [proofDelayMs]) + + const handleConfirm = useCallback(async (amount: number, alias?: string, tz1?: string) => { + if (!activeAction) return + const action = activeAction + setActiveAction(null) + + if (mode === 'mock') { + startProving(() => { + if (action === 'shield') return applyShield(wallet, amount) + if (action === 'transfer') return applyTransfer(wallet, amount, alias ?? 'unknown') + return applyUnshield(wallet, amount) + }) + return + } + + // Real mode: call wallet-server + setRealPending(true) + setRealOpError(null) + try { + if (action === 'shield') { + await walletShield(tz1!, amount) + } else if (action === 'transfer') { + const contact = contacts.find(c => c.alias === alias) + if (!contact) throw new Error('Contact not found') + await walletTransfer(JSON.parse(contact.address), amount) + } else { + await walletUnshield(tz1!, amount) + } + await walletScan() + const newBal = await getWalletBalance() + setRealPrivBalance(newBal) + setRealTxHistory(prev => [{ + id: Math.random().toString(36).slice(2), + type: action, + amount, + recipient: action === 'transfer' ? alias : undefined, + proof: null, + timestamp: Date.now(), + }, ...prev]) + } catch (e) { + setRealOpError(e instanceof Error ? e.message : 'unknown error') + } finally { + setRealPending(false) + } + }, [activeAction, mode, wallet, contacts, startProving]) + + const resetWallet = () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + setWallet(INITIAL_STATE) + setPhase('idle') + setProofProgress(0) + setActiveAction(null) + setPendingProof(null) + } + + return ( +
+ {/* Header */} +
+
+ TzEL + Private Wallet +
+
+ {mode === 'mock' && MOCK MODE} + {mode === 'mock' && ( +
+ Proof time + {PROOF_SPEED_PRESETS.map(p => ( + + ))} +
+ )} + +
+
+ + {mode === 'mock' ? ( + <> + {/* Balance */} + + + {/* Action buttons */} +
+ {(['shield', 'transfer', 'unshield'] as TxType[]).map(type => { + const disabled = isBusy + || (type === 'shield' && pubBalance === 0) + || (type === 'transfer' && privBalance === 0) + || (type === 'unshield' && privBalance === 0) + return ( + + ) + })} + +
+ + {/* Inline action form */} + {activeAction && !isBusy && ( + setActiveAction(null)} + onAddContact={addContact} + /> + )} + + {/* Proof animation */} + {isBusy && ( + + )} + + {/* Last proof banner */} + {phase === 'done' && pendingProof && ( +
+
STARK proof verified
+
+
{pendingProof.sizeKb} KBproof size
+
+
{(pendingProof.generationMs / 1000).toFixed(1)}sgeneration
+
+
~35msverification
+
+
POST-QUANTUM
+
+ )} + + {/* Transaction history */} +
Transaction history
+ + + {/* Chain view */} + + + ) : ( + <> + {/* Connection errors */} + {(realWalletError || realError) && ( +
+ {realWalletError && ( +
+ wallet-server {realWalletError} + · run: wallet-server --trust-me-bro +
+ )} + {realError && ( +
+ sp-ledger {realError} + · run: sp-ledger --trust-me-bro +
+ )} +
+ )} + + {/* Balance */} + + + {/* Action buttons */} +
+ {(['shield', 'transfer', 'unshield'] as TxType[]).map(type => { + const disabled = realPending + || !!realWalletError + || (type === 'transfer' && (realPrivBalance ?? 0) === 0) + || (type === 'unshield' && (realPrivBalance ?? 0) === 0) + return ( + + ) + })} + +
+ + {/* Receive address */} +
+ + {showReceive && myAddress && ( +
+
{truncateHex(JSON.stringify(myAddress), 30, 20)}
+
+ + + +
+
Share this file with the sender — each address is single-use.
+
+ )} +
+ + {/* Inline action form */} + {activeAction && !realPending && ( + setActiveAction(null)} + onAddContact={addContact} + /> + )} + + {/* Pending overlay */} + {realPending && ( +
+
+ Submitting + + Scan +
+
Sending transaction to ledger…
+
+
trust-me-bro · no proof generation
+
+ )} + + {/* Op error */} + {realOpError && ( +
+ {realOpError} + +
+ )} + + {/* Transaction history */} +
Transaction history
+ + + {/* Chain view */} + {realState && ( + + )} + + )} +
+ ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..df57956 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/mockService.ts b/web/src/mockService.ts new file mode 100644 index 0000000..dfa334a --- /dev/null +++ b/web/src/mockService.ts @@ -0,0 +1,107 @@ +import type { MockWalletState, Note, TxRecord, ProofInfo } from './types' + +export const INJECT_DELAY_MS = 1200 +export const VERIFY_DELAY_MS = 600 + +export const PROOF_SPEED_PRESETS = [ + { label: 'Fast', ms: 2000 }, + { label: 'Normal', ms: 10000 }, + { label: 'Realistic', ms: 30000 }, +] as const + +export type ProofSpeed = typeof PROOF_SPEED_PRESETS[number]['label'] + +function randomHex(): string { + return '0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('') +} + +function randomProof(): ProofInfo { + return { + sizeKb: 300 + Math.floor(Math.random() * 20), + generationMs: 2500 + Math.floor(Math.random() * 800), + } +} + +export const INITIAL_STATE: MockWalletState = { + publicBalance: 200, + notes: [], + history: [], + merkleSize: 0, + merkleRoot: '0x' + '0'.repeat(64), + nullifiers: [], +} + +export function privateBalance(state: MockWalletState): number { + return state.notes.filter(n => !n.spent).reduce((sum, n) => sum + n.amount, 0) +} + +export function applyShield(state: MockWalletState, amount: number): { state: MockWalletState; proof: ProofInfo } { + const proof = randomProof() + const noteIndex = state.merkleSize + const note: Note = { id: `note_${noteIndex}`, amount, merkleIndex: noteIndex, spent: false } + const tx: TxRecord = { id: `tx_${Date.now()}`, type: 'shield', amount, proof, timestamp: Date.now() } + return { + proof, + state: { + ...state, + publicBalance: state.publicBalance - amount, + notes: [...state.notes, note], + history: [tx, ...state.history], + merkleSize: state.merkleSize + 1, + merkleRoot: randomHex(), + }, + } +} + +export function applyTransfer(state: MockWalletState, amount: number, recipient: string): { state: MockWalletState; proof: ProofInfo } { + const proof = randomProof() + const source = state.notes.find(n => !n.spent && n.amount >= amount) + ?? state.notes.filter(n => !n.spent).sort((a, b) => b.amount - a.amount)[0] + if (!source) throw new Error('No active note') + + const change = source.amount - amount + const updatedNotes = state.notes.map(n => n.id === source.id ? { ...n, spent: true } : n) + const changeNote: Note | null = change > 0 + ? { id: `note_${state.merkleSize + 1}`, amount: change, merkleIndex: state.merkleSize + 1, spent: false } + : null + const tx: TxRecord = { id: `tx_${Date.now()}`, type: 'transfer', amount, recipient, proof, timestamp: Date.now() } + + return { + proof, + state: { + ...state, + notes: changeNote ? [...updatedNotes, changeNote] : updatedNotes, + history: [tx, ...state.history], + merkleSize: state.merkleSize + (change > 0 ? 2 : 1), + merkleRoot: randomHex(), + nullifiers: [...state.nullifiers, randomHex()], + }, + } +} + +export function applyUnshield(state: MockWalletState, amount: number): { state: MockWalletState; proof: ProofInfo } { + const proof = randomProof() + const source = state.notes.find(n => !n.spent && n.amount >= amount) + ?? state.notes.filter(n => !n.spent).sort((a, b) => b.amount - a.amount)[0] + if (!source) throw new Error('No active note') + + const change = source.amount - amount + const updatedNotes = state.notes.map(n => n.id === source.id ? { ...n, spent: true } : n) + const changeNote: Note | null = change > 0 + ? { id: `note_${state.merkleSize}`, amount: change, merkleIndex: state.merkleSize, spent: false } + : null + const tx: TxRecord = { id: `tx_${Date.now()}`, type: 'unshield', amount, proof, timestamp: Date.now() } + + return { + proof, + state: { + ...state, + publicBalance: state.publicBalance + amount, + notes: changeNote ? [...updatedNotes, changeNote] : updatedNotes, + history: [tx, ...state.history], + merkleSize: state.merkleSize + (change > 0 ? 1 : 0), + merkleRoot: randomHex(), + nullifiers: [...state.nullifiers, randomHex()], + }, + } +} diff --git a/web/src/realService.ts b/web/src/realService.ts new file mode 100644 index 0000000..bdbd3f0 --- /dev/null +++ b/web/src/realService.ts @@ -0,0 +1,75 @@ +export interface RealLedgerState { + balances: Record + merkleSize: number + merkleRoot: string + nullifiers: string[] +} + +async function apiFetch(path: string): Promise { + const res = await fetch(`/api${path}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() as Promise +} + +export async function fetchLedgerState(): Promise { + const [balancesResp, treeResp, nullifiersResp] = await Promise.all([ + apiFetch<{ balances: Record }>('/balances'), + apiFetch<{ root: string | number; size: number }>('/tree'), + apiFetch<{ nullifiers: (string | number)[] }>('/nullifiers'), + ]) + + return { + balances: balancesResp.balances, + merkleSize: treeResp.size, + merkleRoot: String(treeResp.root), + nullifiers: nullifiersResp.nullifiers.map(String), + } +} + +// ─── Wallet-server (port 8081) ───────────────────────────────────────────── + +async function walletFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`/wallet${path}`, init) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`HTTP ${res.status}${text ? ': ' + text : ''}`) + } + return res.json() as Promise +} + +export async function getWalletBalance(): Promise { + const r = await walletFetch<{ private_balance: number }>('/balance') + return r.private_balance +} + +export async function getWalletAddress(): Promise> { + return walletFetch('/address') +} + +export async function walletShield(sender: string, amount: number): Promise { + await walletFetch('/shield', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sender, amount }), + }) +} + +export async function walletTransfer(to: unknown, amount: number): Promise { + await walletFetch('/transfer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to, amount }), + }) +} + +export async function walletUnshield(recipient: string, amount: number): Promise { + await walletFetch('/unshield', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipient, amount }), + }) +} + +export async function walletScan(): Promise<{ found: number; spent: number }> { + return walletFetch('/scan', { method: 'POST' }) +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..da5bb03 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,36 @@ +export interface Note { + id: string + amount: number + merkleIndex: number + spent: boolean +} + +export interface ProofInfo { + sizeKb: number + generationMs: number +} + +export type TxType = 'shield' | 'transfer' | 'unshield' + +export interface TxRecord { + id: string + type: TxType + amount: number + recipient?: string + proof: ProofInfo | null + timestamp: number +} + +export interface MockWalletState { + publicBalance: number + notes: Note[] + history: TxRecord[] + merkleSize: number + merkleRoot: string + nullifiers: string[] +} + +export interface Contact { + alias: string + address: string +} diff --git a/web/src/useAddressBook.ts b/web/src/useAddressBook.ts new file mode 100644 index 0000000..2761bb1 --- /dev/null +++ b/web/src/useAddressBook.ts @@ -0,0 +1,47 @@ +import { useState, useCallback } from 'react' +import type { Contact } from './types' + +const STORAGE_KEY = 'tzel_contacts' + +const DEFAULT_CONTACTS: Contact[] = [ + { alias: 'Bob', address: 'tzel1m9f3a...(mock)' }, +] + +function loadContacts(): Contact[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as Contact[] + if (Array.isArray(parsed) && parsed.length > 0) return parsed + } + } catch { + // ignore parse errors + } + return DEFAULT_CONTACTS +} + +function saveContacts(contacts: Contact[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(contacts)) +} + +export function useAddressBook() { + const [contacts, setContacts] = useState(loadContacts) + + const addContact = useCallback((c: Contact) => { + setContacts(prev => { + const next = [...prev.filter(x => x.alias !== c.alias), c] + saveContacts(next) + return next + }) + }, []) + + const removeContact = useCallback((alias: string) => { + setContacts(prev => { + const next = prev.filter(x => x.alias !== alias) + saveContacts(next) + return next + }) + }, []) + + return { contacts, addContact, removeContact } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4c77361 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..8a38923 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + rewrite: (path) => path.replace(/^\/api/, ''), + changeOrigin: true, + }, + '/wallet': { + target: 'http://localhost:8081', + rewrite: (path) => path.replace(/^\/wallet/, ''), + changeOrigin: true, + }, + }, + }, +}) From 52d7f29d3fb8fbbb6045b3d2906f38eb709808e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Thir=C3=A9?= Date: Fri, 17 Apr 2026 23:41:29 +0200 Subject: [PATCH 5/6] fix(wallet-server): address audit findings - Rename --trust-me-bro to --skip-proof - Fix transfer/unshield atomicity: drop pre-POST save_wallet; notes are only committed to disk via finalize_successful_spend after a confirmed ledger response, matching the shield handler's pattern - Normalise ledger error status codes to 502 BAD_GATEWAY across all handlers - Change POST /address (was GET): endpoint mutates addr_counter so GET semantics were wrong; add block_in_place for consistency - Remove three paraphrasing inline comments - Update docs and web dapp to match Co-Authored-By: Claude Sonnet 4.6 --- apps/wallet/src/lib.rs | 41 ++++++++++++++++++----------------------- docs/wallet_server.md | 12 ++++++------ web/src/realService.ts | 2 +- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/apps/wallet/src/lib.rs b/apps/wallet/src/lib.rs index 609390c..d49c128 100644 --- a/apps/wallet/src/lib.rs +++ b/apps/wallet/src/lib.rs @@ -929,7 +929,7 @@ impl WalletFile { .filter(|(_, note)| !pending.contains(¬e_nullifier(note))) .map(|(i, n)| (i, n.v)) .collect(); - indexed.sort_by(|a, b| b.1.cmp(&a.1)); // largest first + indexed.sort_by(|a, b| b.1.cmp(&a.1)); let mut sum = 0u128; let mut selected = vec![]; for (i, v) in indexed { @@ -2775,12 +2775,10 @@ fn felt_to_hex(f: &F) -> String { // Convert LE bytes to big integer, then to hex let mut val = [0u8; 32]; val.copy_from_slice(f); - // Reverse to big-endian for hex display let mut be = [0u8; 32]; for i in 0..32 { be[i] = val[31 - i]; } - // Strip leading zeros let hex_str = hex::encode(be); let trimmed = hex_str.trim_start_matches('0'); if trimmed.is_empty() { @@ -7039,7 +7037,7 @@ mod network_profile_tests { } // ═══════════════════════════════════════════════════════════════════════ -// Wallet HTTP server (trust-me-bro mode) +// Wallet HTTP server // ═══════════════════════════════════════════════════════════════════════ pub fn wallet_server_entry() { @@ -7057,7 +7055,7 @@ pub fn wallet_server_entry() { let mut wallet_path = "wallet.json".to_string(); let mut ledger_url = "http://localhost:8080".to_string(); let mut port: u16 = 8081; - let mut trust_me_bro = false; + let mut skip_proof = false; let mut proving_service: Option = None; let mut i = 1; @@ -7075,8 +7073,8 @@ pub fn wallet_server_entry() { port = v.parse().unwrap_or(8081); } else if arg == "--port" { i += 1; if i < args.len() { port = args[i].parse().unwrap_or(8081); } - } else if arg == "--trust-me-bro" { - trust_me_bro = true; + } else if arg == "--skip-proof" { + skip_proof = true; } else if let Some(v) = arg.strip_prefix("--proving-service=") { proving_service = Some(v.to_string()); } else if arg == "--proving-service" { @@ -7086,13 +7084,12 @@ pub fn wallet_server_entry() { } let pc = ProveConfig { - skip_proof: trust_me_bro, + skip_proof, reprove_bin: String::new(), executables_dir: String::new(), proving_service_url: proving_service, }; - // Create wallet if it does not exist. if !std::path::Path::new(&wallet_path).exists() { cmd_keygen(&wallet_path).expect("failed to create wallet"); } @@ -7113,11 +7110,13 @@ pub fn wallet_server_entry() { async fn address_handler( State(st): State, ) -> Result, (StatusCode, String)> { - let mut guard = st.lock().unwrap(); - let (ref mut w, ref path, _, _) = *guard; - let (_, addr) = w.next_address().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - Ok(Json(addr)) + tokio::task::block_in_place(|| { + let mut guard = st.lock().unwrap(); + let (ref mut w, ref path, _, _) = *guard; + let (_, addr) = w.next_address().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(addr)) + }) } #[derive(serde::Deserialize)] @@ -7184,7 +7183,7 @@ pub fn wallet_server_entry() { }; let resp: ShieldResp = post_json(&format!("{}/shield", ledger), &req) - .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; // Save only after successful POST so a failed shield doesn't // permanently consume an address slot on disk. @@ -7219,8 +7218,6 @@ pub fn wallet_server_entry() { .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))? }; - save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - let resp: TransferResp = post_json(&format!("{}/transfer", ledger), &prepared.req) .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; @@ -7257,8 +7254,6 @@ pub fn wallet_server_entry() { .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))? }; - save_wallet(path, w).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - let resp: UnshieldResp = post_json(&format!("{}/unshield", ledger), &prepared.req) .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; @@ -7293,19 +7288,19 @@ pub fn wallet_server_entry() { }) } - let mode = if trust_me_bro { - "trust-me-bro".to_string() + let mode = if skip_proof { + "skip-proof".to_string() } else if let Some(ref url) = pc.proving_service_url { format!("proving-service={}", url) } else { - "trust-me-bro (default)".to_string() + "skip-proof (default)".to_string() }; let state: WalletState = Arc::new(Mutex::new((wallet, wallet_path, ledger_url, pc))); let app = Router::new() .route("/balance", get(balance_handler)) - .route("/address", get(address_handler)) + .route("/address", post(address_handler)) .route("/shield", post(shield_handler)) .route("/transfer", post(transfer_handler)) .route("/unshield", post(unshield_handler)) diff --git a/docs/wallet_server.md b/docs/wallet_server.md index d96693b..dcdc0d7 100644 --- a/docs/wallet_server.md +++ b/docs/wallet_server.md @@ -15,7 +15,7 @@ wallet-server \ --wallet=/path/to/wallet.json \ --ledger=http://:8787 \ --port=8081 \ - --trust-me-bro # skip STARK proofs (demo/dev only) + --skip-proof # skip STARK proofs (demo/dev only) ``` With a real proving service: @@ -40,9 +40,9 @@ Returns the available private balance. { "private_balance": 1000000 } ``` -### `GET /wallet/address` +### `POST /address` -Generates (or returns) the next unused payment address. The address is a JSON blob containing the KEM public keys and diversifier needed for a sender to encrypt a note to this wallet. +Generates the next payment address and persists the wallet. The address is a JSON blob containing the KEM public keys and diversifier needed for a sender to encrypt a note to this wallet. Each call advances the address counter — share the result with senders and call again to get a fresh address. ### `POST /scan` @@ -83,7 +83,7 @@ Withdraws private funds to a public L1 address. ### Prerequisites -- A running `tzel-operator` connected to an L1 node (or use the `--trust-me-bro` flag to skip proofs) +- A running `tzel-operator` connected to an L1 node (or use the `--skip-proof` flag to skip proofs) - A funded L1 address in the octez-client keychain (for shield) ### Step-by-step @@ -91,7 +91,7 @@ Withdraws private funds to a public L1 address. 1. **Start the wallet server** ```bash -wallet-server --wallet=/tmp/test-wallet.json --ledger=http://localhost:8787 --port=8081 --trust-me-bro +wallet-server --wallet=/tmp/test-wallet.json --ledger=http://localhost:8787 --port=8081 --skip-proof ``` 2. **Check balance** (should be 0 on a fresh wallet) @@ -103,7 +103,7 @@ curl http://localhost:8081/balance 3. **Get a payment address** ```bash -curl http://localhost:8081/wallet/address +curl -X POST http://localhost:8081/address ``` 4. **Shield funds** diff --git a/web/src/realService.ts b/web/src/realService.ts index bdbd3f0..7bdb991 100644 --- a/web/src/realService.ts +++ b/web/src/realService.ts @@ -43,7 +43,7 @@ export async function getWalletBalance(): Promise { } export async function getWalletAddress(): Promise> { - return walletFetch('/address') + return walletFetch('/address', { method: 'POST' }) } export async function walletShield(sender: string, amount: number): Promise { From f80b5b080ba1625aaaa482abdfcf6c0043b4ff8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Thir=C3=A9?= Date: Sat, 18 Apr 2026 01:12:06 +0200 Subject: [PATCH 6/6] fix(core): restore verify_wots_signature_against_leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed by mistake in the audit cleanup — the rollup kernel uses it to authenticate signed verifier and bridge configs. Co-Authored-By: Claude Sonnet 4.6 --- core/src/lib.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/core/src/lib.rs b/core/src/lib.rs index 03b1c67..1822358 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -627,6 +627,28 @@ pub fn recover_wots_pk(msg_hash: &F, pub_seed: &F, key_idx: u32, sig: &[F]) -> V .collect() } +pub fn verify_wots_signature_against_leaf( + msg_hash: &F, + pub_seed: &F, + key_idx: u32, + sig: &[F], + expected_leaf: &F, +) -> Result<(), String> { + if sig.len() != WOTS_CHAINS { + return Err(format!( + "bad WOTS signature length: got {}, expected {}", + sig.len(), + WOTS_CHAINS + )); + } + let recovered_pk = recover_wots_pk(msg_hash, pub_seed, key_idx, sig); + let recovered_leaf = wots_pk_to_leaf(pub_seed, key_idx, &recovered_pk); + if &recovered_leaf != expected_leaf { + return Err("configuration signature verification failed".into()); + } + Ok(()) +} + pub fn xmss_subtree_root(ask_j: &F, pub_seed: &F, start: u32, height: usize) -> F { if height == AUTH_DEPTH && start == 0 { assert_full_xmss_rebuild_allowed("xmss_subtree_root");