From 3688e307773f98bd53e430cb6a9044ec71619f94 Mon Sep 17 00:00:00 2001 From: reid Date: Mon, 5 Oct 2026 18:09:39 -0500 Subject: [PATCH] add vanity PKH tooling --- Cargo.toml | 4 +- README.md | 204 ++++++- examples/vanity-pkh/Cargo.toml | 16 + examples/vanity-pkh/src/main.rs | 372 ++++++++++++ examples/vanity-pkh/tests/cli.rs | 92 +++ tx-types/Cargo.toml | 5 +- tx-types/examples/vanity_gpu_table.rs | 29 + tx-types/src/crypto/cheetah_nostd.rs | 160 +++-- tx-types/src/crypto/mod.rs | 6 +- tx-types/src/crypto/vanity/export.rs | 32 + tx-types/src/crypto/vanity/mnemonic.rs | 131 ++++ tx-types/src/crypto/vanity/mod.rs | 573 ++++++++++++++++++ tx-types/tests/vanity_search.rs | 78 +++ tx-types/vanity/browser/gpu.js | 111 ++++ tx-types/vanity/browser/miner.d.ts | 39 ++ tx-types/vanity/browser/miner.js | 58 ++ tx-types/vanity/browser/mnemonic-gpu.js | 96 +++ tx-types/vanity/browser/mnemonic-self-test.js | 33 + tx-types/vanity/browser/self-test.js | 47 ++ tx-types/vanity/browser/shaders/cheetah.wgsl | 92 +++ tx-types/vanity/browser/shaders/encoding.wgsl | 70 +++ tx-types/vanity/browser/shaders/field.wgsl | 79 +++ .../vanity/browser/shaders/fixed-base.wgsl | 47 ++ tx-types/vanity/browser/shaders/mnemonic.wgsl | 101 +++ tx-types/vanity/browser/shaders/search.wgsl | 24 + tx-types/vanity/browser/shaders/sha512.wgsl | 137 +++++ tx-types/vanity/browser/shaders/tip5.wgsl | 47 ++ tx-types/vanity/browser/wasm.js | 128 ++++ tx-types/vanity/browser/worker.js | 162 +++++ tx-types/vanity/build-browser.py | 79 +++ tx-types/vanity/wasm/Cargo.toml | 16 + tx-types/vanity/wasm/src/lib.rs | 547 +++++++++++++++++ tx-types/vanity/wasm/src/mnemonic_gpu.rs | 194 ++++++ 33 files changed, 3744 insertions(+), 65 deletions(-) create mode 100644 examples/vanity-pkh/Cargo.toml create mode 100644 examples/vanity-pkh/src/main.rs create mode 100644 examples/vanity-pkh/tests/cli.rs create mode 100644 tx-types/examples/vanity_gpu_table.rs create mode 100644 tx-types/src/crypto/vanity/export.rs create mode 100644 tx-types/src/crypto/vanity/mnemonic.rs create mode 100644 tx-types/src/crypto/vanity/mod.rs create mode 100644 tx-types/tests/vanity_search.rs create mode 100644 tx-types/vanity/browser/gpu.js create mode 100644 tx-types/vanity/browser/miner.d.ts create mode 100644 tx-types/vanity/browser/miner.js create mode 100644 tx-types/vanity/browser/mnemonic-gpu.js create mode 100644 tx-types/vanity/browser/mnemonic-self-test.js create mode 100644 tx-types/vanity/browser/self-test.js create mode 100644 tx-types/vanity/browser/shaders/cheetah.wgsl create mode 100644 tx-types/vanity/browser/shaders/encoding.wgsl create mode 100644 tx-types/vanity/browser/shaders/field.wgsl create mode 100644 tx-types/vanity/browser/shaders/fixed-base.wgsl create mode 100644 tx-types/vanity/browser/shaders/mnemonic.wgsl create mode 100644 tx-types/vanity/browser/shaders/search.wgsl create mode 100644 tx-types/vanity/browser/shaders/sha512.wgsl create mode 100644 tx-types/vanity/browser/shaders/tip5.wgsl create mode 100644 tx-types/vanity/browser/wasm.js create mode 100644 tx-types/vanity/browser/worker.js create mode 100644 tx-types/vanity/build-browser.py create mode 100644 tx-types/vanity/wasm/Cargo.toml create mode 100644 tx-types/vanity/wasm/src/lib.rs create mode 100644 tx-types/vanity/wasm/src/mnemonic_gpu.rs diff --git a/Cargo.toml b/Cargo.toml index 2c2f037..c2460bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["tx-types", "pokenoun"] +members = ["tx-types", "pokenoun", "examples/vanity-pkh", "tx-types/vanity/wasm"] resolver = "2" [workspace.package] @@ -72,7 +72,7 @@ tracing-core = "0.1" tracing-tracy = { version = "0.11.4" } tracy-client = { version = "0.18.2" } x25519-dalek = { version = "2.0.0", features = ["static_secrets"], default-features = false } -bip39 = { version = "2.0", default-features = false, features = ["std"] } +bip39 = { version = "2.0", default-features = false } hmac = "0.12" pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } zeroize = { version = "1.0", default-features = false, features = ["alloc"] } diff --git a/README.md b/README.md index cc61cd2..c2068a9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,208 @@ Add this to your `Cargo.toml`: tx-types = { git = "https://github.com/SWPSCO/tx-types" } ``` +## Vanity address library + +The `tx_types::crypto::vanity` module mines Nockchain public-key hash prefixes. +The example CLI in `examples/vanity-pkh` searches a case-sensitive prefix: + +```sh +cargo run --release -p vanity-pkh -- nock --output ./nock-key.json +``` + +Use `--threads 8` to select the worker count and `--max-attempts 1000000` to +bound the total search. The default uses all available CPU workers. Prefixes +use the Base58 alphabet, which excludes `0`, `O`, `I`, and `l` in exact mode. +Each additional exact character usually multiplies the work by about 58; the +distribution of the first character depends on the integer encoding. + +Use `--insensitive` to ignore ASCII case and accept letter/digit alternatives: + +```sh +cargo run --release -p vanity-pkh -- Reid --insensitive --output ./reid-key.json +``` + +The pairs are `a/4`, `b/8`, `e/3`, `i/1`, `l/1`, `o/0`, `s/5`, `t/7`, and +`z/2`. The letters `i/I` and `l/L` remain distinct: an `I` prefix matches `i` +or `1`, **never capital `L`**. A typed `1` can match `1`, `i`, or `L`. +The browser library uses the same matching rules. Resulting addresses always use +canonical Base58; alternatives affect matching only. + +The default searches 256-bit entropy values and produces a 24-word English +BIP39 mnemonic. Every candidate follows BIP39 seed derivation with an empty +passphrase, then the shared SLIP-10-over-Cheetah master derivation. Restore the +phrase in Nockster at path `m`, with an empty passphrase, to recover the matching +address. The tests pin this behavior to a `nockchain-wallet` mnemonic/address +vector in Nockster. + +The program prints only the matching PKH to stdout and progress to stderr. Its +private JSON contains `mnemonic`, `derivation_path` (`m`), `passphrase` (`""`), +`pkh`, `public_key_base58`, `secret_key_hex_be` (a 32-byte big-endian scalar), and +`secret_key_base58` (Base58 of those same 32 bytes). Keep this file private and +backed up. Restore using the phrase; the JSON itself is not a wallet import file. + +Use `--raw-key` to search raw private scalars. This is faster because candidates +advance by curve addition instead of repeating BIP39 and master-key derivation. +Raw-key exports omit the mnemonic and its derivation settings; a wallet seed +phrase cannot be recovered from a raw scalar. + +The output path must not exist. It is reserved before mining, with mode `0600` +on Unix, and remains empty if the search ends without a match. Exit status is +`0` for success, `2` for reaching the attempt limit, and `1` for an error. Ctrl-C +stops the process; there is no checkpoint file. Each run uses fresh OS entropy +and saves one match. + +### Browser: WebGPU and WASM + +The reusable browser runtime lives in `tx-types/vanity/browser`, with a thin +WASM bridge in `tx-types/vanity/wasm`. Both mnemonic and raw-key modes select +WebGPU when a device is available and otherwise use the shared `no_std` core +through WASM on CPU. The native CLI uses CPU workers. GPU compilation or +verification errors stop the search. + +Build the browser assets into a host application's static directory: + +```sh +rustup target add wasm32-unknown-unknown +python3 tx-types/vanity/build-browser.py --out-dir ../my-app/public/vendor/vanity +``` + +The builder compiles the Rust WASM module, copies the JavaScript runtime and +WGSL shaders, derives shader constants from Rust source, and generates the +public fixed-base curve table with the Rust curve implementation. The runtime +has no DOM, framework, npm, CDN, remote service, or key-upload dependency. Serve +its assets over HTTPS or localhost. + +The Nockster page and browser integration tests live in the +[nockster.com repository](https://github.com/SWPSCO/nockster.com). Its build +consumes this library; page markup and styling belong to the host application. + +WebCrypto supplies random entropy to WASM. In mnemonic mode, WASM encodes each +entropy value as a BIP39 phrase and prepares the HMAC key block. The GPU performs +all 2048 PBKDF2 rounds, SLIP-10 master derivation, fixed-base scalar multiplication, +PKH hashing, and prefix matching. Scalar multiplication uses a four-bit generator +table and Jacobian mixed additions, requiring only one final extension-field +inversion. PBKDF2 runs in sixteen bounded passes after its initial round. + +Mnemonic GPU buffers contain private HMAC key material and derived keys. They +stay on the local device, and the input and intermediate buffers are cleared in +queue order after every batch. Temporary host input copies are erased after +upload. WASM retains the entropy needed to re-derive a winning phrase and +independently verifies its address before export. + +In raw-key mode, WASM retains private scalars and uploads only public points to +WebGPU. That shader advances curve points, hashes PKHs, and tests prefixes. +Both modes use portable `u32` pairs for 64-bit arithmetic and shared Rust prefix +masks. Device self-tests compare GPU results with Rust before production entropy +is generated. Compilation and parity failures stop the search. + +The browser runs bounded batches in a Web Worker and stops on device loss or a +verification failure. Each raw-key lane reserves at most `u32::MAX` candidate offsets +below the curve order. Raw-key CPU walks use the same bounded offset range. +Mnemonic search increments the entropy and stops before wrapping. There is +no checkpoint. Mnemonic GPU batches use 4,096 lanes by default (configurable +from 1 to 4,096); raw-key batches default to 64 lanes (1 to 256). Smaller batches +reduce latency on slower devices. Throughput depends on the adapter and driver. + +### Embed in a wallet or webpage + +Import the public `miner.js` API from your generated asset directory: + +```js +import { mineAddress } from "/vendor/vanity/miner.js"; + +const controller = new AbortController(); +const result = await mineAddress({ + prefix: "Reid", + insensitive: true, + keyMode: "mnemonic", // Default: 24-word phrase, path m, empty passphrase. + backend: "auto", // Both modes: WebGPU when available, otherwise WASM CPU. + maxAttempts: 1_000_000, // 0 means unlimited. + signal: controller.signal, + onProgress(event) { + // status, backend selection, or attempts/rate/elapsed time; no private keys. + updateMiningProgress(event); + }, +}); + +if (result) { + // result.pkh is public. result.keyJson contains the private recovery material. + await savePrivateKey(result.keyJson); + result.keyJson.fill(0); +} +// Call controller.abort() from your Cancel action. Rejection has name AbortError. +``` + +The promise returns `null` when the attempt limit is reached. Results contain +`pkh`, `keyJson` (UTF-8 bytes), `attempts`, and `backend`. TypeScript declarations +ship in `miner.d.ts`. Asset overrides are `workerUrl`, `wasmUrl`, `shaderUrls` +(raw-key shaders), `mnemonicShaderUrls`, and `tableUrl` (generator table). Raw +shader order is field, constants, Cheetah, TIP5, encoding, search. Mnemonic shader +order is field, constants, Cheetah, TIP5, encoding, SHA-512, fixed-base, mnemonic. +`lanes` tunes GPU batch size; `steps` applies only to raw-key GPU mining. Neither +affects CPU search. Private results are delivered to your application, with no +automatic downloads or storage. + +Native Rust consumers enable mnemonic mining without the convenience CLI: + +```toml +[dependencies] +tx-types = { git = "https://github.com/SWPSCO/tx-types", default-features = false, features = ["vanity-mnemonic"] } +``` + +```rust +use tx_types::crypto::vanity::{MatchMode, MnemonicBatch, MnemonicSearch, Prefix, Zeroizing}; + +fn start_search(entropy: [u8; 32]) -> MnemonicSearch { + // Supply 32 bytes from a cryptographic random-number generator. + MnemonicSearch::new(Zeroizing::new(entropy)) +} + +fn search_batch(search: &mut MnemonicSearch) -> Result { + let prefix = Prefix::with_mode("Reid", MatchMode::Insensitive)?; + Ok(search.search_batch(&prefix, 1)) +} +``` + +The Rust library is `no_std` and allocation-free along the search path. The +native CLI uses CPU workers; browser GPU selection belongs to the JavaScript +host and WGSL backend. The `vanity-export` feature adds `key_json` for private +JSON serialization and includes `vanity-mnemonic`. Without optional features, +`tx_types::crypto::vanity` exposes the raw-key `Search` API. The example CLI +is a separate workspace package, invoked with `cargo run -p vanity-pkh`. + +### Search core + +`tx_types::crypto::vanity` supplies `Prefix`, `Search::new`, +`Search::search_batch`, `pkh_from_public_key`, and `encode_pkh`. The CPU runner +depends on `tx-types` with `default-features = false`. The core always uses +`cheetah_nostd`, including when Cargo unifies features with a `std` consumer. + +In raw-key mode, each worker samples an independent nonzero scalar uniformly below the Cheetah +group order. Initialization multiplies the generator by that scalar; each +candidate thereafter takes one affine point addition. The PKH hashes the +public-key noun with TIP5 and encodes the digest as a base-Goldilocks integer +in Base58, using fixed-size buffers throughout. A batch tests a bounded number +of candidates, returns a match and attempt count, and retains the next untested +scalar. It stops at the group order instead of wrapping through zero. + +The search path, including initialization and extension-field inversion, uses +no allocator, host entropy, I/O, threads, or noun arena. Other library functions +can use `alloc`. The WebGPU backend implements the arithmetic in WGSL, while +WASM uses these Rust primitives for key generation and verification. Starting +scalars, mnemonic entropy, and search states are secret, since they determine +every private key in their search. Mnemonic search also uses fixed-size buffers +and erases its entropy, phrase, and scalar buffers when dropped. + +Run the focused checks with: + +```sh +cargo test -p tx-types --no-default-features --features vanity-export +cargo test -p tx-types --features vanity-export --lib +cargo test -p vanity-pkh +cargo test -p vanity-pkh-wasm +``` + ## License Licensed under either of @@ -29,4 +231,4 @@ Licensed under either of * Apache License, Version 2.0 * MIT license -at your option. \ No newline at end of file +at your option. diff --git a/examples/vanity-pkh/Cargo.toml b/examples/vanity-pkh/Cargo.toml new file mode 100644 index 0000000..31255c1 --- /dev/null +++ b/examples/vanity-pkh/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "vanity-pkh" +version.workspace = true +edition.workspace = true +description = "Example CLI for tx-types vanity address mining" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +tx-types = { path = "../../tx-types", default-features = false, features = ["vanity-export"] } +getrandom = { workspace = true } +zeroize = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +bs58 = { workspace = true } diff --git a/examples/vanity-pkh/src/main.rs b/examples/vanity-pkh/src/main.rs new file mode 100644 index 0000000..f4b2221 --- /dev/null +++ b/examples/vanity-pkh/src/main.rs @@ -0,0 +1,372 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use tx_types::crypto::utils_nostd::{be32_lt, is_zero32, CHEETAH_N}; +use tx_types::crypto::vanity::{ + encode_pkh, key_json, Match, MatchMode, Mnemonic, MnemonicSearch, Prefix, Search, +}; +use zeroize::Zeroizing; + +const HELP: &str = "Usage: vanity-pkh --output [--insensitive] [--raw-key] [--threads ] [--max-attempts ] + +Mine a Base58 Nockchain public-key hash prefix (exact matching by default). +Search 24-word BIP39 phrases using Nockster's master address (path m, empty passphrase). + + --output FILE Write the winning key as JSON; file must not exist (0600 on Unix) + --raw-key Search raw scalars instead of recoverable mnemonics (faster) + --threads N CPU workers (default: available parallelism) + --max-attempts N Stop after at most N candidates across all workers + --insensitive Ignore case; match a/4, b/8, e/3, i/1, l/1, o/0, s/5, t/7, z/2 + The letters i and l do not match each other + -h, --help Show this help + +The output contains the mnemonic, derivation settings, private key, public key, and PKH. +With --raw-key the output has no mnemonic. Keep the file private and backed up. +Only the public PKH and search statistics are printed to the terminal."; + +struct Args { + prefix: Prefix, + output: PathBuf, + threads: usize, + max_attempts: u64, + raw_key: bool, +} + +fn parse_args(args: impl Iterator) -> Result, String> { + let mut args = args; + let mut prefix = None; + let mut raw_key = false; + let mut mode = MatchMode::Exact; + let mut output = None; + let mut threads = std::thread::available_parallelism().map_or(1, usize::from); + let mut max_attempts = u64::MAX; + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(None), + "--raw-key" => raw_key = true, + "--insensitive" => mode = MatchMode::Insensitive, + "--output" => { + output = Some(PathBuf::from(args.next().ok_or("missing --output value")?)) + } + "--threads" => { + threads = args + .next() + .ok_or("missing --threads value")? + .parse() + .map_err(|_| "--threads requires a positive integer")?; + if threads == 0 { + return Err("--threads must be greater than zero".into()); + } + } + "--max-attempts" => { + max_attempts = args + .next() + .ok_or("missing --max-attempts value")? + .parse() + .map_err(|_| "--max-attempts requires a positive integer")?; + if max_attempts == 0 { + return Err("--max-attempts must be greater than zero".into()); + } + } + _ if arg.starts_with('-') => return Err(format!("unknown option: {arg}")), + _ if prefix.is_none() => prefix = Some(arg), + _ => return Err("only one prefix is accepted".into()), + } + } + Ok(Some(Args { + prefix: Prefix::with_mode(&prefix.ok_or("a Base58 prefix is required")?, mode) + .map_err(|e| e.to_string())?, + output: output.ok_or("--output is required")?, + threads, + max_attempts, + raw_key, + })) +} + +fn random_search() -> Result { + let mut secret = Zeroizing::new([0; 32]); + loop { + getrandom::fill(secret.as_mut()).map_err(|e| format!("OS entropy failed: {e}"))?; + // Rejection sampling is uniform over all nonzero group scalars. + if !is_zero32(&secret) && be32_lt(&secret, &CHEETAH_N) { + return Search::new(secret).map_err(|e| e.to_string()); + } + } +} + +fn create_output(path: &PathBuf) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|e| format!("cannot create {}: {e}", path.display())) +} + +struct Found { + key: Match, + mnemonic: Option, +} + +fn write_match(file: &mut File, found: &Found) -> Result<(), String> { + let json = key_json(&found.key, found.mnemonic.as_ref()); + file.write_all(json.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("cannot save winning key: {e}")) +} + +enum WorkerSearch { + Mnemonic(MnemonicSearch), + Raw(Search), +} + +impl WorkerSearch { + fn random(raw_key: bool) -> Result { + if raw_key { + return random_search().map(Self::Raw); + } + let mut entropy = Zeroizing::new([0; 32]); + getrandom::fill(entropy.as_mut()).map_err(|e| format!("OS entropy failed: {e}"))?; + Ok(Self::Mnemonic(MnemonicSearch::new(entropy))) + } + + fn batch(&mut self, prefix: &Prefix, limit: u64) -> (u64, Option, bool) { + match self { + Self::Mnemonic(search) => { + let batch = search.search_batch(prefix, limit); + ( + batch.attempts, + batch.matched.map(|found| Found { + key: found.key, + mnemonic: Some(found.mnemonic), + }), + batch.exhausted, + ) + } + Self::Raw(search) => { + let batch = search.search_batch(prefix, limit); + ( + batch.attempts, + batch.matched.map(|key| Found { + key, + mnemonic: None, + }), + batch.exhausted, + ) + } + } + } +} + +fn mine(args: Args) -> Result { + // Reserve the private file before spending time mining or generating keys. + let mut output = create_output(&args.output)?; + let stop = AtomicBool::new(false); + let claimed = AtomicU64::new(0); + let completed = AtomicU64::new(0); + let start = Instant::now(); + eprintln!( + "Mining {} with {} workers; key output: {}", + if args.raw_key { + "raw keys" + } else { + "24-word mnemonics (m, empty passphrase)" + }, + args.threads, + args.output.display() + ); + let winner = std::thread::scope(|scope| -> Result, String> { + let (sender, receiver) = mpsc::channel(); + let mut workers = Vec::new(); + for _ in 0..args.threads { + let sender = sender.clone(); + let (stop, claimed, completed) = (&stop, &claimed, &completed); + let args = &args; + let worker = std::thread::Builder::new().spawn_scoped(scope, move || { + let result = (|| -> Result<(), String> { + let mut search = WorkerSearch::random(args.raw_key)?; + let batch_size = if args.raw_key { 256 } else { 1 }; + while !stop.load(Ordering::Relaxed) { + let claim = + claimed.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { + (n < args.max_attempts) + .then(|| n.saturating_add(batch_size).min(args.max_attempts)) + }); + let Ok(first) = claim else { + break; + }; + let limit = batch_size.min(args.max_attempts - first); + let (attempts, matched, exhausted) = search.batch(&args.prefix, limit); + completed.fetch_add(attempts, Ordering::Relaxed); + if let Some(found) = matched { + if !stop.swap(true, Ordering::Relaxed) { + sender + .send(found) + .map_err(|_| "result receiver disconnected")?; + } + break; + } + if exhausted { + search = WorkerSearch::random(args.raw_key)?; + } + } + Ok(()) + })(); + if result.is_err() { + stop.store(true, Ordering::Relaxed); + } + result + }); + match worker { + Ok(worker) => workers.push(worker), + Err(error) => { + stop.store(true, Ordering::Relaxed); + return Err(format!("cannot start worker: {error}")); + } + } + } + drop(sender); + let winner = loop { + match receiver.recv_timeout(Duration::from_secs(1)) { + Ok(found) => break Some(found), + Err(mpsc::RecvTimeoutError::Disconnected) => break None, + Err(mpsc::RecvTimeoutError::Timeout) => { + let count = completed.load(Ordering::Relaxed); + eprintln!( + "{count} candidates, {:.0}/s, {:.1}s elapsed", + count as f64 / start.elapsed().as_secs_f64(), + start.elapsed().as_secs_f64() + ); + } + } + }; + // Save a winning key before waiting for the remaining workers. + if let Some(found) = &winner { + write_match(&mut output, found)?; + } + for worker in workers { + let result = worker.join().map_err(|_| "mining worker panicked")?; + if winner.is_none() { + result?; + } + } + Ok(winner) + })?; + let count = completed.load(Ordering::Relaxed); + eprintln!( + "Tested {count} candidates in {:.2}s ({:.0}/s)", + start.elapsed().as_secs_f64(), + count as f64 / start.elapsed().as_secs_f64() + ); + if let Some(found) = winner { + println!("{}", encode_pkh(found.key.pkh)); + eprintln!("Key saved to {}", args.output.display()); + Ok(true) + } else { + eprintln!( + "No match within the attempt limit. {} is empty.", + args.output.display() + ); + Ok(false) + } +} + +fn main() { + let result = parse_args(std::env::args().skip(1)).and_then(|args| match args { + Some(args) => mine(args), + None => { + println!("{HELP}"); + Ok(true) + } + }); + match result { + Ok(true) => {} + Ok(false) => std::process::exit(2), + Err(error) => { + eprintln!("error: {error}"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tx_types::crypto::cheetah_nostd::{cheetah_pub_from_sk, ser_a_pt}; + use tx_types::crypto::vanity::pkh_from_public_key; + + #[test] + fn insensitive_flag_works_before_or_after_prefix() { + for input in [ + vec!["--insensitive", "I", "--output", "key.json"], + vec!["I", "--output", "key.json", "--insensitive"], + ] { + let args = parse_args(input.into_iter().map(String::from)) + .unwrap() + .unwrap(); + let masks = args.prefix.digit_masks(); + let alphabet = tx_types::crypto::vanity::BASE58_ALPHABET; + for (letter, expected) in [(b'i', true), (b'1', true), (b'L', false)] { + let index = alphabet.iter().position(|&b| b == letter).unwrap(); + assert_eq!(masks[0][index / 32] & (1 << (index % 32)) != 0, expected); + } + } + assert!(parse_args(["I", "--output", "key.json"].into_iter().map(String::from)).is_err()); + } + + #[test] + fn output_contains_a_recoverable_key_and_matching_address() { + let mut secret_key = Zeroizing::new([0; 32]); + secret_key[31] = 1; + let public_key = cheetah_pub_from_sk(*secret_key); + let pkh = pkh_from_public_key(&public_key); + let found = Match { + secret_key_be: secret_key, + public_key, + pkh, + }; + let path = std::env::temp_dir().join(format!( + "vanity-pkh-output-test-{}.json", + std::process::id() + )); + let mut file = create_output(&path).unwrap(); + write_match( + &mut file, + &Found { + key: Match { + secret_key_be: Zeroizing::new(*found.secret_key_be), + public_key, + pkh, + }, + mnemonic: None, + }, + ) + .unwrap(); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let secret_hex = saved["secret_key_hex_be"].as_str().unwrap(); + assert_eq!(secret_hex, format!("{:064x}", 1)); + let decoded = bs58::decode(saved["secret_key_base58"].as_str().unwrap()) + .into_vec() + .unwrap(); + assert_eq!(decoded, *found.secret_key_be); + assert_eq!(cheetah_pub_from_sk(decoded.try_into().unwrap()), public_key); + assert_eq!(saved["pkh"].as_str().unwrap(), encode_pkh(pkh).as_str()); + assert_eq!( + bs58::decode(saved["public_key_base58"].as_str().unwrap()) + .into_vec() + .unwrap(), + ser_a_pt(&public_key) + ); + drop(file); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/examples/vanity-pkh/tests/cli.rs b/examples/vanity-pkh/tests/cli.rs new file mode 100644 index 0000000..b7676b7 --- /dev/null +++ b/examples/vanity-pkh/tests/cli.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "vanity-pkh-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } +} + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_vanity-pkh")) +} + +#[test] +fn help_and_invalid_arguments() { + assert!(command().arg("--help").output().unwrap().status.success()); + for args in [ + vec![], + vec!["0"], + vec!["abc", "--threads", "0"], + vec!["abc", "--max-attempts", "0"], + vec!["abc", "--unknown"], + vec!["abc", "--output"], + ] { + assert_eq!( + command().args(args).output().unwrap().status.code(), + Some(1) + ); + } +} + +#[test] +fn preserves_existing_key_file() { + let dir = TempDir::new(); + let output = dir.0.join("key.json"); + std::fs::write(&output, "keep this key").unwrap(); + let result = command() + .args(["abc", "--output"]) + .arg(&output) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(1)); + assert_eq!(std::fs::read_to_string(output).unwrap(), "keep this key"); +} + +#[test] +fn global_attempt_limit_and_private_permissions() { + let dir = TempDir::new(); + let output = dir.0.join("key.json"); + let result = command() + .args([ + "2222222222222222222222222222222222222222222222222222222", + "--threads", + "3", + "--max-attempts", + "7", + "--output", + ]) + .arg(&output) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(2)); + assert!(String::from_utf8(result.stderr) + .unwrap() + .contains("Tested 7 candidates")); + assert!(result.stdout.is_empty()); + assert_eq!(std::fs::metadata(&output).unwrap().len(), 0); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(output).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} diff --git a/tx-types/Cargo.toml b/tx-types/Cargo.toml index c4e5e13..2220ba4 100644 --- a/tx-types/Cargo.toml +++ b/tx-types/Cargo.toml @@ -31,7 +31,7 @@ num-traits = { workspace = true, optional = true } bs58 = { workspace = true, features = ["check"], optional = true } anyhow = { workspace = true, optional = true } chrono = { workspace = true, features = ["serde"], optional = true } -bip39 = { workspace = true, optional = true } +bip39 = { workspace = true, optional = true, features = ["zeroize"] } pbkdf2 = { workspace = true, optional = true } bytes = { workspace = true, optional = true } once_cell = { workspace = true, optional = true } @@ -57,9 +57,12 @@ std = [ "dep:anyhow", "dep:chrono", "dep:bip39", + "bip39/std", "dep:pbkdf2", "dep:bytes", "dep:once_cell", ] tracy = [] legacy-tests = [] +vanity-mnemonic = ["dep:bip39"] +vanity-export = ["vanity-mnemonic", "dep:bs58", "dep:serde_json"] diff --git a/tx-types/examples/vanity_gpu_table.rs b/tx-types/examples/vanity_gpu_table.rs new file mode 100644 index 0000000..1dc40ca --- /dev/null +++ b/tx-types/examples/vanity_gpu_table.rs @@ -0,0 +1,29 @@ +//! Generate the public fixed-base table from the same Rust curve primitives. +use std::io::{BufWriter, Write}; +use tx_types::crypto::cheetah_nostd::cheetah_pub_from_sk; +use tx_types::crypto::utils_nostd::add_mod_n; + +fn main() -> std::io::Result<()> { + let path = std::env::args_os().nth(1).expect("output path"); + let mut out = BufWriter::new(std::fs::File::create(path)?); + let mut power = [0; 32]; + power[31] = 1; + for _window in 0..64 { + let mut scalar = [0; 32]; + for digit in 0..16 { + let (x, y) = if digit == 0 { + ([0; 6], [0; 6]) + } else { + cheetah_pub_from_sk(scalar) + }; + for word in x.into_iter().chain(y) { + out.write_all(&word.to_le_bytes())?; + } + scalar = add_mod_n(&scalar, &power); + } + for _ in 0..4 { + power = add_mod_n(&power, &power); + } + } + out.flush() +} diff --git a/tx-types/src/crypto/cheetah_nostd.rs b/tx-types/src/crypto/cheetah_nostd.rs index 30c9e5a..50954a7 100644 --- a/tx-types/src/crypto/cheetah_nostd.rs +++ b/tx-types/src/crypto/cheetah_nostd.rs @@ -5,7 +5,7 @@ use hmac::{Hmac, Mac}; use sha2::Sha512; use zeroize::Zeroize; -use crate::crypto::goldilocks::{bpegcd_full, tip5_permute, Belt, GOLDILOCKS_P}; +use crate::crypto::goldilocks::{tip5_permute, Belt, GOLDILOCKS_P}; use crate::crypto::utils_nostd::{add_mod_n, mul_mod_n}; // ---- Constants -------------------------------------------------------------- @@ -66,7 +66,7 @@ pub struct T8 { // F_{p^6} element (tower) as six Belt limbs #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct F6lt([Belt; 6]); +pub struct F6lt(pub(crate) [Belt; 6]); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CheetahPoint { @@ -134,30 +134,36 @@ fn f6_inv(a: &F6lt) -> F6lt { return *a; } - let mut u = [Belt(0); 7]; - u[..6].copy_from_slice(&a.0); - - // μ(t) = t^6 - 7 - let mu = [ - -Belt(7), - Belt(0), - Belt(0), - Belt(0), - Belt(0), - Belt(0), - Belt(1), - ]; - - let (s, _t, d0) = bpegcd_full(&u, &mu); - let inv_d0 = d0.inv(); - F6lt([ - s[0] * inv_d0, - s[1] * inv_d0, - s[2] * inv_d0, - s[3] * inv_d0, - s[4] * inv_d0, - s[5] * inv_d0, - ]) + // Euclid with degree-six remainders and coefficients reduced modulo t^6-7. + // All scratch space has a fixed size, including the quotient product. + let mut r0 = [Belt(0); 7]; + r0[0] = -Belt(7); + r0[6] = Belt(1); + let mut r1 = [Belt(0); 7]; + r1[..6].copy_from_slice(&a.0); + let mut s0 = F6_ZERO; + let mut s1 = F6_ONE; + while let Some(degree) = r1.iter().rposition(|v| *v != Belt(0)) { + if degree == 0 { + return f6_scal(&s1, r1[0].inv()); + } + let mut quotient = [Belt(0); 7]; + let inv_lead = r1[degree].inv(); + for i in (degree..7).rev() { + let factor = r0[i] * inv_lead; + quotient[i - degree] = factor; + for j in 0..=degree { + r0[i - degree + j] = r0[i - degree + j] - factor * r1[j]; + } + } + core::mem::swap(&mut r0, &mut r1); + quotient[0] = quotient[0] + Belt(7) * quotient[6]; + let q = F6lt(core::array::from_fn(|i| quotient[i])); + let next = f6_sub(&s0, &f6_mul(&q, &s1)); + s0 = s1; + s1 = next; + } + unreachable!("nonzero extension-field element has an inverse") } #[inline] @@ -210,7 +216,7 @@ fn ch_add_unsafe(p: &CheetahPoint, q: &CheetahPoint) -> CheetahPoint { } } #[inline] -fn ch_add(p: &CheetahPoint, q: &CheetahPoint) -> CheetahPoint { +pub(crate) fn ch_add(p: &CheetahPoint, q: &CheetahPoint) -> CheetahPoint { ch_add_unsafe(p, q) } @@ -316,41 +322,24 @@ fn f_montify(a: u64) -> u64 { /// TIP5 hash of a variable-length list of u64 words. /// This follows the upstream nockchain implementation with: /// - RATE=10, padding with [1, 0, 0, ...], Montgomery form, REPLACE absorption -fn tip5_hash_words(words: &[u64]) -> [u64; DIGEST_LENGTH] { +pub(crate) fn tip5_hash_words(words: &[u64]) -> [u64; DIGEST_LENGTH] { let mut state = [0u64; 16]; - // Pad input to be a multiple of RATE - // Hoon always adds padding, even if len % RATE == 0 - let len = words.len(); - let remainder = len % RATE; - let padding_needed = RATE - remainder; // Always add at least [1, 0, ..., 0] - - // Create padded input: original + [1, 0, 0, ...] - // Then montify EVERYTHING (Hoon does padding before montification) - let mut padded = alloc::vec::Vec::with_capacity(len + padding_needed); - for &w in words { - padded.push(w); - } - padded.push(1); - for _ in 1..padding_needed { - padded.push(0); - } - - // Now montify all elements - for i in 0..padded.len() { - padded[i] = f_montify(padded[i]); - } - - // Absorb RATE-sized blocks with REPLACE (not XOR) - let mut offset = 0; - while offset < padded.len() { - // Replace first RATE elements of state with input block - for i in 0..RATE { - state[i] = padded[offset + i]; + let mut chunks = words.chunks_exact(RATE); + for block in &mut chunks { + for (slot, &word) in state[..RATE].iter_mut().zip(block) { + *slot = f_montify(word); } tip5_permute(&mut state); - offset += RATE; } + // Always absorb padding, including when the input fills an entire block. + let tail = chunks.remainder(); + state[..RATE].fill(0); + for (slot, &word) in state.iter_mut().zip(tail) { + *slot = f_montify(word); + } + state[tail.len()] = f_montify(1); + tip5_permute(&mut state); // Demontify the digest (convert from Montgomery form back to normal) [ @@ -1161,6 +1150,63 @@ fn be32_atom_to_t8_le(be: &[u8; 32]) -> T8 { mod tests { use super::*; + #[test] + fn fixed_inverse_matches_polynomial_gcd() { + use crate::crypto::goldilocks::bpegcd_full; + let mut modulus = [Belt(0); 7]; + modulus[0] = -Belt(7); + modulus[6] = Belt(1); + let mut random = 0x91ab_339e_574f_0acdu64; + let mut cases = alloc::vec![F6_ZERO, F6_ONE, GX, GY]; + for i in 0..6 { + let mut a = F6_ZERO; + a.0[i] = Belt(1); + cases.push(a); + } + for _ in 0..64 { + cases.push(F6lt(core::array::from_fn(|_| { + random ^= random << 13; + random ^= random >> 7; + random ^= random << 17; + Belt(random % GOLDILOCKS_P) + }))); + } + for a in cases { + let inverse = f6_inv(&a); + if a == F6_ZERO { + assert_eq!(inverse, F6_ZERO); + continue; + } + let mut polynomial = [Belt(0); 7]; + polynomial[..6].copy_from_slice(&a.0); + let (s, _, d) = bpegcd_full(&polynomial, &modulus); + let reference = F6lt(core::array::from_fn(|i| s[i] * d.inv())); + assert_eq!(inverse, reference); + assert_eq!(f6_mul(&a, &inverse), F6_ONE); + } + } + + #[test] + fn streaming_tip5_matches_reference_at_rate_boundaries() { + for len in [0, 1, 9, 10, 11, 19, 20, 21, 38, 40, 101] { + let words: Vec = (0..len) + .map(|i| GOLDILOCKS_P - 1 - i as u64) + .collect(); + let mut padded = words.clone(); + padded.push(1); + padded.resize((len / RATE + 1) * RATE, 0); + let mut state = [0; 16]; + for block in padded.chunks_exact(RATE) { + for i in 0..RATE { + state[i] = f_montify(block[i]); + } + tip5_permute(&mut state); + } + let expected = core::array::from_fn(|i| f_mont_reduction(state[i] as u128)); + assert_eq!(tip5_hash_words(&words), expected); + } + } + #[test] fn test_schnorr_sign_digest_with_t8_key() { // Test vectors from Hoon with T8 format secret key diff --git a/tx-types/src/crypto/mod.rs b/tx-types/src/crypto/mod.rs index 762c8c4..0707848 100644 --- a/tx-types/src/crypto/mod.rs +++ b/tx-types/src/crypto/mod.rs @@ -7,13 +7,11 @@ /// - SLIP-10 hierarchical deterministic key derivation /// - Utility routines for scalar arithmetic -// For no_std builds, use the self-contained cheetah_nostd and goldilocks modules -#[cfg(not(feature = "std"))] +// Self-contained primitives are also available to hosted mining tools. pub mod goldilocks; -#[cfg(not(feature = "std"))] pub mod utils_nostd; -#[cfg(not(feature = "std"))] pub mod cheetah_nostd; +pub mod vanity; // For std builds, use the new modular structure #[cfg(feature = "std")] diff --git a/tx-types/src/crypto/vanity/export.rs b/tx-types/src/crypto/vanity/export.rs new file mode 100644 index 0000000..f321fd6 --- /dev/null +++ b/tx-types/src/crypto/vanity/export.rs @@ -0,0 +1,32 @@ +use crate::crypto::cheetah_nostd::ser_a_pt; +use alloc::string::String; +use core::fmt::Write; +use zeroize::Zeroizing; + +use super::{encode_pkh, Match, Mnemonic}; + +/// Private JSON for a verified match. Mnemonic recovery uses path `m` and an +/// empty BIP39 passphrase. The returned buffer erases itself on drop. +pub fn key_json(found: &Match, mnemonic: Option<&Mnemonic>) -> Zeroizing { + let mut out = Zeroizing::new(String::with_capacity(1024)); + out.push_str("{\n"); + if let Some(mnemonic) = mnemonic { + // English BIP39 words need no JSON escaping. + writeln!(&mut *out, " \"mnemonic\": \"{mnemonic}\",").unwrap(); + out.push_str(" \"derivation_path\": \"m\",\n \"passphrase\": \"\",\n"); + } + out.push_str(" \"secret_key_hex_be\": \""); + for byte in found.secret_key_be.iter() { + write!(&mut *out, "{byte:02x}").unwrap(); + } + let base58 = Zeroizing::new(bs58::encode(found.secret_key_be.as_ref()).into_string()); + writeln!(&mut *out, "\",\n \"secret_key_base58\": \"{}\",", &*base58).unwrap(); + let public = serde_json::json!({ + "pkh": encode_pkh(found.pkh).as_str(), + "public_key_base58": bs58::encode(ser_a_pt(&found.public_key)).into_string(), + }); + let public = serde_json::to_string_pretty(&public).unwrap(); + out.push_str(&public[2..]); + out.push('\n'); + out +} diff --git a/tx-types/src/crypto/vanity/mnemonic.rs b/tx-types/src/crypto/vanity/mnemonic.rs new file mode 100644 index 0000000..38c270f --- /dev/null +++ b/tx-types/src/crypto/vanity/mnemonic.rs @@ -0,0 +1,131 @@ +//! BIP39 English phrases, empty passphrase, Cheetah master path `m`. + +use crate::crypto::cheetah_nostd::{cheetah_pub_from_sk, master_from_seed}; +pub use bip39::Mnemonic; +use zeroize::{Zeroize, Zeroizing}; + +use super::{encode_pkh, pkh_from_public_key, Match, Prefix}; + +/// Derive the address Nockster displays when importing this phrase with an +/// empty passphrase. This uses the shared SLIP-10-over-Cheetah implementation. +pub fn derive_mnemonic(mnemonic: &Mnemonic) -> Match { + let seed = Zeroizing::new(mnemonic.to_seed_normalized("")); + let (mut scalar, mut chain_code) = master_from_seed(seed.as_ref()); + let secret_key_be = Zeroizing::new(scalar); + scalar.zeroize(); + chain_code.zeroize(); + let public_key = cheetah_pub_from_sk(*secret_key_be); + Match { + secret_key_be, + pkh: pkh_from_public_key(&public_key), + public_key, + } +} + +/// Private recovery material. The mnemonic and scalar erase themselves on drop. +pub struct MnemonicMatch { + pub mnemonic: Mnemonic, + pub key: Match, +} + +pub struct MnemonicBatch { + pub attempts: u64, + pub matched: Option, + pub exhausted: bool, +} + +/// Allocation-free search through 256-bit entropy values. Each candidate runs +/// the complete BIP39 and Cheetah derivation; no scalar offsets are used. +pub struct MnemonicSearch { + entropy: Zeroizing<[u8; 32]>, + exhausted: bool, +} + +impl MnemonicSearch { + /// Start from 32 bytes supplied by a cryptographic random-number generator. + pub fn new(entropy: Zeroizing<[u8; 32]>) -> Self { + Self { + entropy, + exhausted: false, + } + } + + pub fn search_batch(&mut self, prefix: &Prefix, limit: u64) -> MnemonicBatch { + let mut batch = MnemonicBatch { + attempts: 0, + matched: None, + exhausted: self.exhausted, + }; + while batch.attempts < limit && !self.exhausted { + let mnemonic = Mnemonic::from_entropy(self.entropy.as_ref()).expect("256-bit entropy"); + let key = derive_mnemonic(&mnemonic); + batch.attempts += 1; + self.advance(); + if prefix.matches(&encode_pkh(key.pkh)) { + batch.matched = Some(MnemonicMatch { mnemonic, key }); + break; + } + } + batch.exhausted = self.exhausted; + batch + } + + fn advance(&mut self) { + for byte in self.entropy.iter_mut().rev() { + let (next, carry) = byte.overflowing_add(1); + *byte = next; + if !carry { + return; + } + } + self.exhausted = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nockchain_wallet_master_address_vector() { + // Public throwaway vector in nockster-core/src/extended_key.rs. + let phrase = "wedding chef bread absurd leader surge auction access document fiber chunk hurt earn rain swarm cotton leisure ozone drill switch cry jungle soda oxygen"; + let mnemonic = Mnemonic::parse_in_normalized(bip39::Language::English, phrase).unwrap(); + let key = derive_mnemonic(&mnemonic); + assert_eq!( + encode_pkh(key.pkh).as_str(), + "9QnMES6nZnbyqPKfTysvisj1UqvxjSkL4XQaAsMUafGVw2m7rrUHzsW" + ); + } + + #[test] + fn batches_resume_and_phrase_restores_the_winner() { + let mut entropy = [0x42; 32]; + entropy[31] += 2; + let target = Mnemonic::from_entropy(&entropy).unwrap(); + let expected = derive_mnemonic(&target); + let prefix = Prefix::new(encode_pkh(expected.pkh).as_str()).unwrap(); + let mut search = MnemonicSearch::new(Zeroizing::new([0x42; 32])); + assert_eq!(search.search_batch(&prefix, 0).attempts, 0); + assert!(search.search_batch(&prefix, 2).matched.is_none()); + let batch = search.search_batch(&prefix, 3); + assert_eq!(batch.attempts, 1); + let found = batch.matched.unwrap(); + assert_eq!(found.mnemonic, target); + assert_eq!( + *derive_mnemonic(&found.mnemonic).secret_key_be, + *expected.secret_key_be + ); + assert_eq!(found.key.pkh, expected.pkh); + } + + #[test] + fn entropy_exhaustion_does_not_wrap() { + let mut search = MnemonicSearch::new(Zeroizing::new([255; 32])); + let prefix = Prefix::new("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap(); + let batch = search.search_batch(&prefix, 2); + assert_eq!(batch.attempts, 1); + assert!(batch.exhausted); + assert_eq!(search.search_batch(&prefix, 1).attempts, 0); + } +} diff --git a/tx-types/src/crypto/vanity/mod.rs b/tx-types/src/crypto/vanity/mod.rs new file mode 100644 index 0000000..221e645 --- /dev/null +++ b/tx-types/src/crypto/vanity/mod.rs @@ -0,0 +1,573 @@ +//! Allocation-free raw-key and mnemonic PKH prefix searches. +//! +//! Enable `vanity-mnemonic` for BIP39 recovery at path `m` with an empty +//! passphrase, and `vanity-export` for private JSON serialization. +//! Browser hosts use the reusable WebGPU/WASM runtime in `vanity/browser`. +//! +//! The host supplies a uniformly random scalar in `1..CHEETAH_N`. Each search +//! walks consecutive scalars with one affine point addition per candidate. +//! Give parallel workers independent random starting scalars. Keep these starts +//! secret: a start and an offset determine every private key in that walk. + +use core::fmt; +pub use zeroize::Zeroizing; + +#[cfg(feature = "vanity-mnemonic")] +mod mnemonic; +#[cfg(feature = "vanity-mnemonic")] +pub use mnemonic::{derive_mnemonic, Mnemonic, MnemonicBatch, MnemonicMatch, MnemonicSearch}; + +#[cfg(feature = "vanity-export")] +mod export; +#[cfg(feature = "vanity-export")] +pub use export::key_json; + +use super::cheetah_nostd::{ch_add, cheetah_pub_from_sk, tip5_hash_words, CheetahPoint, F6lt, G}; +use super::goldilocks::{Belt, GOLDILOCKS_P}; +use super::utils_nostd::{be32_lt, is_zero32, CHEETAH_N}; + +pub const BASE58_ALPHABET: &[u8; 58] = + b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +pub const MAX_PKH_LEN: usize = 55; +pub type PublicKey = ([u64; 6], [u64; 6]); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + InvalidSecretKey, + InvalidPrefix, + ImpossiblePrefix, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InvalidSecretKey => "secret key must be in 1..CHEETAH_N", + Self::InvalidPrefix => { + "prefix must contain 1 to 55 characters supported by the matching mode" + } + Self::ImpossiblePrefix => "prefix is outside the canonical PKH encoding range", + }) + } +} + +/// Prefix matching rules shared by CPU, WASM, and WebGPU runners. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MatchMode { + #[default] + Exact, + /// Ignore ASCII case and match a/4, b/8, e/3, i/1, l/1, o/0, s/5, t/7, z/2. + /// The i and l letter groups remain distinct; only the digit 1 matches both. + Insensitive, +} + +impl MatchMode { + fn normalize(self, byte: u8) -> u8 { + if self == Self::Exact { + return byte; + } + match byte.to_ascii_lowercase() { + b'4' => b'a', + b'8' => b'b', + b'3' => b'e', + b'0' => b'o', + b'5' => b's', + b'7' => b't', + b'2' => b'z', + other => other, + } + } + + fn accepts(self, pattern: u8, candidate: u8) -> bool { + let a = self.normalize(pattern); + let b = self.normalize(candidate); + a == b + || (self == Self::Insensitive + && ((a == b'1' && matches!(b, b'i' | b'l')) + || (b == b'1' && matches!(a, b'i' | b'l')))) + } +} + +/// Prefix of the canonical Base58 PKH, not the public key. +#[derive(Clone, Copy)] +pub struct Prefix { + bytes: [u8; MAX_PKH_LEN], + len: usize, + mode: MatchMode, +} + +impl Prefix { + pub fn new(text: &str) -> Result { + Self::with_mode(text, MatchMode::Exact) + } + + pub fn with_mode(text: &str, mode: MatchMode) -> Result { + let input = text.as_bytes(); + if input.is_empty() + || input.len() > MAX_PKH_LEN + || !input.iter().all(|&b| { + BASE58_ALPHABET + .iter() + .any(|&candidate| mode.accepts(b, candidate)) + }) + { + return Err(Error::InvalidPrefix); + } + let mut bytes = [0; MAX_PKH_LEN]; + for (dest, &source) in bytes.iter_mut().zip(input) { + *dest = mode.normalize(source); + } + let prefix = Self { + bytes, + len: input.len(), + mode, + }; + // A nonzero integer cannot have a leading '1'. Find the smallest + // matching numeral to check the 55-character upper bound as well. + let mut smallest = [0; MAX_PKH_LEN]; + for i in 0..prefix.len { + let candidate = BASE58_ALPHABET + .iter() + .copied() + .find(|&b| !(i == 0 && prefix.len > 1 && b == b'1') && mode.accepts(bytes[i], b)); + let Some(candidate) = candidate else { + return Err(Error::ImpossiblePrefix); + }; + smallest[i] = candidate; + } + if prefix.len == MAX_PKH_LEN + && smallest.as_slice() > encode_pkh([GOLDILOCKS_P - 1; 5]).as_str().as_bytes() + { + return Err(Error::ImpossiblePrefix); + } + Ok(prefix) + } + + pub fn matches(&self, address: &EncodedPkh) -> bool { + let address = address.as_str().as_bytes(); + address.len() >= self.len + && address + .iter() + .zip(&self.bytes[..self.len]) + .all(|(&candidate, &pattern)| self.mode.accepts(pattern, candidate)) + } + + /// Allowed Base58 digit indices at each position, low 32 bits then high 26. + /// Zero masks terminate the prefix. GPU consumers use the same compiled + /// matching rules without duplicating case or substitution logic. + pub fn digit_masks(&self) -> [[u32; 2]; MAX_PKH_LEN] { + let mut masks = [[0; 2]; MAX_PKH_LEN]; + for (i, mask) in masks[..self.len].iter_mut().enumerate() { + for (digit, &byte) in BASE58_ALPHABET.iter().enumerate() { + if self.mode.accepts(self.bytes[i], byte) { + mask[digit / 32] |= 1 << (digit % 32); + } + } + } + masks + } +} + +/// Fixed-capacity canonical Base58 address. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EncodedPkh { + bytes: [u8; MAX_PKH_LEN], + start: usize, +} + +impl EncodedPkh { + pub fn as_str(&self) -> &str { + core::str::from_utf8(&self.bytes[self.start..]).expect("Base58 is ASCII") + } +} + +impl fmt::Display for EncodedPkh { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Encode `sum(digest[i] * p^i)` as an integer in Base58, where p is Goldilocks. +/// Digest elements must be less than p. No checksum or fixed-width byte padding +/// is part of this encoding. +pub fn encode_pkh(digest: [u64; 5]) -> EncodedPkh { + let mut limbs = [0u64; 5]; + for digit in digest.into_iter().rev() { + assert!(digit < GOLDILOCKS_P, "PKH limb must be a field element"); + let mut carry = digit as u128; + for limb in &mut limbs { + let wide = *limb as u128 * GOLDILOCKS_P as u128 + carry; + *limb = wide as u64; + carry = wide >> 64; + } + debug_assert_eq!(carry, 0); + } + let mut out = EncodedPkh { + bytes: [0; MAX_PKH_LEN], + start: MAX_PKH_LEN, + }; + loop { + let mut remainder = 0u128; + for limb in limbs.iter_mut().rev() { + let wide = (remainder << 64) | *limb as u128; + *limb = (wide / 58) as u64; + remainder = wide % 58; + } + out.start -= 1; + out.bytes[out.start] = BASE58_ALPHABET[remainder as usize]; + if limbs == [0; 5] { + return out; + } + } +} + +/// Hash a finite Cheetah public key in native limb order (least significant +/// coefficient first). Coordinates must be canonical Goldilocks elements. +pub fn pkh_from_public_key(public_key: &PublicKey) -> [u64; 5] { + // Noun [[x0 x1 x2 x3 x4 x5] [y0 y1 y2 y3 y4 y5] %.n]. + // hash-noun-varlen absorbs leaf count, leaves, then the Dyck tree shape. + // Hoon %.n is the atom 1. Each six-tuple has five right-nested cells. + let mut words = [0u64; 38]; + words[0] = 13; + words[1..7].copy_from_slice(&public_key.0); + words[7..13].copy_from_slice(&public_key.1); + words[13] = 1; + words[14..].copy_from_slice(&[ + 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, + ]); + tip5_hash_words(&words) +} + +/// A winning key. Secret bytes are big-endian and zeroized on drop. +/// This type intentionally does not implement Debug or Clone. +pub struct Match { + pub secret_key_be: Zeroizing<[u8; 32]>, + pub public_key: PublicKey, + pub pkh: [u64; 5], +} + +pub struct BatchResult { + pub attempts: u64, + pub matched: Option, + pub exhausted: bool, +} + +/// Resumable search state; no entropy, threads, I/O, or heap allocation. +pub struct Search { + secret_key: Zeroizing<[u8; 32]>, + point: CheetahPoint, + exhausted: bool, +} + +impl Search { + /// The caller must use cryptographic entropy to choose a production key. + pub fn new(secret_key: Zeroizing<[u8; 32]>) -> Result { + if is_zero32(&secret_key) || !be32_lt(&secret_key, &CHEETAH_N) { + return Err(Error::InvalidSecretKey); + } + let (x, y) = cheetah_pub_from_sk(*secret_key); + Ok(Self { + secret_key, + point: CheetahPoint { + x: F6lt(x.map(Belt)), + y: F6lt(y.map(Belt)), + inf: false, + }, + exhausted: false, + }) + } + + /// Test at most `limit` candidates, stopping at a match or the group order. + /// State points to the next untested scalar after every call. + pub fn search_batch(&mut self, prefix: &Prefix, limit: u64) -> BatchResult { + let mut result = BatchResult { + attempts: 0, + matched: None, + exhausted: self.exhausted, + }; + while result.attempts < limit && !self.exhausted { + let public_key = (self.point.x.0.map(|v| v.0), self.point.y.0.map(|v| v.0)); + let pkh = pkh_from_public_key(&public_key); + result.attempts += 1; + if prefix.matches(&encode_pkh(pkh)) { + result.matched = Some(Match { + secret_key_be: Zeroizing::new(*self.secret_key), + public_key, + pkh, + }); + } + for byte in self.secret_key.iter_mut().rev() { + let (next, carry) = byte.overflowing_add(1); + *byte = next; + if !carry { + break; + } + } + self.exhausted = *self.secret_key == CHEETAH_N; + if !self.exhausted { + self.point = ch_add(&self.point, &G); + } + result.exhausted = self.exhausted; + if result.matched.is_some() { + break; + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pokenoun::{Arena, Noun}; + + fn secret(value: u64) -> Zeroizing<[u8; 32]> { + let mut key = Zeroizing::new([0; 32]); + key[24..].copy_from_slice(&value.to_be_bytes()); + key + } + + fn tuple(arena: &mut Arena, limbs: &[u64]) -> Noun { + let mut noun = arena.alloc_atom_u64(*limbs.last().unwrap()); + for &limb in limbs[..limbs.len() - 1].iter().rev() { + let head = arena.alloc_atom_u64(limb); + noun = arena.alloc_cell(head, noun); + } + noun + } + + #[test] + fn pkh_matches_hoon_vector_and_generic_noun_hash() { + let public_key = ( + [ + 9323455886065152710, + 8604621052628066076, + 8724446291889705637, + 15913798201200938686, + 6871293856171770838, + 11532431931696133539, + ], + [ + 10242415564008566488, + 10485181329625226048, + 8639946714446054618, + 4053240175695272783, + 11730999058788639792, + 14820844833610271254, + ], + ); + let digest = pkh_from_public_key(&public_key); + assert_eq!( + digest, + [ + 0x9f6b_9852_a2ca_6ef0, + 0x7885_9c43_f58e_b268, + 0xb77f_75f3_fe27_9fc5, + 0x0004_4dd0_c6cc_6166, + 0xf8d7_889d_bf3d_eb9f + ] + ); + let mut arena = Arena::new(); + let x = tuple(&mut arena, &public_key.0); + let y = tuple(&mut arena, &public_key.1); + let inf = arena.alloc_atom_u64(1); + let tail = arena.alloc_cell(y, inf); + let noun = arena.alloc_cell(x, tail); + assert_eq!(digest, pokenoun::hash_noun_varlen(noun, &arena).unwrap()); + } + + #[test] + fn encoding_matches_integer_reference() { + use ibig::UBig; + for digest in [ + [0; 5], + [1, 0, 0, 0, 0], + [57, 0, 0, 0, 0], + [58, 0, 0, 0, 0], + [256, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [GOLDILOCKS_P - 1; 5], + [ + 0x9f6b_9852_a2ca_6ef0, + 0x7885_9c43_f58e_b268, + 0xb77f_75f3_fe27_9fc5, + 0x0004_4dd0_c6cc_6166, + 0xf8d7_889d_bf3d_eb9f, + ], + ] { + let mut n = UBig::from(0u8); + for limb in digest.into_iter().rev() { + n = n * UBig::from(GOLDILOCKS_P) + UBig::from(limb); + } + let mut bytes = Vec::new(); + loop { + let digit = usize::try_from(&n % UBig::from(58u8)).unwrap(); + bytes.push(BASE58_ALPHABET[digit]); + n /= UBig::from(58u8); + if n == UBig::from(0u8) { + break; + } + } + bytes.reverse(); + assert_eq!(encode_pkh(digest).as_str().as_bytes(), bytes); + #[cfg(feature = "std")] + assert_eq!( + encode_pkh(digest).as_str(), + crate::Hash { values: digest }.to_b58() + ); + } + assert_eq!(encode_pkh([0; 5]).as_str(), "1"); + assert_eq!(encode_pkh([58, 0, 0, 0, 0]).as_str(), "21"); + } + + #[test] + fn prefixes_validate_and_match_case_sensitively() { + for invalid in ["", "0", "O", "I", "l", "é", "ab cd", &"a".repeat(56)] { + assert!(matches!(Prefix::new(invalid), Err(Error::InvalidPrefix))); + } + for impossible in ["11", "1abc", &"z".repeat(55)] { + assert!(matches!( + Prefix::new(impossible), + Err(Error::ImpossiblePrefix) + )); + } + let address = encode_pkh([9, 0, 0, 0, 0]); // A + assert!(Prefix::new("A").unwrap().matches(&address)); + assert!(!Prefix::new("a").unwrap().matches(&address)); + assert!(!Prefix::new("AA").unwrap().matches(&address)); + assert!(Prefix::new("1").unwrap().matches(&encode_pkh([0; 5]))); + } + + #[test] + fn insensitive_matching_keeps_i_and_capital_l_distinct() { + fn address(text: &str) -> EncodedPkh { + assert!(text.bytes().all(|b| BASE58_ALPHABET.contains(&b))); + let mut bytes = [0; MAX_PKH_LEN]; + let start = MAX_PKH_LEN - text.len(); + bytes[start..].copy_from_slice(text.as_bytes()); + EncodedPkh { bytes, start } + } + let insensitive = |text| Prefix::with_mode(text, MatchMode::Insensitive).unwrap(); + for pattern in ["I", "i"] { + assert!(insensitive(pattern).matches(&address("i"))); + assert!(insensitive(pattern).matches(&address("1"))); + assert!(!insensitive(pattern).matches(&address("L"))); + } + for pattern in ["L", "l"] { + assert!(insensitive(pattern).matches(&address("L"))); + assert!(insensitive(pattern).matches(&address("1"))); + assert!(!insensitive(pattern).matches(&address("i"))); + } + for candidate in ["1", "i", "L"] { + assert!(insensitive("1").matches(&address(candidate))); + } + for candidate in ["5a17", "saL7", "S4Lt"] { + assert!(insensitive("SALT").matches(&address(candidate))); + } + assert!(!insensitive("SALT").matches(&address("sai7"))); + assert!(!Prefix::new("SALT").unwrap().matches(&address("5a17"))); + assert!(insensitive("0").matches(&address("o"))); + assert!(insensitive("1abc").matches(&address("LAbC"))); + assert!(Prefix::with_mode("z".repeat(55).as_str(), MatchMode::Insensitive).is_ok()); + assert!(Prefix::with_mode("!", MatchMode::Insensitive).is_err()); + } + + #[test] + fn gpu_digit_masks_match_cpu_rules_for_every_character() { + for mode in [MatchMode::Exact, MatchMode::Insensitive] { + for pattern in 0u8..=127 { + let text = String::from(pattern as char); + let Ok(prefix) = Prefix::with_mode(&text, mode) else { + continue; + }; + let masks = prefix.digit_masks(); + for (digit, &candidate) in BASE58_ALPHABET.iter().enumerate() { + let address = encode_pkh([digit as u64, 0, 0, 0, 0]); + assert_eq!(address.as_str().as_bytes(), &[candidate]); + assert_eq!( + prefix.matches(&address), + masks[0][digit / 32] & (1 << (digit % 32)) != 0 + ); + } + assert_eq!(masks[1..], [[0; 2]; MAX_PKH_LEN - 1]); + } + } + } + + #[test] + fn search_resumes_and_recovers_exact_secret_and_public_key() { + let mut search = Search::new(secret(1)).unwrap(); + assert_eq!( + search.search_batch(&Prefix::new("A").unwrap(), 0).attempts, + 0 + ); + for value in 1..=12 { + let key = secret(value); + let public_key = cheetah_pub_from_sk(*key); + let pkh = pkh_from_public_key(&public_key); + let full = Prefix::new(encode_pkh(pkh).as_str()).unwrap(); + let result = search.search_batch(&full, 1); + let found = result + .matched + .expect("incremental point matches scalar multiplication"); + assert_eq!(result.attempts, 1); + assert!(!result.exhausted); + assert_eq!(*found.secret_key_be, *key); + assert_eq!(found.public_key, public_key); + assert_eq!(found.pkh, pkh); + #[cfg(feature = "std")] + { + let reference = crate::crypto::cheetah::point::cheetah_pub_from_sk(*key); + assert_eq!(reference, [public_key.0, public_key.1]); + let pk = crate::SchnorrPubkey { + x: crate::F6LT { + values: public_key.0, + }, + y: crate::F6LT { + values: public_key.1, + }, + inf: false, + }; + assert_eq!(pk.to_hash().values, found.pkh); + } + } + } + + #[test] + fn batch_stops_at_match_and_at_scalar_boundary() { + let target = pkh_from_public_key(&cheetah_pub_from_sk(*secret(4))); + let prefix = Prefix::new(encode_pkh(target).as_str()).unwrap(); + let mut search = Search::new(secret(1)).unwrap(); + let first = search.search_batch(&prefix, 2); + assert_eq!(first.attempts, 2); + assert!(first.matched.is_none()); + let second = search.search_batch(&prefix, 10); + assert_eq!(second.attempts, 2); + assert_eq!(*second.matched.unwrap().secret_key_be, *secret(4)); + + assert!(matches!( + Search::new(secret(0)), + Err(Error::InvalidSecretKey) + )); + assert!(matches!( + Search::new(Zeroizing::new(CHEETAH_N)), + Err(Error::InvalidSecretKey) + )); + assert!(matches!( + Search::new(Zeroizing::new([255; 32])), + Err(Error::InvalidSecretKey) + )); + let mut last = Zeroizing::new(CHEETAH_N); + last[31] -= 1; + let public_key = cheetah_pub_from_sk(*last); + let prefix = Prefix::new(encode_pkh(pkh_from_public_key(&public_key)).as_str()).unwrap(); + let mut search = Search::new(last).unwrap(); + let batch = search.search_batch(&prefix, 8); + assert!(batch.exhausted); + assert!(batch.matched.is_some()); + assert_eq!(batch.attempts, 1); + let batch = search.search_batch(&prefix, 8); + assert_eq!(batch.attempts, 0); + assert!(batch.exhausted); + assert!(batch.matched.is_none()); + } +} diff --git a/tx-types/tests/vanity_search.rs b/tx-types/tests/vanity_search.rs new file mode 100644 index 0000000..3e8ab35 --- /dev/null +++ b/tx-types/tests/vanity_search.rs @@ -0,0 +1,78 @@ +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use tx_types::crypto::cheetah_nostd::cheetah_pub_from_sk; +use tx_types::crypto::vanity::{encode_pkh, pkh_from_public_key, Prefix, Search}; +use zeroize::Zeroizing; + +struct CountingAllocator; + +thread_local! { + static ALLOCATIONS: Cell> = const { Cell::new(None) }; +} + +fn record_allocation() { + let _ = ALLOCATIONS.try_with(|count| { + if let Some(n) = count.get() { + count.set(Some(n + 1)); + } + }); +} + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_allocation(); + System.alloc(layout) + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record_allocation(); + System.alloc_zeroed(layout) + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record_allocation(); + System.realloc(ptr, layout, size) + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + System.dealloc(ptr, layout) + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +#[test] +fn initialization_and_search_allocate_nothing() { + // A full-width starting scalar exercises all scalar-multiplication bits. + let start = [0x35; 32]; + let mut target = start; + target[31] += 8; + let target_public_key = cheetah_pub_from_sk(target); + let pkh = pkh_from_public_key(&target_public_key); + let address = encode_pkh(pkh); + + ALLOCATIONS.with(|count| count.set(Some(0))); + let prefix = Prefix::new(address.as_str()).unwrap(); + let mut search = Search::new(Zeroizing::new(start)).unwrap(); + let result = search.search_batch(&prefix, 10); + let allocations = ALLOCATIONS.with(|count| count.replace(None).unwrap()); + + assert_eq!(allocations, 0); + assert_eq!(result.attempts, 9); + let found = result.matched.unwrap(); + assert_eq!(*found.secret_key_be, target); + assert_eq!(found.public_key, target_public_key); + assert_eq!(found.pkh, pkh); +} + +#[cfg(feature = "vanity-mnemonic")] +#[test] +fn mnemonic_derivation_allocates_nothing() { + use tx_types::crypto::vanity::MnemonicSearch; + let prefix = Prefix::new("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap(); + ALLOCATIONS.with(|count| count.set(Some(0))); + let mut search = MnemonicSearch::new(Zeroizing::new([0x42; 32])); + let batch = search.search_batch(&prefix, 1); + let allocations = ALLOCATIONS.with(|count| count.replace(None).unwrap()); + assert_eq!(batch.attempts, 1); + assert_eq!(allocations, 0); +} diff --git a/tx-types/vanity/browser/gpu.js b/tx-types/vanity/browser/gpu.js new file mode 100644 index 0000000..a71f37f --- /dev/null +++ b/tx-types/vanity/browser/gpu.js @@ -0,0 +1,111 @@ +const POINT_WORDS = 26; +const RESULT_WORDS = 14; + +export class WebGpuUnavailableError extends Error {} + +export async function requestGpuDevice() { + if (!navigator.gpu) throw new WebGpuUnavailableError("This browser does not expose WebGPU."); + try { + let adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) adapter = await navigator.gpu.requestAdapter(); + if (!adapter) throw new Error("No WebGPU adapter is available."); + const device = await adapter.requestDevice(); + const info = adapter.info; + return { device, adapterName: info?.description || info?.device || info?.architecture || "WebGPU adapter" }; + } catch (error) { throw new WebGpuUnavailableError(`WebGPU could not initialize: ${error.message}`); } +} + +export async function shaderSource(urls) { + const files = ["shaders/field.wgsl", "generated/constants.wgsl", "shaders/cheetah.wgsl", + "shaders/tip5.wgsl", "shaders/encoding.wgsl", "shaders/search.wgsl"]; + return (await Promise.all((urls ?? files).map(async (path) => { + const response = await fetch(new URL(path, import.meta.url)); + if (!response.ok) throw new Error(`Cannot load ${path}. Run the browser build first.`); + return response.text(); + }))).join("\n"); +} + +export class GpuSearch { + static async create({ shaderUrls } = {}) { + const { device, adapterName } = await requestGpuDevice(); + const gpu = new GpuSearch(device); + gpu.adapterName = adapterName; + try { + const module = device.createShaderModule({ code: await shaderSource(shaderUrls) }); + const messages = (await module.getCompilationInfo()).messages.filter((m) => m.type === "error"); + if (messages.length) throw new Error(messages.map((m) => `${m.lineNum}:${m.linePos}: ${m.message}`).join("\n")); + gpu.pipeline = await device.createComputePipelineAsync({ + layout: "auto", compute: { module, entryPoint: "mine" }, + }); + gpu.checkDevice(); + return gpu; + } catch (error) { gpu.destroy(); throw error; } + } + + constructor(device) { + this.device = device; + this.buffers = []; + this.loss = null; + device.lost.then((info) => { this.loss = `GPU device lost: ${info.message || info.reason}`; }); + device.addEventListener("uncapturederror", (event) => { this.loss = event.error.message; }); + } + + checkDevice() { if (this.loss) throw new Error(this.loss); } + + buffer(size, usage) { + const buffer = this.device.createBuffer({ size, usage }); + this.buffers.push(buffer); + return buffer; + } + + initialize(points, masks, prefixLength) { + this.checkDevice(); + this.releaseBuffers(); + this.lanes = points.length / POINT_WORDS; + if (!Number.isInteger(this.lanes) || this.lanes < 1 || this.lanes > 256) throw new Error("Invalid lane count."); + if (masks.length !== 110 || prefixLength < 1 || prefixLength > 55) throw new Error("Invalid prefix masks."); + this.pointBuffer = this.buffer(points.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC); + this.configBuffer = this.buffer(114 * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST); + this.resultBuffer = this.buffer(this.lanes * RESULT_WORDS * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC); + this.readback = this.buffer(this.resultBuffer.size, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ); + this.config = new Uint32Array(114); + this.config.set([this.lanes, 1, prefixLength, 0]); + this.config.set(masks, 4); + this.device.queue.writeBuffer(this.pointBuffer, 0, points); + this.bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [this.pointBuffer, this.configBuffer, this.resultBuffer].map((buffer, binding) => ({ binding, resource: { buffer } })), + }); + } + + async dispatch(steps) { + if (!Number.isInteger(steps) || steps < 1 || steps > 16) throw new Error("Use 1 to 16 steps per GPU batch."); + this.checkDevice(); + this.config[1] = steps; + this.device.queue.writeBuffer(this.configBuffer, 0, this.config); + const commands = this.device.createCommandEncoder(); + const pass = commands.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(this.lanes / 32)); + pass.end(); + commands.copyBufferToBuffer(this.resultBuffer, 0, this.readback, 0, this.readback.size); + this.device.queue.submit([commands.finish()]); + await this.readback.mapAsync(GPUMapMode.READ); + let words; + try { words = new Uint32Array(this.readback.getMappedRange()).slice(); } + finally { this.readback.unmap(); } + this.checkDevice(); + return Array.from({ length: this.lanes }, (_, lane) => { + const i = lane * RESULT_WORDS; + return { lane, status: words[i], offset: words[i+1], tested: words[i+2], digest: words.slice(i+4, i+14) }; + }); + } + + releaseBuffers() { + for (const buffer of this.buffers) buffer.destroy(); + this.buffers = []; + } + + destroy() { this.releaseBuffers(); this.device.destroy(); } +} diff --git a/tx-types/vanity/browser/miner.d.ts b/tx-types/vanity/browser/miner.d.ts new file mode 100644 index 0000000..2eabcd6 --- /dev/null +++ b/tx-types/vanity/browser/miner.d.ts @@ -0,0 +1,39 @@ +export interface MineOptions { + prefix: string; + /** Ignore case and match documented letter/digit alternatives. */ + insensitive?: boolean; + /** Default: mnemonic (24 words, path m, empty passphrase). raw has no phrase. */ + keyMode?: "mnemonic" | "raw"; + /** Both modes: auto selects WebGPU, then WASM CPU when unavailable. */ + backend?: "auto" | "cpu"; + /** GPU lanes: mnemonic 1..4096 (default 4096); raw 1..256 (default 64). */ + lanes?: number; + /** Raw-key GPU steps per batch (1..16); ignored by mnemonic mining. */ + steps?: number; + /** Total candidates across all lanes. 0 (default) means unlimited. */ + maxAttempts?: number; + signal?: AbortSignal; + onProgress?: (event: MineProgress) => void; + /** Asset overrides for bundlers or a different static asset layout. */ + workerUrl?: string | URL; + wasmUrl?: string | URL; + shaderUrls?: (string | URL)[]; + mnemonicShaderUrls?: (string | URL)[]; + tableUrl?: string | URL; +} + +export type MineProgress = + | { type: "status"; message: string } + | { type: "backend"; adapter: string; reason: string } + | { type: "progress"; adapter: string; attempts: number; seconds: number; rate: number }; + +export interface MineResult { + pkh: string; + /** UTF-8 private JSON; mnemonic mode includes phrase and recovery settings. */ + keyJson: Uint8Array; + attempts: number; + backend: string; +} + +/** Returns null on the attempt limit; rejects with AbortError on cancellation. */ +export function mineAddress(options: MineOptions): Promise; diff --git a/tx-types/vanity/browser/miner.js b/tx-types/vanity/browser/miner.js new file mode 100644 index 0000000..e4e2314 --- /dev/null +++ b/tx-types/vanity/browser/miner.js @@ -0,0 +1,58 @@ +/** + * Mine an address without UI dependencies. Both recovery modes automatically + * select WebGPU when available and otherwise use WASM CPU. + * + * @param {import('./miner.js').MineOptions} options + * @returns {Promise} null means the attempt limit was reached. + */ +export function mineAddress(options) { + const { signal, onProgress, workerUrl = new URL("./worker.js", import.meta.url), + wasmUrl, shaderUrls, mnemonicShaderUrls, tableUrl, ...settings } = options; + return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new DOMException("Mining cancelled", "AbortError")); return; } + let worker; + try { worker = new Worker(workerUrl, { type: "module" }); } + catch (error) { reject(error); return; } + let settled = false; + let abortTimer; + const finish = (error, result = null) => { + if (settled) return; + settled = true; + clearTimeout(abortTimer); + signal?.removeEventListener("abort", abort); + worker.terminate(); + if (error) reject(error); else resolve(result); + }; + const aborted = () => finish(new DOMException("Mining cancelled", "AbortError")); + const abort = () => { + worker.postMessage({ type: "stop" }); + abortTimer = setTimeout(aborted, 5000); + }; + signal?.addEventListener("abort", abort, { once: true }); + worker.onerror = (event) => finish(new Error(event.message || "Mining worker failed.")); + worker.onmessageerror = () => finish(new Error("Cannot decode mining worker response.")); + worker.onmessage = ({ data }) => { + if (signal?.aborted) { if (data.type === "stopped") aborted(); return; } + if (data.type === "error") finish(new Error(data.message)); + else if (data.type === "stopped") aborted(); + else if (data.type === "limit") finish(null); + else if (data.type === "match") finish(null, { + pkh: data.pkh, keyJson: new Uint8Array(data.bytes), + attempts: data.attempts, backend: data.adapter, + }); + else { + try { onProgress?.(data); } + catch (error) { finish(error); } + } + }; + try { + worker.postMessage({ type: "start", insensitive: false, keyMode: "mnemonic", backend: "auto", lanes: options.keyMode === "raw" ? 64 : 4096, + steps: 1, maxAttempts: 0, ...settings, + wasmUrl: wasmUrl === undefined ? undefined : new URL(wasmUrl, import.meta.url).href, + shaderUrls: shaderUrls?.map(url => new URL(url, import.meta.url).href), + mnemonicShaderUrls: mnemonicShaderUrls?.map(url => new URL(url, import.meta.url).href), + tableUrl: tableUrl === undefined ? undefined : new URL(tableUrl, import.meta.url).href, + }); + } catch (error) { finish(error); } + }); +} diff --git a/tx-types/vanity/browser/mnemonic-gpu.js b/tx-types/vanity/browser/mnemonic-gpu.js new file mode 100644 index 0000000..8979019 --- /dev/null +++ b/tx-types/vanity/browser/mnemonic-gpu.js @@ -0,0 +1,96 @@ +import { GpuSearch, requestGpuDevice } from "./gpu.js"; + +export async function mnemonicShaderSource(urls) { + const files = ["shaders/field.wgsl", "generated/constants.wgsl", "shaders/cheetah.wgsl", + "shaders/tip5.wgsl", "shaders/encoding.wgsl", "shaders/sha512.wgsl", + "shaders/fixed-base.wgsl", "shaders/mnemonic.wgsl"]; + return (await Promise.all((urls ?? files).map(async path => { + const response = await fetch(new URL(path, import.meta.url)); + if (!response.ok) throw new Error(`Cannot load ${path}. Run the browser build first.`); + return response.text(); + }))).join("\n"); +} + +export class MnemonicGpuSearch extends GpuSearch { + static async create({ mnemonicShaderUrls, tableUrl = new URL("./generated/generator-table.bin", import.meta.url) } = {}) { + const { device, adapterName } = await requestGpuDevice(); + const gpu = new MnemonicGpuSearch(device); + gpu.adapterName = adapterName; + try { + const [code, response] = await Promise.all([mnemonicShaderSource(mnemonicShaderUrls), fetch(tableUrl)]); + if (!response.ok) throw new Error("Cannot load the generator table. Run the browser build first."); + const table = await response.arrayBuffer(); + if (table.byteLength !== 64 * 16 * 96) throw new Error("Invalid generator table length."); + const module = device.createShaderModule({ code }); + const errors = (await module.getCompilationInfo()).messages.filter(m => m.type === "error"); + if (errors.length) throw new Error(errors.map(m => `${m.lineNum}:${m.linePos}: ${m.message}`).join("\n")); + // One explicit layout lets all four stages share a bind group. + gpu.layout = device.createBindGroupLayout({ entries: ["read-only-storage", "storage", "read-only-storage", "storage", "read-only-storage"].map((type,binding) => ({ + binding, visibility: GPUShaderStage.COMPUTE, buffer: { type }, + })) }); + const layout = device.createPipelineLayout({ bindGroupLayouts: [gpu.layout] }); + gpu.pipelines = await Promise.all(["mnemonic_init", "mnemonic_pbkdf", "mnemonic_master", "mnemonic_address"].map(entryPoint => + device.createComputePipelineAsync({ layout, compute: { module, entryPoint } }))); + gpu.table = device.createBuffer({ size: table.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + device.queue.writeBuffer(gpu.table, 0, table); + gpu.checkDevice(); + return gpu; + } catch (error) { gpu.destroy(); throw error; } + } + + initialize(lanes, masks, prefixLength) { + this.checkDevice(); + this.releaseBuffers(); + if (!Number.isInteger(lanes) || lanes < 1 || lanes > 4096) throw new Error("Choose 1 to 4096 mnemonic GPU lanes."); + this.lanes = lanes; + const secretUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST; + this.input = this.buffer(lanes * 128, secretUsage); + this.states = this.buffer(lanes * 264, secretUsage); + this.configBuffer = this.buffer(114 * 4, secretUsage); + this.resultBuffer = this.buffer(lanes * 56, secretUsage | GPUBufferUsage.COPY_SRC); + this.readback = this.buffer(lanes * 56, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ); + this.config = new Uint32Array(114); + this.config.set([lanes, 0, prefixLength, 0]); this.config.set(masks, 4); + this.bindGroup = this.device.createBindGroup({ layout: this.layout, + entries: [this.input, this.states, this.configBuffer, this.resultBuffer, this.table] + .map((buffer,binding) => ({ binding, resource: { buffer } })), + }); + } + + // The input contains private HMAC key blocks, never public point data. + // Copy it into the queue and erase the caller's temporary view immediately. + async dispatch(inputs) { + const count = inputs.length / 32; + try { + if (!Number.isInteger(count) || count < 1 || count > this.lanes) throw new Error("Invalid mnemonic batch size."); + this.checkDevice(); + this.device.queue.writeBuffer(this.input, 0, inputs); + } finally { inputs.fill(0); } + this.config[0] = count; + this.device.queue.writeBuffer(this.configBuffer, 0, this.config); + const commands = this.device.createCommandEncoder(); + const stage = (pipeline) => { + const pass = commands.beginComputePass(); + pass.setPipeline(pipeline); pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(count/32)); pass.end(); + }; + stage(this.pipelines[0]); + for (let chunk = 0; chunk < 16; chunk++) stage(this.pipelines[1]); + stage(this.pipelines[2]); stage(this.pipelines[3]); + commands.copyBufferToBuffer(this.resultBuffer, 0, this.readback, 0, count * 56); + // Clear the secret GPU buffers in queue order before exposing results. + commands.clearBuffer(this.input); commands.clearBuffer(this.states); + this.device.queue.submit([commands.finish()]); + await this.readback.mapAsync(GPUMapMode.READ); + let words; + try { words = new Uint32Array(this.readback.getMappedRange()).slice(0, count*14); } + finally { this.readback.unmap(); } + this.checkDevice(); + return Array.from({length:count}, (_,lane) => { + const i = lane*14; + return { lane, status: words[i], tested: words[i+2], digest: words.slice(i+4,i+14) }; + }); + } + + destroy() { this.table?.destroy(); super.destroy(); } +} diff --git a/tx-types/vanity/browser/mnemonic-self-test.js b/tx-types/vanity/browser/mnemonic-self-test.js new file mode 100644 index 0000000..683c874 --- /dev/null +++ b/tx-types/vanity/browser/mnemonic-self-test.js @@ -0,0 +1,33 @@ +import { addressFromDigest } from "./self-test.js"; + +function assert(condition, message) { if (!condition) throw new Error(`Mnemonic WebGPU self-test: ${message}`); } + +export async function mnemonicSelfTest(gpu, keys) { + // Both sides of HMAC's 128-byte key boundary, full-width entropy, partial + // workgroups, and resumed batches run before any production entropy is drawn. + for (const [value, count] of [[0, 3], [0x42, 2], [255, 1]]) { + keys.reset(1); + const masks = keys.prefix("1"); + keys.mnemonicGpuStart(new Uint8Array(32).fill(value)); + gpu.initialize(count, masks, 1); + const inputs = keys.mnemonicGpuPrepare(count); + const results = await gpu.dispatch(inputs); + assert(inputs.every(word => word === 0), "temporary key buffer not erased"); + for (const result of results) { + const expected = keys.mnemonicGpuReference(result.lane).slice(24); + assert(result.tested === 1 && result.status < 2, "candidate counter or derivation"); + assert(result.digest.every((word,i) => word === expected[i]), `address parity for entropy ${value}, lane ${result.lane}`); + assert(result.status === Number(addressFromDigest(expected).startsWith("1")), "prefix match"); + } + if (value === 0x42) { + // Continue at the next entropy value, and exercise exact matching/export. + const inputs = keys.mnemonicGpuPrepare(1); + const address = addressFromDigest(keys.mnemonicGpuReference(0).slice(24)); + gpu.initialize(1, keys.prefix(address), address.length); + const [result] = await gpu.dispatch(inputs); + assert(result.status === 1, "full address match"); + keys.mnemonicGpuVerify(0).fill(0); + } + } + gpu.releaseBuffers(); keys.clear(); +} diff --git a/tx-types/vanity/browser/self-test.js b/tx-types/vanity/browser/self-test.js new file mode 100644 index 0000000..6f90538 --- /dev/null +++ b/tx-types/vanity/browser/self-test.js @@ -0,0 +1,47 @@ +export function addressFromDigest(words) { + const prime = 0xffffffff00000001n; + let integer = 0n; + for (let i = 4; i >= 0; i--) { + integer = integer * prime + BigInt(words[i*2]) + (BigInt(words[i*2+1]) << 32n); + } + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let address = ""; + do { address = alphabet[Number(integer % 58n)] + address; integer /= 58n; } while (integer); + return address; +} + +function assert(condition, message) { if (!condition) throw new Error(`WebGPU self-test: ${message}`); } + +// Tests the actual device before generating production keys. This checks both +// scalar-1 doubling and full-width curve arithmetic against the Rust module. +export async function selfTest(gpu, keys) { + keys.reset(2); + const one = new Uint8Array(32); one[31] = 1; + assert(keys.setSeed(0, one) && keys.setSeed(1, new Uint8Array(32).fill(0x35)), "seed setup"); + gpu.initialize(keys.points(), keys.prefix("1"), 1); + for (let batch = 0; batch < 2; batch++) { + const results = await gpu.dispatch(2); + for (const result of results) { + const expected = keys.reference(result.lane, batch * 2 + 1).slice(24); + assert(result.status === 0 && result.tested === 2 && result.offset === batch * 2 + 1, "candidate counters"); + assert(result.digest.every((word, i) => word === expected[i]), `PKH parity in lane ${result.lane}, batch ${batch}`); + } + } + // Exercise full Base58 conversion, the exact matcher, and verified output. + const address = addressFromDigest(keys.reference(0, 1).slice(24)); + gpu.initialize(keys.points(), keys.prefix(address), address.length); + let results = await gpu.dispatch(2); + assert(results[0].status === 1 && results[0].offset === 1, "exact prefix match"); + const output = keys.verify(0, 1); + output.fill(0); + // The same compiled character masks cover all case and digit equivalences. + const insensitive = address.toLowerCase().replaceAll("s", "5").replaceAll("a", "4") + .replaceAll("e", "3").replaceAll("i", "1").replaceAll("l", "1") + .replaceAll("o", "0").replaceAll("t", "7").replaceAll("b", "8").replaceAll("z", "2"); + gpu.initialize(keys.points(), keys.prefix(insensitive, true), insensitive.length); + results = await gpu.dispatch(2); + assert(results[0].status === 1 && results[0].offset === 1, "insensitive prefix match"); + keys.verify(0, 1).fill(0); + keys.clear(); + gpu.releaseBuffers(); +} diff --git a/tx-types/vanity/browser/shaders/cheetah.wgsl b/tx-types/vanity/browser/shaders/cheetah.wgsl new file mode 100644 index 0000000..52af1da --- /dev/null +++ b/tx-types/vanity/browser/shaders/cheetah.wgsl @@ -0,0 +1,92 @@ +alias F6 = array; +struct Point { x: F6, y: F6, offset: u32, padding: u32 } + +fn f6_add(a: F6, b: F6) -> F6 { + var out: F6; + for (var i = 0u; i < 6u; i++) { out[i] = fadd(a[i], b[i]); } + return out; +} +fn f6_sub(a: F6, b: F6) -> F6 { + var out: F6; + for (var i = 0u; i < 6u; i++) { out[i] = fsub(a[i], b[i]); } + return out; +} +fn f6_scale(a: F6, scalar: U64) -> F6 { + var out: F6; + for (var i = 0u; i < 6u; i++) { out[i] = fmul(a[i], scalar); } + return out; +} +fn f6_mul(a: F6, b: F6) -> F6 { + var wide: array; + for (var i = 0u; i < 6u; i++) { + for (var j = 0u; j < 6u; j++) { + wide[i+j] = fadd(wide[i+j], fmul(a[i], b[j])); + } + } + for (var i = 6u; i < 11u; i++) { + wide[i-6u] = fadd(wide[i-6u], fmul(U64(7u, 0u), wide[i])); + } + var out: F6; + for (var i = 0u; i < 6u; i++) { out[i] = wide[i]; } + return out; +} +fn f6_equal(a: F6, b: F6) -> bool { + for (var i = 0u; i < 6u; i++) { if !equal(a[i], b[i]) { return false; } } + return true; +} +fn f6_inv(a: F6) -> F6 { + var r0: array; + r0[0] = fneg(U64(7u, 0u)); + r0[6] = ONE; + var r1: array; + for (var i = 0u; i < 6u; i++) { r1[i] = a[i]; } + var s0: F6; + var s1: F6; + s1[0] = ONE; + // The remainder degree decreases at every step. + for (var iteration = 0u; iteration < 7u; iteration++) { + var degree = 6i; + while degree >= 0i { + if !equal(r1[u32(degree)], ZERO) { break; } + degree--; + } + if degree < 0i { return F6(); } + if degree == 0i { return f6_scale(s1, finv(r1[0])); } + var quotient = array(); + let inverse = finv(r1[u32(degree)]); + for (var i = 6i; i >= degree; i--) { + let factor = fmul(r0[u32(i)], inverse); + quotient[u32(i-degree)] = factor; + for (var j = 0i; j <= degree; j++) { + let k = u32(i-degree+j); + r0[k] = fsub(r0[k], fmul(factor, r1[u32(j)])); + } + } + let previous = r0; + r0 = r1; + r1 = previous; + quotient[0] = fadd(quotient[0], fmul(U64(7u, 0u), quotient[6])); + var q: F6; + for (var i = 0u; i < 6u; i++) { q[i] = quotient[i]; } + let next = f6_sub(s0, f6_mul(q, s1)); + s0 = s1; + s1 = next; + } + return F6(); +} + +// The host reserves the full offset range below the group order. This function +// only encounters finite points; scalar 1 uses the doubling formula. +fn advance(point: Point) -> Point { + var numerator = f6_sub(GY, point.y); + var denominator = f6_sub(GX, point.x); + if f6_equal(point.x, GX) { + numerator = f6_scale(f6_mul(point.x, point.x), U64(3u, 0u)); + numerator[0] = fadd(numerator[0], ONE); + denominator = f6_scale(point.y, U64(2u, 0u)); + } + let slope = f6_mul(numerator, f6_inv(denominator)); + let x = f6_sub(f6_sub(f6_mul(slope, slope), point.x), GX); + let y = f6_sub(f6_mul(slope, f6_sub(point.x, x)), point.y); + return Point(x, y, point.offset + 1u, 0u); +} diff --git a/tx-types/vanity/browser/shaders/encoding.wgsl b/tx-types/vanity/browser/shaders/encoding.wgsl new file mode 100644 index 0000000..e221764 --- /dev/null +++ b/tx-types/vanity/browser/shaders/encoding.wgsl @@ -0,0 +1,70 @@ +struct Result { + found: u32, offset: u32, tested: u32, padding: u32, + digest: array, +} +struct EncodedPkh { digits: array, length: u32 } +fn base58_digits(digest: array) -> EncodedPkh { + // Convert the base-p digest to 20 little-endian radix-2^16 limbs. Using + // 16-bit limbs keeps the subsequent division by 58 within u32 range. + var integer: array; + for (var d = 4i; d >= 0i; d--) { + var words = array(); + // p = [1, 0, 65535, 65535] in radix 2^16. + for (var i = 0u; i < 20u; i++) { + let factors = array(1u, 0u, 65535u, 65535u); + var carry = 0u; + for (var j = 0u; j < 4u && i+j < 20u; j++) { + let wide = words[i+j] + integer[i] * factors[j] + carry; + words[i+j] = wide & 65535u; + carry = wide >> 16u; + } + var k = i+4u; + while carry != 0u && k < 20u { + let wide = words[k] + carry; + words[k] = wide & 65535u; + carry = wide >> 16u; + k++; + } + } + let digit = digest[u32(d)]; + let addend = array(digit.x & 65535u, digit.x >> 16u, + digit.y & 65535u, digit.y >> 16u); + var carry = 0u; + for (var i = 0u; i < 20u; i++) { + var value = 0u; + if i < 4u { value = addend[i]; } + let wide = words[i] + value + carry; + integer[i] = wide & 65535u; + carry = wide >> 16u; + } + } + var digits: array; + var length = 0u; + loop { + var remainder = 0u; + var nonzero = 0u; + for (var i = 19i; i >= 0i; i--) { + let wide = (remainder << 16u) | integer[u32(i)]; + integer[u32(i)] = wide / 58u; + remainder = wide % 58u; + nonzero |= integer[u32(i)]; + } + digits[length] = remainder; + length++; + if nonzero == 0u || length == 55u { break; } + } + return EncodedPkh(digits, length); +} + +fn prefix_matches(digest: array) -> bool { + let encoded = base58_digits(digest); + let prefix_length = config[2]; + if encoded.length < prefix_length { return false; } + for (var i = 0u; i < prefix_length; i++) { + let digit = encoded.digits[encoded.length - 1u - i]; + let mask = config[4u + i*2u + digit/32u]; + if (mask & (1u << (digit % 32u))) == 0u { return false; } + } + return true; +} + diff --git a/tx-types/vanity/browser/shaders/field.wgsl b/tx-types/vanity/browser/shaders/field.wgsl new file mode 100644 index 0000000..a846801 --- /dev/null +++ b/tx-types/vanity/browser/shaders/field.wgsl @@ -0,0 +1,79 @@ +// Unsigned 64-bit words are (low, high). All arithmetic uses portable u32. +alias U64 = vec2; +const P = U64(1u, 0xffffffffu); +const ZERO = U64(0u); +const ONE = U64(1u, 0u); + +fn equal(a: U64, b: U64) -> bool { return all(a == b); } +fn less(a: U64, b: U64) -> bool { return a.y < b.y || (a.y == b.y && a.x < b.x); } +fn add64(a: U64, b: U64) -> U64 { + let lo = a.x + b.x; + return U64(lo, a.y + b.y + select(0u, 1u, lo < a.x)); +} +fn sub64(a: U64, b: U64) -> U64 { + return U64(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x)); +} +// Addition and subtraction take canonical field elements. +fn fadd(a: U64, b: U64) -> U64 { + var sum = add64(a, b); + if less(sum, a) { sum = sub64(sum, P); } + if !less(sum, P) { sum = sub64(sum, P); } + return sum; +} +fn fsub(a: U64, b: U64) -> U64 { + if less(a, b) { return sub64(P, sub64(b, a)); } + return sub64(a, b); +} +fn fneg(a: U64) -> U64 { + if equal(a, ZERO) { return ZERO; } + return sub64(P, a); +} + +// Full 32x32 -> 64 multiplication using four 16-bit products. +fn mul32(a: u32, b: u32) -> U64 { + let a0 = a & 65535u; + let a1 = a >> 16u; + let b0 = b & 65535u; + let b1 = b >> 16u; + let low = a0 * b0; + let middle = a1 * b0 + (low >> 16u); + let middle2 = a0 * b1 + (middle & 65535u); + return U64((middle2 << 16u) | (low & 65535u), + a1 * b1 + (middle >> 16u) + (middle2 >> 16u)); +} +fn mul64(a: U64, b: U64) -> vec4 { + let ll = mul32(a.x, b.x); + let lh = mul32(a.x, b.y); + let hl = mul32(a.y, b.x); + let hh = mul32(a.y, b.y); + let mid0 = ll.y + lh.x; + let mid1 = mid0 + hl.x; + let c1 = select(0u, 1u, mid0 < ll.y) + select(0u, 1u, mid1 < mid0); + let high0 = lh.y + hl.y; + let high1 = high0 + hh.x; + let high2 = high1 + c1; + let c2 = select(0u, 1u, high0 < lh.y) + select(0u, 1u, high1 < high0) + + select(0u, 1u, high2 < high1); + return vec4(ll.x, mid1, high2, hh.y + c2); +} +fn fmul(a: U64, b: U64) -> U64 { + let product = mul64(a, b); + var low = product.xy; + let high = U64(product.w, 0u); + let borrow = less(low, high); + low = sub64(low, high); + if borrow { low = add64(low, P); } + // 2^64 = 2^32 - 1 (mod p), 2^96 = -1 (mod p). + let folded = sub64(U64(0u, product.z), U64(product.z, 0u)); + return fadd(low, folded); +} +fn finv(a: U64) -> U64 { + // p-2 = 0xfffffffeffffffff. + var result = ONE; + var base = a; + for (var bit = 0u; bit < 64u; bit++) { + if bit != 32u { result = fmul(result, base); } + base = fmul(base, base); + } + return result; +} diff --git a/tx-types/vanity/browser/shaders/fixed-base.wgsl b/tx-types/vanity/browser/shaders/fixed-base.wgsl new file mode 100644 index 0000000..3c268ef --- /dev/null +++ b/tx-types/vanity/browser/shaders/fixed-base.wgsl @@ -0,0 +1,47 @@ +// Jacobian coordinates: x=X/Z^2, y=Y/Z^3. Z=0 represents infinity. +// EFD madd-2007-bl and dbl-2007-bl, with Cheetah's a=1: +// https://www.hyperelliptic.org/EFD/g1p/auto-shortw-jacobian.html +struct Affine { x: F6, y: F6 } +struct Jacobian { x: F6, y: F6, z: F6 } +fn f6_one() -> F6 { var out = F6(); out[0] = ONE; return out; } +fn j_double(p: Jacobian) -> Jacobian { + if f6_equal(p.z, F6()) || f6_equal(p.y, F6()) { return Jacobian(); } + let xx = f6_mul(p.x, p.x); + let yy = f6_mul(p.y, p.y); + let yyyy = f6_mul(yy, yy); + let zz = f6_mul(p.z, p.z); + let sum = f6_add(p.x, yy); + let s = f6_scale(f6_sub(f6_sub(f6_mul(sum, sum), xx), yyyy), U64(2u, 0u)); + let m = f6_add(f6_scale(xx, U64(3u, 0u)), f6_mul(zz, zz)); + let x = f6_sub(f6_mul(m, m), f6_scale(s, U64(2u, 0u))); + let y = f6_sub(f6_mul(m, f6_sub(s, x)), f6_scale(yyyy, U64(8u, 0u))); + let yz = f6_add(p.y, p.z); + let z = f6_sub(f6_sub(f6_mul(yz, yz), yy), zz); + return Jacobian(x, y, z); +} +fn j_mixed_add(p: Jacobian, q: Affine) -> Jacobian { + if f6_equal(p.z, F6()) { return Jacobian(q.x, q.y, f6_one()); } + let zz = f6_mul(p.z, p.z); + let u = f6_mul(q.x, zz); + let s = f6_mul(q.y, f6_mul(p.z, zz)); + let h = f6_sub(u, p.x); + let r = f6_scale(f6_sub(s, p.y), U64(2u, 0u)); + if f6_equal(h, F6()) { + if f6_equal(r, F6()) { return j_double(p); } + return Jacobian(); + } + let hh = f6_mul(h, h); + let i = f6_scale(hh, U64(4u, 0u)); + let j = f6_mul(h, i); + let v = f6_mul(p.x, i); + let x = f6_sub(f6_sub(f6_mul(r, r), j), f6_scale(v, U64(2u, 0u))); + let y = f6_sub(f6_mul(r, f6_sub(v, x)), f6_scale(f6_mul(p.y, j), U64(2u, 0u))); + let zh = f6_add(p.z, h); + let z = f6_sub(f6_sub(f6_mul(zh, zh), zz), hh); + return Jacobian(x, y, z); +} +fn j_affine(p: Jacobian) -> Point { + let inverse = f6_inv(p.z); + let squared = f6_mul(inverse, inverse); + return Point(f6_mul(p.x, squared), f6_mul(p.y, f6_mul(squared, inverse)), 0u, 0u); +} diff --git a/tx-types/vanity/browser/shaders/mnemonic.wgsl b/tx-types/vanity/browser/shaders/mnemonic.wgsl new file mode 100644 index 0000000..8da2276 --- /dev/null +++ b/tx-types/vanity/browser/shaders/mnemonic.wgsl @@ -0,0 +1,101 @@ +struct MnemonicState { + inner: ShaState, outer: ShaState, u: ShaState, total: ShaState, + round: u32, valid: u32, +} +@group(0) @binding(0) var key_blocks: array; +@group(0) @binding(1) var mnemonic_states: array; +@group(0) @binding(2) var config: array; +@group(0) @binding(3) var results: array; +@group(0) @binding(4) var generator_table: array; + +@compute @workgroup_size(32) +fn mnemonic_init(@builtin(global_invocation_id) id: vec3) { + let lane = id.x; + if lane >= config[0] { return; } + var ipad = ShaBlock(); var opad = ShaBlock(); + for (var i = 0u; i < 16u; i++) { + ipad[i] = key_blocks[lane][i] ^ U64(0x36363636u); + opad[i] = key_blocks[lane][i] ^ U64(0x5c5c5c5cu); + } + let inner = sha512_compress(SHA512_IV, ipad); + let outer = sha512_compress(SHA512_IV, opad); + // Salt "mnemonic" followed by the big-endian block counter 1. + var salt = ShaBlock(); + salt[0] = U64(0x6f6e6963u, 0x6d6e656du); + salt[1] = U64(0x80000000u, 1u); + salt[15] = U64(1120u, 0u); + let u = sha512_after_pad(outer, sha512_compress(inner, salt)); + mnemonic_states[lane] = MnemonicState(inner, outer, u, u, 1u, 0u); +} + +// Sixteen bounded dispatches perform the remaining 2047 PBKDF2 rounds. +@compute @workgroup_size(32) +fn mnemonic_pbkdf(@builtin(global_invocation_id) id: vec3) { + let lane = id.x; + if lane >= config[0] { return; } + var state = mnemonic_states[lane]; + let count = min(128u, 2048u - state.round); + for (var round = 0u; round < count; round++) { + state.u = hmac512_64(state.inner, state.outer, state.u); + for (var i = 0u; i < 8u; i++) { state.total[i] ^= state.u[i]; } + } + state.round += count; + mnemonic_states[lane] = state; +} + +fn scalar_valid(words: ShaState) -> bool { + // Group order generated from Rust, most significant word first. + var nonzero = 0u; + for (var i = 0u; i < 4u; i++) { nonzero |= words[i].x | words[i].y; } + if nonzero == 0u { return false; } + for (var i = 0u; i < 4u; i++) { + if less(words[i], CHEETAH_ORDER[i]) { return true; } + if !equal(words[i], CHEETAH_ORDER[i]) { return false; } + } + return false; +} + +@compute @workgroup_size(32) +fn mnemonic_master(@builtin(global_invocation_id) id: vec3) { + let lane = id.x; + if lane >= config[0] { return; } + var key = ShaBlock(); + // HMAC key "Nockchain seed". + key[0] = U64(0x63686169u, 0x4e6f636bu); + key[1] = U64(0x65640000u, 0x6e207365u); + var ipad = ShaBlock(); var opad = ShaBlock(); + for (var i = 0u; i < 16u; i++) { + ipad[i] = key[i] ^ U64(0x36363636u); + opad[i] = key[i] ^ U64(0x5c5c5c5cu); + } + let inner = sha512_compress(SHA512_IV, ipad); + let outer = sha512_compress(SHA512_IV, opad); + var data = mnemonic_states[lane].total; + var valid = 0u; + for (var retry = 0u; retry < 256u; retry++) { + data = hmac512_64(inner, outer, data); + if scalar_valid(data) { valid = 1u; break; } + } + mnemonic_states[lane].u = data; + mnemonic_states[lane].valid = valid; +} + +@compute @workgroup_size(32) +fn mnemonic_address(@builtin(global_invocation_id) id: vec3) { + let lane = id.x; + if lane >= config[0] { return; } + if mnemonic_states[lane].valid != 1u || mnemonic_states[lane].round != 2048u { + results[lane] = Result(3u, 0u, 0u, 0u, array()); return; + } + let scalar = mnemonic_states[lane].u; + var point = Jacobian(); + for (var window = 0u; window < 64u; window++) { + let word = scalar[3u - window/16u]; + let half = select(word.x, word.y, (window & 15u) >= 8u); + let digit = (half >> ((window & 7u)*4u)) & 15u; + if digit != 0u { point = j_mixed_add(point, generator_table[window*16u + digit]); } + } + if f6_equal(point.z, F6()) { results[lane] = Result(3u,0u,0u,0u,array()); return; } + let digest = hash_point(j_affine(point)); + results[lane] = Result(select(0u, 1u, prefix_matches(digest)), 0u, 1u, 0u, digest); +} diff --git a/tx-types/vanity/browser/shaders/search.wgsl b/tx-types/vanity/browser/shaders/search.wgsl new file mode 100644 index 0000000..ed57013 --- /dev/null +++ b/tx-types/vanity/browser/shaders/search.wgsl @@ -0,0 +1,24 @@ +@group(0) @binding(0) var points: array; +@group(0) @binding(1) var config: array; +@group(0) @binding(2) var results: array; + +@compute @workgroup_size(32) +fn mine(@builtin(global_invocation_id) id: vec3) { + let lane = id.x; + if lane >= config[0] { return; } + var point = points[lane]; + var result: Result; + for (var step = 0u; step < config[1]; step++) { + if point.offset == 0xffffffffu { result.found = 2u; break; } + result.digest = hash_point(point); + result.offset = point.offset; + result.tested++; + if prefix_matches(result.digest) { + result.found = 1u; + break; + } + point = advance(point); + } + points[lane] = point; + results[lane] = result; +} diff --git a/tx-types/vanity/browser/shaders/sha512.wgsl b/tx-types/vanity/browser/shaders/sha512.wgsl new file mode 100644 index 0000000..225c34d --- /dev/null +++ b/tx-types/vanity/browser/shaders/sha512.wgsl @@ -0,0 +1,137 @@ +// SHA-512 constants and compression, FIPS 180-4. Words are (low, high). +fn rotate64(a: U64, n: u32) -> U64 { + if n == 32u { return a.yx; } + if n < 32u { return (a >> vec2(n)) | (a.yx << vec2(32u-n)); } + return (a.yx >> vec2(n-32u)) | (a << vec2(64u-n)); +} +fn shift64(a: U64, n: u32) -> U64 { + return U64((a.x >> n) | (a.y << (32u-n)), a.y >> n); +} +alias ShaState = array; +alias ShaBlock = array; + +fn sha512_compress(state: ShaState, message: ShaBlock) -> ShaState { + var schedule = message; + var a = state[0]; var b = state[1]; var c = state[2]; var d = state[3]; + var e = state[4]; var f = state[5]; var g = state[6]; var h = state[7]; + for (var t = 0u; t < 80u; t++) { + let slot = t & 15u; + if t >= 16u { + let x = schedule[(t-15u) & 15u]; + let y = schedule[(t-2u) & 15u]; + let small0 = rotate64(x, 1u) ^ rotate64(x, 8u) ^ shift64(x, 7u); + let small1 = rotate64(y, 19u) ^ rotate64(y, 61u) ^ shift64(y, 6u); + schedule[slot] = add64(add64(schedule[slot], small0), add64(schedule[(t-7u) & 15u], small1)); + } + let big1 = rotate64(e, 14u) ^ rotate64(e, 18u) ^ rotate64(e, 41u); + let choose = (e & f) ^ (~e & g); + let first = add64(add64(add64(h, big1), choose), add64(SHA512_K[t], schedule[slot])); + let big0 = rotate64(a, 28u) ^ rotate64(a, 34u) ^ rotate64(a, 39u); + let majority = (a & b) ^ (a & c) ^ (b & c); + let second = add64(big0, majority); + h = g; g = f; f = e; e = add64(d, first); + d = c; c = b; b = a; a = add64(first, second); + } + return ShaState(add64(state[0], a), add64(state[1], b), add64(state[2], c), add64(state[3], d), + add64(state[4], e), add64(state[5], f), add64(state[6], g), add64(state[7], h)); +} + +// Hash exactly 64 bytes after an already-compressed 128-byte HMAC pad. +fn sha512_after_pad(state: ShaState, data: ShaState) -> ShaState { + var block = ShaBlock(); + for (var i = 0u; i < 8u; i++) { block[i] = data[i]; } + block[8] = U64(0u, 0x80000000u); + block[15] = U64(1536u, 0u); + return sha512_compress(state, block); +} +fn hmac512_64(inner: ShaState, outer: ShaState, data: ShaState) -> ShaState { + return sha512_after_pad(outer, sha512_after_pad(inner, data)); +} +const SHA512_K = array(U64(3609767458u, 1116352408u), +U64(602891725u, 1899447441u), +U64(3964484399u, 3049323471u), +U64(2173295548u, 3921009573u), +U64(4081628472u, 961987163u), +U64(3053834265u, 1508970993u), +U64(2937671579u, 2453635748u), +U64(3664609560u, 2870763221u), +U64(2734883394u, 3624381080u), +U64(1164996542u, 310598401u), +U64(1323610764u, 607225278u), +U64(3590304994u, 1426881987u), +U64(4068182383u, 1925078388u), +U64(991336113u, 2162078206u), +U64(633803317u, 2614888103u), +U64(3479774868u, 3248222580u), +U64(2666613458u, 3835390401u), +U64(944711139u, 4022224774u), +U64(2341262773u, 264347078u), +U64(2007800933u, 604807628u), +U64(1495990901u, 770255983u), +U64(1856431235u, 1249150122u), +U64(3175218132u, 1555081692u), +U64(2198950837u, 1996064986u), +U64(3999719339u, 2554220882u), +U64(766784016u, 2821834349u), +U64(2566594879u, 2952996808u), +U64(3203337956u, 3210313671u), +U64(1034457026u, 3336571891u), +U64(2466948901u, 3584528711u), +U64(3758326383u, 113926993u), +U64(168717936u, 338241895u), +U64(1188179964u, 666307205u), +U64(1546045734u, 773529912u), +U64(1522805485u, 1294757372u), +U64(2643833823u, 1396182291u), +U64(2343527390u, 1695183700u), +U64(1014477480u, 1986661051u), +U64(1206759142u, 2177026350u), +U64(344077627u, 2456956037u), +U64(1290863460u, 2730485921u), +U64(3158454273u, 2820302411u), +U64(3505952657u, 3259730800u), +U64(106217008u, 3345764771u), +U64(3606008344u, 3516065817u), +U64(1432725776u, 3600352804u), +U64(1467031594u, 4094571909u), +U64(851169720u, 275423344u), +U64(3100823752u, 430227734u), +U64(1363258195u, 506948616u), +U64(3750685593u, 659060556u), +U64(3785050280u, 883997877u), +U64(3318307427u, 958139571u), +U64(3812723403u, 1322822218u), +U64(2003034995u, 1537002063u), +U64(3602036899u, 1747873779u), +U64(1575990012u, 1955562222u), +U64(1125592928u, 2024104815u), +U64(2716904306u, 2227730452u), +U64(442776044u, 2361852424u), +U64(593698344u, 2428436474u), +U64(3733110249u, 2756734187u), +U64(2999351573u, 3204031479u), +U64(3815920427u, 3329325298u), +U64(3928383900u, 3391569614u), +U64(566280711u, 3515267271u), +U64(3454069534u, 3940187606u), +U64(4000239992u, 4118630271u), +U64(1914138554u, 116418474u), +U64(2731055270u, 174292421u), +U64(3203993006u, 289380356u), +U64(320620315u, 460393269u), +U64(587496836u, 685471733u), +U64(1086792851u, 852142971u), +U64(365543100u, 1017036298u), +U64(2618297676u, 1126000580u), +U64(3409855158u, 1288033470u), +U64(4234509866u, 1501505948u), +U64(987167468u, 1607167915u), +U64(1246189591u, 1816402316u)); +const SHA512_IV = array(U64(4089235720u, 1779033703u), +U64(2227873595u, 3144134277u), +U64(4271175723u, 1013904242u), +U64(1595750129u, 2773480762u), +U64(2917565137u, 1359893119u), +U64(725511199u, 2600822924u), +U64(4215389547u, 528734635u), +U64(327033209u, 1541459225u)); diff --git a/tx-types/vanity/browser/shaders/tip5.wgsl b/tx-types/vanity/browser/shaders/tip5.wgsl new file mode 100644 index 0000000..43ccf5c --- /dev/null +++ b/tx-types/vanity/browser/shaders/tip5.wgsl @@ -0,0 +1,47 @@ +fn lookup_word(word: u32) -> u32 { + return LOOKUP[word & 255u] | (LOOKUP[(word >> 8u) & 255u] << 8u) + | (LOOKUP[(word >> 16u) & 255u] << 16u) | (LOOKUP[word >> 24u] << 24u); +} +fn permute(input: array) -> array { + var state = input; + for (var round = 0u; round < 7u; round++) { + var substituted: array; + for (var i = 0u; i < 4u; i++) { + substituted[i] = U64(lookup_word(state[i].x), lookup_word(state[i].y)); + } + for (var i = 4u; i < 16u; i++) { + let square = fmul(state[i], state[i]); + let fourth = fmul(square, square); + substituted[i] = fmul(fmul(fourth, square), state[i]); + } + for (var i = 0u; i < 16u; i++) { + var sum = ZERO; + for (var j = 0u; j < 16u; j++) { + sum = fadd(sum, fmul(U64(MDS[(j + 16u - i) % 16u], 0u), substituted[j])); + } + state[i] = fadd(sum, ROUND_CONSTANTS[round * 16u + i]); + } + } + return state; +} +fn hash_point(point: Point) -> array { + var transcript: array; + transcript[0] = U64(13u, 0u); + for (var i = 0u; i < 6u; i++) { + transcript[i+1u] = point.x[i]; + transcript[i+7u] = point.y[i]; + } + transcript[13] = ONE; + for (var i = 0u; i < 24u; i++) { transcript[14u+i] = U64(DYCK[i], 0u); } + transcript[38] = ONE; // TIP5 varlen padding + var state: array; + for (var block = 0u; block < 4u; block++) { + for (var i = 0u; i < 10u; i++) { + state[i] = fmul(transcript[block * 10u + i], MONT_R); + } + state = permute(state); + } + var digest: array; + for (var i = 0u; i < 5u; i++) { digest[i] = fmul(state[i], MONT_R_INVERSE); } + return digest; +} diff --git a/tx-types/vanity/browser/wasm.js b/tx-types/vanity/browser/wasm.js new file mode 100644 index 0000000..f24f017 --- /dev/null +++ b/tx-types/vanity/browser/wasm.js @@ -0,0 +1,128 @@ +// All views into WASM memory are refreshed after calls that can allocate. +export class WasmKeys { + constructor(exports) { this.exports = exports; } + + static async load(url = new URL("./generated/vanity_pkh.wasm", import.meta.url)) { + const response = await fetch(url); + if (!response.ok) throw new Error("Could not load the vanity mining WASM module."); + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {}); + return new WasmKeys(instance.exports); + } + + reset(lanes) { + if (!Number.isInteger(lanes) || this.exports.reset(lanes) !== 1) { + throw new Error("Choose between 1 and 256 GPU lanes."); + } + this.lanes = lanes; + } + + prefix(text, insensitive = false) { + const bytes = new TextEncoder().encode(text); + if (bytes.length < 1 || bytes.length > 55) throw new Error("Use a prefix of 1 to 55 characters."); + const e = this.exports; + new Uint8Array(e.memory.buffer, e.prefix_ptr(), bytes.length).set(bytes); + if (e.set_prefix(bytes.length, Number(insensitive)) !== 1) { + throw new Error("This prefix cannot match. Exact mode uses Base58 (no 0, O, I, or l); insensitive mode also accepts their equivalents."); + } + return new Uint32Array(e.memory.buffer, e.masks_ptr(), 110).slice(); + } + + setSeed(lane, bytes) { + if (bytes.length !== 32) throw new Error("A seed must contain 32 bytes."); + const e = this.exports; + new Uint8Array(e.memory.buffer, e.seed_ptr(), 32).set(bytes); + return e.set_seed(lane) === 1; + } + + seedRandomly() { + const bytes = new Uint8Array(32); + try { + for (let lane = 0; lane < this.lanes; lane++) { + do { crypto.getRandomValues(bytes); } while (!this.setSeed(lane, bytes)); + } + } finally { bytes.fill(0); } + } + + points() { + const e = this.exports; + return new Uint32Array(e.memory.buffer, e.points_ptr(), this.lanes * 26).slice(); + } + + reference(lane, offset) { + const e = this.exports; + if (e.reference_candidate(lane, offset) !== 1) throw new Error("Invalid reference candidate."); + return new Uint32Array(e.memory.buffer, e.reference_ptr(), 34).slice(); + } + + verify(lane, offset) { + const e = this.exports; + if (e.verify_match(lane, offset) !== 1) throw new Error("GPU match failed independent WASM verification."); + return new Uint8Array(e.memory.buffer, e.output_ptr(), e.output_len()).slice(); + } + + cpuStart() { + if (this.exports.cpu_start() !== 1) throw new Error("Cannot initialize the WASM CPU search."); + } + + mnemonicStart(bytes) { + if (bytes.length !== 32) throw new Error("Mnemonic entropy must contain 32 bytes."); + const e = this.exports; + new Uint8Array(e.memory.buffer, e.seed_ptr(), 32).set(bytes); + if (e.mnemonic_start() !== 1) throw new Error("Cannot initialize mnemonic search."); + } + + mnemonicGpuStart(bytes) { + if (bytes.length !== 32) throw new Error("Mnemonic entropy must contain 32 bytes."); + const e = this.exports; + new Uint8Array(e.memory.buffer, e.seed_ptr(), 32).set(bytes); + if (e.mnemonic_gpu_start() !== 1) throw new Error("Cannot initialize GPU mnemonic search."); + } + + mnemonicGpuPrepare(count) { + const e = this.exports; + const prepared = e.mnemonic_gpu_prepare(count); + if (prepared === 0xffffffff) throw new Error("Invalid GPU mnemonic batch."); + const input = new Uint32Array(e.memory.buffer, e.mnemonic_gpu_input_ptr(), prepared * 32).slice(); + e.mnemonic_gpu_clear_input(); + return input; + } + + mnemonicGpuReference(lane) { + const e = this.exports; + if (e.mnemonic_gpu_reference(lane) !== 1) throw new Error("Invalid mnemonic reference candidate."); + return new Uint32Array(e.memory.buffer, e.reference_ptr(), 34).slice(); + } + + mnemonicGpuVerify(lane) { + const e = this.exports; + if (e.mnemonic_gpu_verify(lane) !== 1) throw new Error("GPU mnemonic match failed CPU recovery verification."); + return new Uint8Array(e.memory.buffer, e.output_ptr(), e.output_len()).slice(); + } + + mnemonicBatch() { + const e = this.exports; + const status = e.mnemonic_batch(); + if (status === 3) throw new Error("Invalid mnemonic search state."); + return { status, tested: e.cpu_attempts() }; + } + + mnemonicVerify() { + const e = this.exports; + if (e.mnemonic_verify() !== 1) throw new Error("Mnemonic recovery verification failed."); + return new Uint8Array(e.memory.buffer, e.output_ptr(), e.output_len()).slice(); + } + + mnemonicDigest() { + const e = this.exports; + return new Uint32Array(e.memory.buffer, e.reference_ptr() + 24 * 4, 10).slice(); + } + + cpuBatch(limit) { + const e = this.exports; + const status = e.cpu_batch(limit); + if (status === 3) throw new Error("Invalid WASM CPU search state."); + return { status, tested: e.cpu_attempts(), lane: 0, offset: e.cpu_match_offset() }; + } + + clear() { this.exports.clear(); } +} diff --git a/tx-types/vanity/browser/worker.js b/tx-types/vanity/browser/worker.js new file mode 100644 index 0000000..2c8fd7c --- /dev/null +++ b/tx-types/vanity/browser/worker.js @@ -0,0 +1,162 @@ +import { WasmKeys } from "./wasm.js"; +import { GpuSearch, WebGpuUnavailableError } from "./gpu.js"; +import { MnemonicGpuSearch } from "./mnemonic-gpu.js"; +import { mnemonicSelfTest } from "./mnemonic-self-test.js"; +import { addressFromDigest, selfTest } from "./self-test.js"; + +let active = false; +let cancelled = false; +let gpu; +const status = (message) => postMessage({ type: "status", message }); + +async function mineMnemonic(keys, options) { + const { prefix, insensitive, maxAttempts, lanes, backend = "auto" } = options; + let reason = "CPU selected"; + if (backend === "auto") { + status("Preparing GPU mnemonic derivation…"); + try { gpu = await MnemonicGpuSearch.create(options); } + catch (error) { + if (!(error instanceof WebGpuUnavailableError)) throw error; + reason = error.message; + } + } + if (cancelled) return; + if (gpu) { + status("Checking GPU mnemonic recovery against Rust…"); + await mnemonicSelfTest(gpu, keys); + } + if (cancelled) return; + keys.reset(1); + const masks = keys.prefix(prefix, insensitive); + const entropy = new Uint8Array(32); + try { + crypto.getRandomValues(entropy); + if (gpu) keys.mnemonicGpuStart(entropy); else keys.mnemonicStart(entropy); + } finally { entropy.fill(0); } + if (gpu) gpu.initialize(lanes, masks, prefix.length); + const adapter = gpu ? `WebGPU · ${gpu.adapterName}` : "CPU · WASM"; + postMessage({ type: "backend", adapter, reason: gpu ? "24-word mnemonic search." : reason }); + const started = performance.now(); + let attempts = 0; + while (!cancelled && (!maxAttempts || attempts < maxAttempts)) { + let results; + if (gpu) { + const count = Math.min(lanes, maxAttempts ? maxAttempts - attempts : lanes); + const input = keys.mnemonicGpuPrepare(count); + if (!input.length) throw new Error("Mnemonic entropy range exhausted. Start a fresh search."); + results = await gpu.dispatch(input); + } else { + results = [keys.mnemonicBatch()]; + await new Promise(resolve => setTimeout(resolve, 0)); + } + if (cancelled) return; + if (results.some(result => result.status === 3)) throw new Error("GPU mnemonic derivation failed."); + attempts += results.reduce((sum, result) => sum + result.tested, 0); + const found = results.find(result => result.status === 1); + if (found) { + const bytes = gpu ? keys.mnemonicGpuVerify(found.lane) : keys.mnemonicVerify(); + const digest = keys.mnemonicDigest(); + if (gpu && !found.digest.every((word,i) => word === digest[i])) { + bytes.fill(0); throw new Error("GPU mnemonic address failed CPU verification."); + } + const pkh = addressFromDigest(digest); + postMessage({ type: "match", pkh, bytes: bytes.buffer, attempts, adapter }, [bytes.buffer]); + return; + } + if (results.some(result => result.status === 2)) throw new Error("Mnemonic entropy range exhausted. Start a fresh search."); + const seconds = (performance.now() - started) / 1000; + postMessage({ type: "progress", attempts, seconds, rate: attempts / seconds, adapter }); + } + if (!cancelled) postMessage({ type: "limit", attempts }); +} + +self.onmessage = async ({ data }) => { + if (data.type === "stop") { + cancelled = true; + gpu?.destroy(); + return; + } + if (data.type !== "start" || active) return; + active = true; + cancelled = false; + let keys; + try { + const { prefix, insensitive, lanes, steps, maxAttempts, backend = "auto", keyMode = "mnemonic" } = data; + if (!["auto", "cpu"].includes(backend)) throw new Error("Choose Automatic or CPU for the backend."); + if (!["mnemonic", "raw"].includes(keyMode)) throw new Error("Choose mnemonic or raw keys."); + if (!Number.isInteger(steps) || steps < 1 || steps > 16) throw new Error("Steps must be between 1 and 16."); + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 0) throw new Error("Attempt limit must be a nonnegative safe integer."); + keys = await WasmKeys.load(data.wasmUrl); + if (cancelled) return; + keys.reset(keyMode === "mnemonic" ? 1 : lanes); + keys.prefix(prefix, insensitive); + if (keyMode === "mnemonic") { + await mineMnemonic(keys, data); + return; + } + let cpuReason = "CPU selected"; + if (backend === "auto") { + status("Checking WebGPU availability…"); + try { gpu = await GpuSearch.create({ shaderUrls: data.shaderUrls }); } + catch (error) { + if (!(error instanceof WebGpuUnavailableError)) throw error; + cpuReason = error.message; + } + } + if (cancelled) return; + if (gpu) { + status("Checking GPU results against WASM…"); + await selfTest(gpu, keys); + } + if (cancelled) return; + keys.reset(gpu ? lanes : 1); + const masks = keys.prefix(prefix, insensitive); + status("Generating private starting keys…"); + keys.seedRandomly(); + if (gpu) gpu.initialize(keys.points(), masks, prefix.length); + else keys.cpuStart(); + const adapter = gpu ? `WebGPU · ${gpu.adapterName}` : "CPU · WASM"; + postMessage({ type: "backend", adapter, reason: gpu ? "" : cpuReason }); + const started = performance.now(); + let attempts = 0; + while (!cancelled) { + // A partial last batch runs only the remaining number of lanes. The + // active-lane count lives in config; persistent point storage is intact. + const remaining = maxAttempts ? maxAttempts - attempts : Number.MAX_SAFE_INTEGER; + if (remaining <= 0) break; + let activeResults; + if (gpu) { + const batchSteps = Math.min(steps, Math.max(1, Math.floor(remaining / lanes))); + gpu.config[0] = Math.min(lanes, remaining); + const results = await gpu.dispatch(batchSteps); + // Inactive lanes retain their previous result records; exclude them. + activeResults = results.slice(0, gpu.config[0]); + } else { + activeResults = [keys.cpuBatch(Math.min(128, remaining))]; + // Yield a task, not just a microtask, so Stop messages can be handled. + await new Promise(resolve => setTimeout(resolve, 0)); + } + attempts += activeResults.reduce((sum, result) => sum + result.tested, 0); + const found = activeResults.find((result) => result.status === 1); + if (cancelled) break; + if (found) { + const bytes = keys.verify(found.lane, found.offset); + const pkh = addressFromDigest(keys.reference(found.lane, found.offset).slice(24)); + postMessage({ type: "match", pkh, bytes: bytes.buffer, attempts, adapter }, [bytes.buffer]); + return; + } + if (activeResults.some((result) => result.status === 2)) throw new Error("Search offset range exhausted. Start a fresh search."); + const seconds = (performance.now() - started) / 1000; + postMessage({ type: "progress", attempts, seconds, rate: attempts / seconds, adapter }); + } + if (!cancelled) postMessage({ type: "limit", attempts }); + } catch (error) { + if (!cancelled) postMessage({ type: "error", message: error.message }); + } finally { + keys?.clear(); + gpu?.destroy(); + gpu = undefined; + active = false; + if (cancelled) postMessage({ type: "stopped" }); + } +}; diff --git a/tx-types/vanity/build-browser.py b/tx-types/vanity/build-browser.py new file mode 100644 index 0000000..482cb58 --- /dev/null +++ b/tx-types/vanity/build-browser.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Build the WASM module and WGSL constants from tx-types' Rust source.""" +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = Path(__file__).resolve().parent / "browser" +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--out-dir", type=Path, required=True, help="Browser library asset destination") +args = parser.parse_args() +DEST = args.out_dir.resolve() +OUT = DEST / "generated" + + +def values(source, name): + match = re.search(rf"(?:pub )?const {name}:.*?=\s*(?:F6lt\()?\[(.*?)\]\)?;", source, re.S) + if not match: + raise ValueError(f"Missing Rust constant: {name}") + body = re.sub(r"//[^\n]*", "", match[1]) + return [int(number.replace("_", ""), 0) for number in + re.findall(r"\b(?:0x[0-9a-fA-F_]+|[0-9][0-9_]*)\b", body)] + + +def u64(value): + return f"U64({value & 0xffffffff}u, {value >> 32}u)" + + +def generate(): + gold = (ROOT / "tx-types/src/crypto/goldilocks.rs").read_text() + curve = (ROOT / "tx-types/src/crypto/cheetah_nostd.rs").read_text() + lookup = values(gold, "LOOKUP_TABLE") + matrix = values(gold, "MDS_MATRIX_I64") + rounds = values(gold, "ROUND_CONSTANTS") + gx, gy = values(curve, "GX"), values(curve, "GY") + order_bytes = bytes(values(curve, "CHEETAH_N")) + order = [int.from_bytes(order_bytes[i:i+8], "big") for i in range(0, 32, 8)] + assert (len(lookup), len(matrix), len(rounds), len(gx), len(gy)) == (256, 256, 112, 6, 6) + first = matrix[:16] + assert all(matrix[i*16:(i+1)*16] == first[-i:] + first[:-i] if i else + matrix[:16] == first for i in range(16)) + prime = 2**64 - 2**32 + 1 + mont_r = pow(2, 64, prime) + lines = ["// Generated from tx-types Rust constants by build-browser.py."] + for name, entries, kind, formatter in [ + ("LOOKUP", lookup, "u32", lambda n: f"{n}u"), + ("MDS", first, "u32", lambda n: f"{n}u"), + ("ROUND_CONSTANTS", [n * mont_r % prime for n in rounds], "U64", u64), + ("GX", gx, "U64", u64), ("GY", gy, "U64", u64), + ("CHEETAH_ORDER", order, "U64", u64), + ("DYCK", [0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1] * 2, "u32", lambda n: f"{n}u"), + ]: + lines.append(f"const {name} = array<{kind}, {len(entries)}>(\n" + + ",\n".join(" " + formatter(n) for n in entries) + "\n);") + lines += [f"const MONT_R = {u64(mont_r)};", + f"const MONT_R_INVERSE = {u64(pow(mont_r, -1, prime))};"] + OUT.mkdir(parents=True, exist_ok=True) + (OUT / "constants.wgsl").write_text("\n".join(lines) + "\n") + + +if __name__ == "__main__": + shutil.copytree(SOURCE, DEST, dirs_exist_ok=True) + generate() + cargo = os.environ.get("CARGO", "cargo") + subprocess.run([cargo, "build", "--release", "-p", "vanity-pkh-wasm", + "--target", "wasm32-unknown-unknown"], cwd=ROOT, check=True) + # Cargo metadata respects CARGO_TARGET_DIR and local Cargo configuration. + import json + metadata = json.loads(subprocess.check_output( + [cargo, "metadata", "--no-deps", "--format-version", "1"], cwd=ROOT)) + artifact = Path(metadata["target_directory"]) / "wasm32-unknown-unknown/release/vanity_pkh_wasm.wasm" + shutil.copyfile(artifact, OUT / "vanity_pkh.wasm") + subprocess.run([cargo, "run", "--quiet", "--release", "-p", "tx-types", "--no-default-features", + "--example", "vanity_gpu_table", "--", str(OUT / "generator-table.bin")], + cwd=ROOT, check=True) + print(f"Built browser library assets in {DEST}") diff --git a/tx-types/vanity/wasm/Cargo.toml b/tx-types/vanity/wasm/Cargo.toml new file mode 100644 index 0000000..a58005d --- /dev/null +++ b/tx-types/vanity/wasm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "vanity-pkh-wasm" +version.workspace = true +edition.workspace = true +description = "WASM key custody and verification for the WebGPU vanity PKH miner" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +tx-types = { path = "../..", default-features = false, features = ["vanity-export"] } +zeroize = { workspace = true } +bs58 = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/tx-types/vanity/wasm/src/lib.rs b/tx-types/vanity/wasm/src/lib.rs new file mode 100644 index 0000000..b1d8435 --- /dev/null +++ b/tx-types/vanity/wasm/src/lib.rs @@ -0,0 +1,547 @@ +//! Fixed-buffer WASM ABI for the browser's WebGPU runner. +//! +//! JavaScript writes only to `seed_ptr` (32 bytes) and `prefix_ptr` (55 bytes). +//! All other exported pointers are read-only views valid until the next call. +//! Refresh typed-array views after a call: WASM memory can grow. Mnemonic GPU +//! batches expose private HMAC key blocks; callers must erase their copies. + +mod mnemonic_gpu; + +use std::cell::RefCell; +use tx_types::crypto::cheetah_nostd::cheetah_pub_from_sk; +use tx_types::crypto::utils_nostd::{be32_lt, is_zero32, CHEETAH_N}; +use tx_types::crypto::vanity::{ + derive_mnemonic, encode_pkh, key_json, pkh_from_public_key, Match, MatchMode, MnemonicMatch, + MnemonicSearch, Prefix, Search, +}; +use zeroize::{Zeroize, Zeroizing}; + +const MAX_LANES: usize = 256; +const POINT_WORDS: usize = 26; + +struct Session { + seed_input: Zeroizing<[u8; 32]>, + prefix_input: [u8; 55], + prefix: Option, + masks: [u32; 110], + seeds: Vec>>, + points: Vec, + reference: [u32; 34], + output: Zeroizing>, + mnemonic: Option, + mnemonic_match: Option, + gpu_mnemonic: mnemonic_gpu::GpuMnemonic, + cpu: Option, + cpu_total: u32, + cpu_attempts: u32, +} + +impl Default for Session { + fn default() -> Self { + Self { + seed_input: Zeroizing::new([0; 32]), + prefix_input: [0; 55], + prefix: None, + masks: [0; 110], + seeds: Vec::new(), + points: Vec::new(), + reference: [0; 34], + output: Zeroizing::new(Vec::new()), + mnemonic: None, + mnemonic_match: None, + gpu_mnemonic: mnemonic_gpu::GpuMnemonic::default(), + cpu: None, + cpu_total: 0, + cpu_attempts: 0, + } + } +} + +thread_local! { + static SESSION: RefCell = RefCell::new(Session::default()); +} + +fn offset_key(key: &[u8; 32], offset: u32) -> Option> { + let mut out = Zeroizing::new(*key); + let mut carry = offset as u64; + for byte in out.iter_mut().rev() { + carry += *byte as u64; + *byte = carry as u8; + carry >>= 8; + } + (carry == 0 && !is_zero32(&out) && be32_lt(&out, &CHEETAH_N)).then_some(out) +} + +fn split_words(words: impl Iterator, out: &mut [u32; N]) { + for (word, pair) in words.zip(out.chunks_exact_mut(2)) { + pair[0] = word as u32; + pair[1] = (word >> 32) as u32; + } +} + +/// Returns 1 on success, 0 for an invalid lane count. Clears all session keys. +#[no_mangle] +pub extern "C" fn reset(lanes: usize) -> u32 { + SESSION.with(|cell| { + let mut session = cell.borrow_mut(); + *session = Session::default(); + if !(1..=MAX_LANES).contains(&lanes) { + return 0; + } + session.seeds.resize_with(lanes, || None); + session.points.resize(lanes * POINT_WORDS, 0); + 1 + }) +} + +#[no_mangle] +pub extern "C" fn seed_ptr() -> *mut u8 { + SESSION.with(|s| s.borrow_mut().seed_input.as_mut_ptr()) +} + +#[no_mangle] +pub extern "C" fn prefix_ptr() -> *mut u8 { + SESSION.with(|s| s.borrow_mut().prefix_input.as_mut_ptr()) +} + +/// Returns 1 for a valid canonical prefix. Clears the selected prefix on error. +#[no_mangle] +pub extern "C" fn set_prefix(len: usize, insensitive: u32) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.prefix = None; + s.masks.fill(0); + let mode = match insensitive { + 0 => MatchMode::Exact, + 1 => MatchMode::Insensitive, + _ => return 0, + }; + s.prefix = s + .prefix_input + .get(..len) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .and_then(|text| Prefix::with_mode(text, mode).ok()); + if let Some(prefix) = s.prefix { + for (dest, mask) in s.masks.chunks_exact_mut(2).zip(prefix.digit_masks()) { + dest.copy_from_slice(&mask); + } + } + u32::from(s.prefix.is_some()) + }) +} + +#[no_mangle] +pub extern "C" fn masks_ptr() -> *const u32 { + SESSION.with(|s| s.borrow().masks.as_ptr()) +} + +/// Rejection sampling: 0 means the caller must draw another seed; 1 accepts it. +/// Reserves u32::MAX offsets below the group order, so GPU walks never wrap. +#[no_mangle] +pub extern "C" fn set_seed(lane: usize) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + let key = Zeroizing::new(*s.seed_input); + s.seed_input.zeroize(); + if lane >= s.seeds.len() || is_zero32(&key) || offset_key(&key, u32::MAX).is_none() { + return 0; + } + let public = cheetah_pub_from_sk(*key); + let mut words = [0; POINT_WORDS]; + split_words(public.0.into_iter().chain(public.1), &mut words); + s.points[lane * POINT_WORDS..(lane + 1) * POINT_WORDS].copy_from_slice(&words); + s.seeds[lane] = Some(key); + 1 + }) +} + +#[no_mangle] +pub extern "C" fn points_ptr() -> *const u32 { + SESSION.with(|s| s.borrow().points.as_ptr()) +} + +/// Derive a candidate using the independent Rust path. The 34 returned words +/// are x (12), y (12), and PKH (10), each u64 split low word then high word. +#[no_mangle] +pub extern "C" fn reference_candidate(lane: usize, offset: u32) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + let Some(Some(seed)) = s.seeds.get(lane) else { + return 0; + }; + let Some(key) = offset_key(seed, offset) else { + return 0; + }; + let public = cheetah_pub_from_sk(*key); + let hash = pkh_from_public_key(&public); + split_words( + public.0.into_iter().chain(public.1).chain(hash), + &mut s.reference, + ); + 1 + }) +} + +#[no_mangle] +pub extern "C" fn reference_ptr() -> *const u32 { + SESSION.with(|s| s.borrow().reference.as_ptr()) +} + +/// Start a CPU walk from lane zero, using the shared no_std search core. +#[no_mangle] +pub extern "C" fn cpu_start() -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.cpu = None; + s.cpu_total = 0; + s.cpu_attempts = 0; + if s.prefix.is_none() { + return 0; + } + let Some(Some(seed)) = s.seeds.first() else { + return 0; + }; + s.cpu = Search::new(Zeroizing::new(**seed)).ok(); + u32::from(s.cpu.is_some()) + }) +} + +/// Test 1..=256 candidates. Returns 0 (continue), 1 (match), 2 (exhausted), +/// or 3 (invalid state/limit). A bounded batch lets the worker process Stop. +#[no_mangle] +pub extern "C" fn cpu_batch(limit: u32) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.cpu_attempts = 0; + if !(1..=256).contains(&limit) { + return 3; + } + let Some(prefix) = s.prefix else { + return 3; + }; + let limit = limit.min(u32::MAX - s.cpu_total); + let Some(search) = s.cpu.as_mut() else { + return 3; + }; + if limit == 0 { + return 2; + } + let batch = search.search_batch(&prefix, limit as u64); + s.cpu_attempts = batch.attempts as u32; + s.cpu_total += s.cpu_attempts; + if batch.matched.is_some() { + 1 + } else if batch.exhausted || s.cpu_total == u32::MAX { + 2 + } else { + 0 + } + }) +} + +#[no_mangle] +pub extern "C" fn cpu_attempts() -> u32 { + SESSION.with(|s| s.borrow().cpu_attempts) +} + +/// Valid after cpu_batch returns 1; pass to verify_match(0, offset). +#[no_mangle] +pub extern "C" fn cpu_match_offset() -> u32 { + SESSION.with(|s| s.borrow().cpu_total.saturating_sub(1)) +} + +/// Consume seed_ptr as 256 bits of mnemonic entropy, without scalar rejection. +#[no_mangle] +pub extern "C" fn mnemonic_start() -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.mnemonic = None; + s.mnemonic_match = None; + s.output.zeroize(); + s.cpu_attempts = 0; + let entropy = Zeroizing::new(*s.seed_input); + s.seed_input.zeroize(); + if s.prefix.is_none() { + return 0; + } + s.mnemonic = Some(MnemonicSearch::new(entropy)); + 1 + }) +} + +/// Test one complete mnemonic derivation so the worker can handle cancellation. +/// Status codes match cpu_batch; cpu_attempts returns this call's candidate count. +#[no_mangle] +pub extern "C" fn mnemonic_batch() -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.cpu_attempts = 0; + s.mnemonic_match = None; + s.output.zeroize(); + let Some(prefix) = s.prefix else { + return 3; + }; + let Some(search) = s.mnemonic.as_mut() else { + return 3; + }; + let batch = search.search_batch(&prefix, 1); + s.cpu_attempts = batch.attempts as u32; + s.mnemonic_match = batch.matched; + if s.mnemonic_match.is_some() { + 1 + } else if batch.exhausted { + 2 + } else { + 0 + } + }) +} + +/// Re-derive the winning phrase before exporting recovery material. The reference +/// buffer receives the verified public point and PKH, with no private data. +#[no_mangle] +pub extern "C" fn mnemonic_verify() -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.output.zeroize(); + let Some(prefix) = s.prefix else { + return 0; + }; + let Some(found) = &s.mnemonic_match else { + return 0; + }; + let key = derive_mnemonic(&found.mnemonic); + if key.pkh != found.key.pkh + || *key.secret_key_be != *found.key.secret_key_be + || !prefix.matches(&encode_pkh(key.pkh)) + { + return 0; + } + let json = key_json(&key, Some(&found.mnemonic)); + split_words( + key.public_key + .0 + .into_iter() + .chain(key.public_key.1) + .chain(key.pkh), + &mut s.reference, + ); + s.output.extend_from_slice(json.as_bytes()); + 1 + }) +} + +/// Verify the prefix from the private scalar, then produce downloadable JSON. +/// Returns zero and clears output for an invalid lane or a false GPU match. +#[no_mangle] +pub extern "C" fn verify_match(lane: usize, offset: u32) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.output.zeroize(); + let Some(prefix) = s.prefix else { + return 0; + }; + let Some(Some(seed)) = s.seeds.get(lane) else { + return 0; + }; + let Some(key) = offset_key(seed, offset) else { + return 0; + }; + let public = cheetah_pub_from_sk(*key); + let hash = pkh_from_public_key(&public); + let address = encode_pkh(hash); + if !prefix.matches(&address) { + return 0; + } + let json = key_json( + &Match { + secret_key_be: key, + public_key: public, + pkh: hash, + }, + None, + ); + s.output.extend_from_slice(json.as_bytes()); + 1 + }) +} + +#[no_mangle] +pub extern "C" fn output_ptr() -> *const u8 { + SESSION.with(|s| s.borrow().output.as_ptr()) +} + +#[no_mangle] +pub extern "C" fn output_len() -> usize { + SESSION.with(|s| s.borrow().output.len()) +} + +/// Erase private session buffers after cancellation or download. +#[no_mangle] +pub extern "C" fn clear() { + SESSION.with(|s| *s.borrow_mut() = Session::default()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mnemonic_export_restores_the_wallet_vector_and_clears_on_reset() { + use tx_types::crypto::vanity::Mnemonic; + let phrase = "wedding chef bread absurd leader surge auction access document fiber chunk hurt earn rain swarm cotton leisure ozone drill switch cry jungle soda oxygen"; + // Parsing the known English vector also validates its checksum. + let mnemonic: Mnemonic = phrase.parse().unwrap(); + let (entropy, len) = mnemonic.to_entropy_array(); + assert_eq!(len, 32); + reset(1); + assert_eq!(mnemonic_batch(), 3); + assert_eq!(mnemonic_verify(), 0); + assert_eq!( + prefix( + "9QnMES6nZnbyqPKfTysvisj1UqvxjSkL4XQaAsMUafGVw2m7rrUHzsW", + false + ), + 1 + ); + SESSION.with(|s| s.borrow_mut().seed_input.copy_from_slice(&entropy[..32])); + assert_eq!(mnemonic_start(), 1); + SESSION.with(|s| assert_eq!(*s.borrow().seed_input, [0; 32])); + assert_eq!(mnemonic_batch(), 1); + assert_eq!(cpu_attempts(), 1); + assert_eq!(mnemonic_verify(), 1); + SESSION.with(|s| { + let s = s.borrow(); + let json: serde_json::Value = serde_json::from_slice(&s.output).unwrap(); + assert_eq!(json["mnemonic"], phrase); + assert_eq!(json["passphrase"], ""); + assert_eq!(json["derivation_path"], "m"); + let restored = derive_mnemonic(&json["mnemonic"].as_str().unwrap().parse().unwrap()); + assert_eq!(json["pkh"], encode_pkh(restored.pkh).as_str()); + assert_eq!( + bs58::decode(json["secret_key_base58"].as_str().unwrap()) + .into_vec() + .unwrap(), + *restored.secret_key_be + ); + }); + assert_eq!(prefix("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", false), 1); + assert_eq!(mnemonic_verify(), 0); + assert_eq!(output_len(), 0); + clear(); + assert_eq!(mnemonic_batch(), 3); + assert_eq!(mnemonic_verify(), 0); + } + + #[test] + fn cpu_batches_resume_and_produce_a_verified_key() { + reset(1); + assert_eq!(cpu_start(), 0); + assert_eq!(cpu_batch(1), 3); + let mut one = [0; 32]; + one[31] = 1; + assert_eq!(seed(one), 1); + let mut target = one; + target[31] = 4; + let address = encode_pkh(pkh_from_public_key(&cheetah_pub_from_sk(target))); + assert_eq!(prefix(address.as_str(), false), 1); + assert_eq!(cpu_start(), 1); + assert_eq!(cpu_batch(0), 3); + assert_eq!(cpu_batch(257), 3); + assert_eq!(cpu_batch(2), 0); + assert_eq!(cpu_attempts(), 2); + assert_eq!(cpu_batch(10), 1); + assert_eq!(cpu_attempts(), 2); + assert_eq!(cpu_match_offset(), 3); + assert_eq!(verify_match(0, cpu_match_offset()), 1); + clear(); + assert_eq!(cpu_batch(1), 3); + } + + fn seed(bytes: [u8; 32]) -> u32 { + SESSION.with(|s| *s.borrow_mut().seed_input = bytes); + set_seed(0) + } + + pub(super) fn prefix(text: &str, insensitive: bool) -> u32 { + SESSION + .with(|s| s.borrow_mut().prefix_input[..text.len()].copy_from_slice(text.as_bytes())); + set_prefix(text.len(), insensitive as u32) + } + + #[test] + fn rejects_invalid_seeds_and_reserves_the_offset_range() { + assert_eq!(reset(0), 0); + assert_eq!(reset(MAX_LANES + 1), 0); + assert_eq!(reset(1), 1); + assert_eq!(seed([0; 32]), 0); + assert_eq!(seed(CHEETAH_N), 0); + assert_eq!(seed([255; 32]), 0); + let mut near_order = CHEETAH_N; + near_order[31] -= 1; + assert_eq!(seed(near_order), 0); + let mut one = [0; 32]; + one[31] = 1; + assert_eq!(seed(one), 1); + SESSION.with(|s| assert_eq!(*s.borrow().seed_input, [0; 32])); + assert_eq!(reference_candidate(0, u32::MAX), 1); + assert_eq!(reference_candidate(1, 0), 0); + clear(); + assert_eq!(reference_candidate(0, 0), 0); + } + + #[test] + fn match_verification_derives_the_key_and_rejects_false_positives() { + reset(1); + let mut one = [0; 32]; + one[31] = 1; + assert_eq!(seed(one), 1); + let mut target = one; + target[31] = 4; + let public = cheetah_pub_from_sk(target); + let address = encode_pkh(pkh_from_public_key(&public)); + assert_eq!(prefix(address.as_str(), false), 1); + assert_eq!(verify_match(0, 2), 0); + assert_eq!(output_len(), 0); + assert_eq!(verify_match(0, 3), 1); + SESSION.with(|s| { + let s = s.borrow(); + let json: serde_json::Value = serde_json::from_slice(&s.output).unwrap(); + assert_eq!(json["secret_key_hex_be"], format!("{:064x}", 4)); + assert_eq!(json["pkh"], address.as_str()); + assert_eq!( + bs58::decode(json["secret_key_base58"].as_str().unwrap()) + .into_vec() + .unwrap(), + target + ); + }); + assert_eq!( + prefix(address.as_str().to_ascii_lowercase().as_str(), true), + 1 + ); + assert_eq!(verify_match(0, 3), 1); + assert_eq!(set_prefix(56, 0), 0); + assert_eq!(verify_match(0, 3), 0); + assert_eq!(output_len(), 0); + clear(); + } + + #[test] + fn wasm_masks_distinguish_capital_l_from_i() { + reset(1); + assert_eq!(prefix("I", false), 0); + assert_eq!(prefix("I", true), 1); + SESSION.with(|s| { + let masks = s.borrow().masks; + for (digit, &byte) in tx_types::crypto::vanity::BASE58_ALPHABET.iter().enumerate() { + assert_eq!( + masks[digit / 32] & (1 << (digit % 32)) != 0, + matches!(byte, b'i' | b'1') + ); + } + }); + assert_eq!(set_prefix(1, 2), 0); + SESSION.with(|s| assert_eq!(s.borrow().masks, [0; 110])); + } +} diff --git a/tx-types/vanity/wasm/src/mnemonic_gpu.rs b/tx-types/vanity/wasm/src/mnemonic_gpu.rs new file mode 100644 index 0000000..dcb6e8b --- /dev/null +++ b/tx-types/vanity/wasm/src/mnemonic_gpu.rs @@ -0,0 +1,194 @@ +use super::{split_words, SESSION}; +use sha2::{Digest, Sha512}; +use tx_types::crypto::vanity::{derive_mnemonic, encode_pkh, key_json, Mnemonic}; +use zeroize::{Zeroize, Zeroizing}; + +#[derive(Default)] +pub(super) struct GpuMnemonic { + next: Option>, + prepared: Vec>, + input: Zeroizing>, +} + +/// Start mnemonic GPU batches from the 256 entropy bits written to seed_ptr. +#[no_mangle] +pub extern "C" fn mnemonic_gpu_start() -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.gpu_mnemonic = GpuMnemonic::default(); + let entropy = Zeroizing::new(*s.seed_input); + s.seed_input.zeroize(); + s.output.zeroize(); + if s.prefix.is_none() { + return 0; + } + s.gpu_mnemonic.next = Some(entropy); + 1 + }) +} + +fn hmac_key(mnemonic: &Mnemonic) -> Zeroizing<[u8; 128]> { + let mut sentence = Zeroizing::new([0u8; 215]); + let mut length = 0; + for (index, word) in mnemonic.words().enumerate() { + if index != 0 { + sentence[length] = b' '; + length += 1; + } + sentence[length..length + word.len()].copy_from_slice(word.as_bytes()); + length += word.len(); + } + let mut key = Zeroizing::new([0; 128]); + if length > 128 { + let mut digest = Sha512::digest(&sentence[..length]); + key[..64].copy_from_slice(&digest); + digest.zeroize(); + } else { + key[..length].copy_from_slice(&sentence[..length]); + } + key +} + +/// Prepare up to 4096 independent candidates. Returns the count, zero at entropy +/// exhaustion, or u32::MAX for invalid state/count. Each input is 16 SHA-512 +/// words, represented as (low u32, high u32), from the padded HMAC key block. +#[no_mangle] +pub extern "C" fn mnemonic_gpu_prepare(count: usize) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + let batch = &mut s.gpu_mnemonic; + batch.prepared.clear(); + batch.input.zeroize(); + if !(1..=4096).contains(&count) { + return u32::MAX; + } + // Grow only while empty/erased so reallocations cannot leave copies of + // live entropy or HMAC key blocks in abandoned allocations. + batch.prepared.reserve(count); + batch.input.reserve(count * 32); + for _ in 0..count { + let Some(mut entropy) = batch.next.take() else { + break; + }; + let mnemonic = Mnemonic::from_entropy(entropy.as_ref()).expect("256-bit entropy"); + let key = hmac_key(&mnemonic); + for word in key.chunks_exact(8) { + let value = u64::from_be_bytes(word.try_into().unwrap()); + batch + .input + .extend_from_slice(&[value as u32, (value >> 32) as u32]); + } + batch.prepared.push(Zeroizing::new(*entropy)); + for byte in entropy.iter_mut().rev() { + let (next, carry) = byte.overflowing_add(1); + *byte = next; + if !carry { + batch.next = Some(entropy); + break; + } + } + } + batch.prepared.len() as u32 + }) +} + +#[no_mangle] +pub extern "C" fn mnemonic_gpu_input_ptr() -> *const u32 { + SESSION.with(|s| s.borrow().gpu_mnemonic.input.as_ptr()) +} + +#[no_mangle] +pub extern "C" fn mnemonic_gpu_clear_input() { + SESSION.with(|s| s.borrow_mut().gpu_mnemonic.input.zeroize()); +} + +fn candidate(lane: usize, verify: bool) -> u32 { + SESSION.with(|cell| { + let mut s = cell.borrow_mut(); + s.output.zeroize(); + let Some(entropy) = s.gpu_mnemonic.prepared.get(lane) else { + return 0; + }; + let mnemonic = Mnemonic::from_entropy(entropy.as_ref()).expect("256-bit entropy"); + let key = derive_mnemonic(&mnemonic); + if verify { + let Some(prefix) = s.prefix else { + return 0; + }; + if !prefix.matches(&encode_pkh(key.pkh)) { + return 0; + } + let json = key_json(&key, Some(&mnemonic)); + s.output.extend_from_slice(json.as_bytes()); + } + split_words( + key.public_key + .0 + .into_iter() + .chain(key.public_key.1) + .chain(key.pkh), + &mut s.reference, + ); + 1 + }) +} + +/// CPU reference for device self-tests. Returns public data in reference_ptr. +#[no_mangle] +pub extern "C" fn mnemonic_gpu_reference(lane: usize) -> u32 { + candidate(lane, false) +} + +/// Re-derive the selected phrase on CPU, check the prefix, and export its backup. +#[no_mangle] +pub extern "C" fn mnemonic_gpu_verify(lane: usize) -> u32 { + candidate(lane, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{clear, output_len, reset}; + + #[test] + fn prepared_candidates_resume_and_restore_after_input_is_erased() { + reset(1); + assert_eq!(mnemonic_gpu_start(), 0); + let entropy = [0x42; 32]; + let mut target = entropy; + target[31] += 2; + let mnemonic = Mnemonic::from_entropy(&target).unwrap(); + let address = encode_pkh(derive_mnemonic(&mnemonic).pkh); + assert_eq!(crate::tests::prefix(address.as_str(), false), 1); + SESSION.with(|s| *s.borrow_mut().seed_input = entropy); + assert_eq!(mnemonic_gpu_start(), 1); + assert_eq!(mnemonic_gpu_prepare(0), u32::MAX); + assert_eq!(mnemonic_gpu_prepare(2), 2); + assert_eq!(mnemonic_gpu_verify(0), 0); + assert_eq!(mnemonic_gpu_prepare(1), 1); + mnemonic_gpu_clear_input(); + assert_eq!(mnemonic_gpu_verify(1), 0); + assert_eq!(mnemonic_gpu_verify(0), 1); + SESSION.with(|s| { + let s = s.borrow(); + let json: serde_json::Value = serde_json::from_slice(&s.output).unwrap(); + assert_eq!(json["mnemonic"], mnemonic.to_string()); + assert_eq!(json["pkh"], address.as_str()); + assert!(s.gpu_mnemonic.input.is_empty()); + }); + clear(); + assert_eq!(output_len(), 0); + assert_eq!(mnemonic_gpu_verify(0), 0); + } + + #[test] + fn entropy_exhaustion_returns_a_partial_batch() { + reset(1); + crate::tests::prefix("A", false); + SESSION.with(|s| *s.borrow_mut().seed_input = [255; 32]); + assert_eq!(mnemonic_gpu_start(), 1); + assert_eq!(mnemonic_gpu_prepare(8), 1); + assert_eq!(mnemonic_gpu_prepare(8), 0); + clear(); + } +}