From 913ced20196ed769a6f75bf4d7c8b1b6f65ccbc5 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:49:38 +0200 Subject: [PATCH 1/2] feat(warp): add WarpWallet challenge collection and Expired status --- AGENTS.md | 7 +- build.rs | 165 +++++++++++++++++++++++++++++++++ data/warp.jsonc | 199 ++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 10 +- src/collections/mod.rs | 1 + src/collections/warp.rs | 49 ++++++++++ src/lib.rs | 13 ++- src/puzzle.rs | 5 +- tests/validation.rs | 33 ++++++- 9 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 data/warp.jsonc create mode 100644 src/collections/warp.rs diff --git a/AGENTS.md b/AGENTS.md index 7afd648..cff9b03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,11 @@ # BOHA - Project Knowledge Base -**Updated:** 2026-03-15 +**Updated:** 2026-03-30 **Branch:** main ## OVERVIEW -Rust library + CLI for crypto puzzle/bounty data. Build-time JSONC→Rust codegen with JSON Schema validation. Eight collections: arweave (11 bounties), b1000 (256 puzzles), ballet (3 puzzles), bitaps (1 SSSS puzzle), bitimage (2 puzzles), gsmg (1 puzzle), hash_collision (6 bounties), zden (15 visual puzzles). +Rust library + CLI for crypto puzzle/bounty data. Build-time JSONC→Rust codegen with JSON Schema validation. Nine collections: arweave (11 bounties), b1000 (256 puzzles), ballet (3 puzzles), bitaps (1 SSSS puzzle), bitimage (2 puzzles), gsmg (1 puzzle), hash_collision (6 bounties), warp (6 WarpWallet challenges), zden (15 visual puzzles). ## STRUCTURE @@ -53,7 +53,7 @@ boha/ | `Puzzle` | struct | puzzle.rs | Core data type (16 fields) | | `Address` | struct | puzzle.rs | value, chain, kind, hash160, witness_program | | `Key` | struct | puzzle.rs | hex, wif, seed, bits, shares | -| `Status` | enum | puzzle.rs | Solved/Unsolved/Claimed/Swept | +| `Status` | enum | puzzle.rs | Solved/Unsolved/Claimed/Swept/Expired | | `Chain` | enum | puzzle.rs | Bitcoin/Ethereum/Litecoin/Monero/Decred/Arweave | | `Seed` | struct | puzzle.rs | BIP39: phrase, path, xpub, entropy | | `Shares` | struct | puzzle.rs | SSSS: threshold, total, shares[] | @@ -130,6 +130,7 @@ Data-driven validation (254 tests, 3 test files): - bitaps: Shamir Secret Sharing - 2 of 3 shares published, third unknown - bitimage: Keys derived from files using SHA256(Base64(file)) as BIP39 entropy - hash_collision: Peter Todd's P2SH bounties for finding hash collisions +- warp: Keybase WarpWallet challenges - deterministic brainwallet (scrypt+pbkdf2) security tests - zden: Visual puzzles - keys encoded in images/animations - arweave: Tiamat's bounties on Arweave blockchain (chronobot.io) - Balances: mempool.space (BTC/LTC), Etherscan (ETH) diff --git a/build.rs b/build.rs index 064690d..4026801 100644 --- a/build.rs +++ b/build.rs @@ -503,6 +503,35 @@ struct HashCollisionPuzzle { solver: Option, } +#[derive(Debug, Deserialize)] +struct WarpMetadata { + source_url: Option, +} + +#[derive(Debug, Deserialize)] +struct WarpFile { + author: Option, + metadata: Option, + puzzles: Vec, +} + +#[derive(Debug, Deserialize)] +struct WarpPuzzle { + name: String, + address: Address, + status: String, + prize: Option, + currency: Option, + pubkey: Option, + start_date: Option, + solve_date: Option, + solve_time: Option, + source_url: Option, + #[serde(default)] + transactions: Vec, + solver: Option, +} + #[derive(Debug, Deserialize)] struct GsmgMetadata { source_url: Option, @@ -1139,6 +1168,7 @@ fn generate_data_version(out_dir: &str) { "data/gsmg.jsonc", "data/hash_collision.jsonc", "data/solvers.jsonc", + "data/warp.jsonc", "data/zden.jsonc", ]; @@ -1173,6 +1203,7 @@ fn main() { println!("cargo:rerun-if-changed=data/b1000.jsonc"); println!("cargo:rerun-if-changed=data/hash_collision.jsonc"); println!("cargo:rerun-if-changed=data/gsmg.jsonc"); + println!("cargo:rerun-if-changed=data/warp.jsonc"); println!("cargo:rerun-if-changed=data/zden.jsonc"); println!("cargo:rerun-if-changed=data/bitaps.jsonc"); println!("cargo:rerun-if-changed=data/bitimage.jsonc"); @@ -1194,6 +1225,7 @@ fn main() { generate_bitaps(&out_dir, &solvers); generate_bitimage(&out_dir, &solvers); generate_ballet(&out_dir, &solvers); + generate_warp(&out_dir, &solvers); } fn generate_b1000(out_dir: &str, solvers: &HashMap) { @@ -1244,6 +1276,7 @@ fn generate_b1000(out_dir: &str, solvers: &HashMap) { "solved" => "Status::Solved", "claimed" => "Status::Claimed", "swept" => "Status::Swept", + "expired" => "Status::Expired", _ => "Status::Unsolved", }; @@ -1367,6 +1400,7 @@ fn generate_hash_collision(out_dir: &str, solvers: &HashMap "Status::Solved", "claimed" => "Status::Claimed", "swept" => "Status::Swept", + "expired" => "Status::Expired", _ => "Status::Unsolved", }; @@ -1631,6 +1665,7 @@ fn generate_zden(out_dir: &str, solvers: &HashMap) { "solved" => "Status::Solved", "claimed" => "Status::Claimed", "swept" => "Status::Swept", + "expired" => "Status::Expired", _ => "Status::Unsolved", }; @@ -1884,6 +1919,7 @@ fn generate_bitimage(out_dir: &str, solvers: &HashMap) "solved" => "Status::Solved", "claimed" => "Status::Claimed", "swept" => "Status::Swept", + "expired" => "Status::Expired", _ => "Status::Unsolved", }; @@ -2027,6 +2063,7 @@ fn generate_ballet(out_dir: &str, solvers: &HashMap) { "solved" => "Status::Solved", "claimed" => "Status::Claimed", "swept" => "Status::Swept", + "expired" => "Status::Expired", _ => "Status::Unsolved", }; @@ -2165,6 +2202,7 @@ fn generate_arweave(out_dir: &str, solvers: &HashMap) "solved" => "Status::Solved", "claimed" => "Status::Claimed", "swept" => "Status::Swept", + "expired" => "Status::Expired", _ => "Status::Unsolved", }; @@ -2276,3 +2314,130 @@ fn generate_arweave(out_dir: &str, solvers: &HashMap) fs::write(&dest_path, output).expect("Failed to write arweave_data.rs"); } + +fn generate_warp(out_dir: &str, solvers: &HashMap) { + let dest_path = Path::new(out_dir).join("warp_data.rs"); + + let mut content = + fs::read_to_string("data/warp.jsonc").expect("Failed to read data/warp.jsonc"); + strip(&mut content).expect("Failed to strip comments from warp.jsonc"); + let wrapped: WithSchema = + serde_json::from_str(&content).expect("Failed to parse warp.jsonc"); + let data = wrapped.inner; + + let default_source_url = data.metadata.as_ref().and_then(|m| m.source_url.as_ref()); + + let mut output = String::new(); + output.push_str(&generate_author_code(&data.author)); + output.push('\n'); + output.push_str("static PUZZLES: &[Puzzle] = &[\n"); + + for puzzle in &data.puzzles { + let puzzle_id = format!("warp/{}", puzzle.name); + + validate_pubkey_for_claimed_puzzles( + &puzzle_id, + &puzzle.status, + &puzzle.transactions, + &puzzle.address.kind, + &puzzle.pubkey, + ); + + let status = match puzzle.status.as_str() { + "solved" => "Status::Solved", + "claimed" => "Status::Claimed", + "swept" => "Status::Swept", + "expired" => "Status::Expired", + _ => "Status::Unsolved", + }; + + let prize = match puzzle.prize { + Some(p) => format!("Some({:.6})", p), + None => "None".to_string(), + }; + + let currency = puzzle + .currency + .as_ref() + .map(|c| format!("Some(\"{}\")", c)) + .unwrap_or_else(|| "None".to_string()); + + let start_date = match &puzzle.start_date { + Some(d) => format!("Some(\"{}\")", d), + None => "None".to_string(), + }; + + let solve_date = match &puzzle.solve_date { + Some(d) => format!("Some(\"{}\")", d), + None => "None".to_string(), + }; + + let solve_time = match puzzle.solve_time { + Some(t) => format!("Some({})", t), + None => "None".to_string(), + }; + + let source_url = puzzle + .source_url + .as_ref() + .or(default_source_url) + .map(|url| format!("Some(\"{}\")", url)) + .unwrap_or_else(|| "None".to_string()); + + let hash160 = format_hash160(&puzzle.address, "bitcoin", &puzzle_id); + let witness_program = format_witness_program(&puzzle.address, &puzzle_id); + let redeem_script = generate_redeem_script_code(&puzzle.address.redeem_script); + let pubkey = format_pubkey(&puzzle.pubkey, &puzzle_id); + let transactions = generate_transactions_code(&puzzle.transactions); + let solver = generate_solver_code(&puzzle.solver, solvers); + + output.push_str(&format!( + r#" Puzzle {{ + id: "warp/{}", + chain: Chain::Bitcoin, + address: Address {{ + value: "{}", + chain: Chain::Bitcoin, + kind: "{}", + hash160: {}, + witness_program: {}, + redeem_script: {}, + }}, + status: {}, + pubkey: {}, + key: None, + prize: {}, + currency: {}, + start_date: {}, + solve_date: {}, + solve_time: {}, + pre_genesis: false, + source_url: {}, + transactions: {}, + solver: {}, + assets: None, + }}, +"#, + puzzle.name, + puzzle.address.value, + puzzle.address.kind, + hash160, + witness_program, + redeem_script, + status, + pubkey, + prize, + currency, + start_date, + solve_date, + solve_time, + source_url, + transactions, + solver, + )); + } + + output.push_str("];\n"); + + fs::write(&dest_path, output).expect("Failed to write warp_data.rs"); +} diff --git a/data/warp.jsonc b/data/warp.jsonc new file mode 100644 index 0000000..9b0181b --- /dev/null +++ b/data/warp.jsonc @@ -0,0 +1,199 @@ +{ + "$schema": "./schemas/collection.schema.json", + "author": { + "name": "Keybase", + "profiles": [ + { + "name": "github", + "url": "https://github.com/keybase/warpwallet" + }, + { + "name": "twitter", + "url": "https://twitter.com/maxtaco" + }, + { + "name": "twitter", + "url": "https://twitter.com/malgorithms" + } + ] + }, + "metadata": { + "source_url": "https://keybase.io/warp" + }, + "puzzles": [ + { + // Challenge 1: 2 random alphanumeric characters, unsalted + "name": "challenge_1", + "address": { + "value": "1JKb1617p68H5MPkoNaMtaJCqKDU3h8qSn", + "kind": "p2pkh", + "hash160": "bdfe0236c752bbf8610b47c57b8c8592230dc575" + }, + "pubkey": { "value": "045f751d820a69524eb71d48ddc6a231ba019b1461f58b0266cbbec617f9e80c6e573582b37014ce7ba7eaf9031265c5f022d8cb286f2194344f207eaa44bb51af", "format": "uncompressed" }, + "status": "solved", + "prize": 0.1, + "start_date": "2013-11-19 20:12:25", + "solve_date": "2013-11-20 03:52:13", + "solve_time": 27588, + "transactions": [ + { + "type": "funding", + "txid": "66e4f6fdf399ce95804347fc4bdbd9e35de7d4044e2cfcb3b51d44db9522f00b", + "date": "2013-11-19 20:12:25", + "amount": 0.1 + }, + { + "type": "claim", + "txid": "1384234046c03e8dfcb868aa68b82df29078dcdda01e243fe006e4e0793b8cd8", + "date": "2013-11-20 03:52:13", + "amount": 0.1 + } + ] + }, + { + // Challenge 2: 3 random alphanumeric characters, unsalted + "name": "challenge_2", + "address": { + "value": "1NMjXhB2DW8pbGvd64o9DiqwCF8BTAkJKu", + "kind": "p2pkh", + "hash160": "ea4676574baeba82db43e26421f4113bc389d513" + }, + "pubkey": { "value": "040ccffff6759ce8bbf62061fa5f57758fc8cca98719de8dfc43da1103ee9b25632323b540ab4692d17db0839865b19ae194b2a553e28442035483925b8c710c1a", "format": "uncompressed" }, + "status": "solved", + "prize": 0.25, + "start_date": "2013-11-19 20:12:25", + "solve_date": "2013-11-20 04:40:30", + "solve_time": 30485, + "transactions": [ + { + "type": "funding", + "txid": "e2a23dc3a745a853caf6be317b75912b6f85a618a25602de67708e12894babdf", + "date": "2013-11-19 20:12:25", + "amount": 0.25 + }, + { + "type": "claim", + "txid": "60c1b66664cb4ba5135acaa34e38bb1cff077355d9aefe2ab5e7e2af8b7df328", + "date": "2013-11-20 04:40:30", + "amount": 0.25 + } + ] + }, + { + // Challenge 3: two Bitcoin subreddit usernames separated by a space, unsalted + "name": "challenge_3", + "address": { + "value": "1FpxSs3tsvV8knTgRv2885bE1GyPq1QrvH", + "kind": "p2pkh", + "hash160": "a2a3985813e2b921ba5bcbf838f2160e5ded078b" + }, + "pubkey": { "value": "04cdf27fe2598b7df25c002f268a7984642b206a54a077e04d8ff8032b0aa2e8b2a8dae0a9f2011f214d9eb57f0c7977fa378bd5ddcb21c90f5311549b5418b199", "format": "uncompressed" }, + "status": "solved", + "prize": 0.5, + "start_date": "2013-11-19 20:12:25", + "solve_date": "2013-11-27 15:04:25", + "solve_time": 672720, + "transactions": [ + { + "type": "funding", + "txid": "35f2a2e601277156a9cfc0e02f92d184918714b1bea3a75bcc82d9f6bea848ea", + "date": "2013-11-19 20:12:25", + "amount": 0.5 + }, + { + "type": "claim", + "txid": "de0253a758588a5079516299b633d55372ed5ae171ca4688a2f6f0c471ff93ad", + "date": "2013-11-27 15:04:25", + "amount": 0.5 + } + ] + }, + { + // Challenge 4: HN top 100 karma username with 2 chars dropped, unsalted + "name": "challenge_4", + "address": { + "value": "1GXXH7FbY7nCJDRc72SMyYykgtEUi5GxfR", + "kind": "p2pkh", + "hash160": "aa4fa3e2be1fbf59edd4bc3702a08f715251e823" + }, + "pubkey": { "value": "048bf325a507144fafb185fedaeb0e8440eb062e40fe30fad92d88fee42bc9ba199eab8ce1b044f55bd8fc9c71fcef4f291ef8e41add26d63de4be09238d6ef007", "format": "uncompressed" }, + "status": "solved", + "prize": 1.0, + "start_date": "2013-11-19 20:12:25", + "solve_date": "2013-11-20 01:23:39", + "solve_time": 18674, + "transactions": [ + { + "type": "funding", + "txid": "f17e84dce6c193690b07e8ea220e90370246d8deb846707dc11d032c688b43fd", + "date": "2013-11-19 20:12:25", + "amount": 1.0 + }, + { + "type": "claim", + "txid": "921bf858098acb6a8ad4d4184fc7654e68cc2e2f4f6ebe7c59cec4e8540e1b12", + "date": "2013-11-20 01:23:39", + "amount": 1.0 + } + ] + }, + { + // The WarpWallet Challenge 1: 8 random alphanumeric characters, unsalted + // Answer: 'PuACRv0R' — expired Feb 1, 2016, reclaimed by Keybase + "name": "warp_challenge_1", + "address": { + "value": "1AdU3EcimMFN7JLJtceSyrmFYE3gF5ZnGj", + "kind": "p2pkh", + "hash160": "699ead63fb2da9733786d47e4cd62609a33d7bb6" + }, + "pubkey": { "value": "041c133e9f41e13c3c31b78fca62355cfcb9f38493d0d9b0449a93401e791de1e0a0e0dc4a37800b64005b4fa85c620e3207fda131f75ce7b7662f74be86a455b0", "format": "uncompressed" }, + "status": "expired", + "prize": 20.0, + "start_date": "2013-11-19 20:12:25", + "solve_date": "2016-01-31 01:19:41", + "transactions": [ + { + "type": "funding", + "txid": "d99ef316fb267491a5f34f8ce35c5083df1a643a850f6b23470263d6e07a2b6b", + "date": "2013-11-19 20:12:25", + "amount": 10.0 + }, + { + "type": "claim", + "txid": "a96d09a4de56f144e95dd2184edd4e53a6beaec9887d14eb63a25aa8e1e456c9", + "date": "2016-01-31 01:19:41", + "amount": 10.001116 + } + ] + }, + { + // The WarpWallet Challenge 2: 8 alphanumeric characters, salted with 'a@b.c' + // Expired Jan 1, 2018, reclaimed by Keybase + "name": "warp_challenge_2", + "address": { + "value": "1MkupVKiCik9iyfnLrJoZLx9RH4rkF3hnA", + "kind": "p2pkh", + "hash160": "e3b07e2fc4ea14b903c11aee122f7fec19e4a621" + }, + "pubkey": { "value": "0403a696da2c6243816c8a7c1d98756c8d56e74ac54a79d35ed7c3a9a9eb84d9be11ac016eb926fda6cb58322ac4b6a505fd52c258d70d5e49eac99b1e96a696c1", "format": "uncompressed" }, + "status": "expired", + "prize": 20.0, + "start_date": "2016-02-01 15:19:33", + "solve_date": "2018-01-03 03:14:39", + "transactions": [ + { + "type": "funding", + "txid": "e12fee70d2b0b308129b9b12d6f95be25617fc4f98c56900034ce466b3113074", + "date": "2016-02-01 15:19:33", + "amount": 10.0 + }, + { + "type": "claim", + "txid": "25d23cf88188258001830a7b7c45fd7c9f43be16ce8f75088810360c55ee9f17", + "date": "2018-01-03 03:14:39", + "amount": 10.0005164 + } + ] + } + ] +} diff --git a/src/cli.rs b/src/cli.rs index 331109d..85d49c9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -218,6 +218,7 @@ impl PuzzleTableRow { Status::Unsolved => "unsolved".yellow().to_string(), Status::Claimed => "claimed".cyan().to_string(), Status::Swept => "swept".red().to_string(), + Status::Expired => "expired".dimmed().to_string(), }; let prize = p.prize.map_or_else( || "-".dimmed().to_string(), @@ -485,6 +486,7 @@ fn print_puzzle_detail_table(p: &Puzzle, show_transactions: bool) { Status::Unsolved => "Unsolved".yellow().to_string(), Status::Claimed => "Claimed".cyan().to_string(), Status::Swept => "Swept".red().to_string(), + Status::Expired => "Expired".dimmed().to_string(), }; let mut rows = vec![ @@ -1068,6 +1070,7 @@ fn output_search_results(results: &[SearchResult], format: OutputFormat, query: Status::Unsolved => "unsolved".yellow().to_string(), Status::Claimed => "claimed".cyan().to_string(), Status::Swept => "swept".red().to_string(), + Status::Expired => "expired".dimmed().to_string(), }; SearchTableRow { @@ -1121,6 +1124,7 @@ fn output_search_results(results: &[SearchResult], format: OutputFormat, query: Status::Unsolved => "unsolved", Status::Claimed => "claimed", Status::Swept => "swept", + Status::Expired => "expired", }; wtr.serialize(SearchCsvRow { @@ -1801,6 +1805,7 @@ fn cmd_export( Status::Unsolved => stats.unsolved += 1, Status::Claimed => stats.claimed += 1, Status::Swept => stats.swept += 1, + Status::Expired => stats.expired += 1, } if puzzle.has_pubkey() { stats.with_pubkey += 1; @@ -1838,6 +1843,7 @@ fn collection_help(include_all: bool) -> String { .iter() .map(|collection| match collection { Collection::HashCollision => "hash_collision (peter_todd)", + Collection::Warp => "warp (warpwallet)", _ => collection.name(), }) .collect(); @@ -1904,11 +1910,11 @@ mod tests { fn collection_help_lists_registry_names() { assert_eq!( collection_help(false), - "arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden" + "arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), warp (warpwallet), zden" ); assert_eq!( collection_help(true), - "arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden, all" + "arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), warp (warpwallet), zden, all" ); } } diff --git a/src/collections/mod.rs b/src/collections/mod.rs index 13001b7..761308f 100644 --- a/src/collections/mod.rs +++ b/src/collections/mod.rs @@ -5,4 +5,5 @@ pub mod bitaps; pub mod bitimage; pub mod gsmg; pub mod hash_collision; +pub mod warp; pub mod zden; diff --git a/src/collections/warp.rs b/src/collections/warp.rs new file mode 100644 index 0000000..367c119 --- /dev/null +++ b/src/collections/warp.rs @@ -0,0 +1,49 @@ +//! Keybase WarpWallet challenges — deterministic brainwallet security tests. + +#[allow(unused_imports)] +use crate::{ + Address, Author, Chain, Entropy, EntropySource, Error, Key, Passphrase, Profile, Pubkey, + PubkeyFormat, Puzzle, RedeemScript, Result, Seed, Solver, Status, Transaction, TransactionType, + Wif, +}; + +include!(concat!(env!("OUT_DIR"), "/warp_data.rs")); + +pub fn author() -> &'static Author { + &AUTHOR +} + +pub fn get(name: &str) -> Result<&'static Puzzle> { + let search_id = if name.contains('/') { + name.to_string() + } else { + format!("warp/{}", name) + }; + + PUZZLES + .iter() + .find(|p| p.id == search_id) + .ok_or(Error::NotFound(search_id)) +} + +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + +pub fn all() -> impl Iterator { + slice().iter() +} + +pub fn solved() -> impl Iterator { + PUZZLES + .iter() + .filter(|p| matches!(p.status, Status::Solved | Status::Claimed)) +} + +pub fn unsolved() -> impl Iterator { + PUZZLES.iter().filter(|p| p.status == Status::Unsolved) +} + +pub const fn count() -> usize { + PUZZLES.len() +} diff --git a/src/lib.rs b/src/lib.rs index 43faf26..7cd27a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ pub mod version { include!(concat!(env!("OUT_DIR"), "/data_version.rs")); } -pub use collections::{arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision, zden}; +pub use collections::{arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision, warp, zden}; pub use puzzle::{ Address, Assets, Author, Chain, Entropy, EntropySource, IntoPuzzleNum, Key, Passphrase, Profile, Pubkey, PubkeyFormat, Puzzle, RedeemScript, Seed, Share, Shares, Solver, Status, @@ -42,11 +42,12 @@ pub enum Collection { Bitimage, Gsmg, HashCollision, + Warp, Zden, } impl Collection { - pub const ALL: [Self; 8] = [ + pub const ALL: [Self; 9] = [ Self::Arweave, Self::B1000, Self::Ballet, @@ -54,6 +55,7 @@ impl Collection { Self::Bitimage, Self::Gsmg, Self::HashCollision, + Self::Warp, Self::Zden, ]; @@ -66,6 +68,7 @@ impl Collection { Self::Bitimage => "bitimage", Self::Gsmg => "gsmg", Self::HashCollision => "hash_collision", + Self::Warp => "warp", Self::Zden => "zden", } } @@ -79,6 +82,7 @@ impl Collection { "bitimage" => Ok(Self::Bitimage), "gsmg" => Ok(Self::Gsmg), "hash_collision" | "peter_todd" => Ok(Self::HashCollision), + "warp" | "warpwallet" => Ok(Self::Warp), "zden" => Ok(Self::Zden), _ => Err(Error::InvalidCollection(name.to_string())), } @@ -93,6 +97,7 @@ impl Collection { Self::Bitimage => bitimage::slice(), Self::Gsmg => gsmg::slice(), Self::HashCollision => hash_collision::slice(), + Self::Warp => warp::slice(), Self::Zden => zden::slice(), } } @@ -110,6 +115,7 @@ impl Collection { Self::Bitimage => bitimage::author(), Self::Gsmg => gsmg::author(), Self::HashCollision => hash_collision::author(), + Self::Warp => warp::author(), Self::Zden => zden::author(), } } @@ -140,6 +146,7 @@ impl Collection { } } Self::HashCollision => hash_collision::get(name), + Self::Warp => warp::get(name), Self::Zden => zden::get(name), } } @@ -178,6 +185,7 @@ pub struct Stats { pub unsolved: usize, pub claimed: usize, pub swept: usize, + pub expired: usize, pub with_pubkey: usize, pub total_prize: HashMap, pub unsolved_prize: HashMap, @@ -193,6 +201,7 @@ pub fn stats() -> Stats { Status::Unsolved => stats.unsolved += 1, Status::Claimed => stats.claimed += 1, Status::Swept => stats.swept += 1, + Status::Expired => stats.expired += 1, } if puzzle.has_pubkey() { stats.with_pubkey += 1; diff --git a/src/puzzle.rs b/src/puzzle.rs index bfe4951..3dd6993 100644 --- a/src/puzzle.rs +++ b/src/puzzle.rs @@ -135,6 +135,7 @@ pub enum Status { Unsolved, Claimed, Swept, + Expired, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] @@ -169,6 +170,7 @@ impl fmt::Display for Status { Status::Unsolved => "unsolved", Status::Claimed => "claimed", Status::Swept => "swept", + Status::Expired => "expired", }) } } @@ -182,8 +184,9 @@ impl FromStr for Status { "unsolved" => Ok(Status::Unsolved), "claimed" => Ok(Status::Claimed), "swept" => Ok(Status::Swept), + "expired" => Ok(Status::Expired), _ => Err(format!( - "unknown status: '{}'. expected: solved, unsolved, claimed, swept", + "unknown status: '{}'. expected: solved, unsolved, claimed, swept, expired", s )), } diff --git a/tests/validation.rs b/tests/validation.rs index cb28e8e..2d85547 100644 --- a/tests/validation.rs +++ b/tests/validation.rs @@ -677,10 +677,13 @@ fn solved_puzzles_with_dates_have_solve_time() { #[test] fn unsolved_puzzles_no_solve_time() { for puzzle in boha::all() { - if matches!(puzzle.status, Status::Unsolved | Status::Swept) { + if matches!( + puzzle.status, + Status::Unsolved | Status::Swept | Status::Expired + ) { assert!( puzzle.solve_time.is_none(), - "Unsolved/swept puzzle {} should not have solve_time", + "Unsolved/swept/expired puzzle {} should not have solve_time", puzzle.id ); } @@ -911,6 +914,23 @@ fn claimed_puzzles_have_claim_transaction() { } } +#[test] +fn expired_puzzles_have_claim_transaction() { + for puzzle in boha::all() { + if puzzle.status == Status::Expired { + let has_claim = puzzle + .transactions + .iter() + .any(|t| t.tx_type == TransactionType::Claim); + assert!( + has_claim, + "Expired puzzle {} missing claim transaction", + puzzle.id + ); + } + } +} + #[test] fn swept_puzzles_have_sweep_transaction() { for puzzle in boha::all() { @@ -988,7 +1008,7 @@ fn solve_date_matches_claim_transaction() { } if !matches!( puzzle.status, - Status::Solved | Status::Claimed | Status::Swept + Status::Solved | Status::Claimed | Status::Swept | Status::Expired ) { continue; } @@ -1011,10 +1031,13 @@ fn solve_date_matches_claim_transaction() { #[test] fn unsolved_puzzles_no_solver() { for puzzle in boha::all() { - if matches!(puzzle.status, Status::Unsolved | Status::Swept) { + if matches!( + puzzle.status, + Status::Unsolved | Status::Swept | Status::Expired + ) { assert!( puzzle.solver.is_none(), - "Unsolved/swept puzzle {} should not have solver", + "Unsolved/swept/expired puzzle {} should not have solver", puzzle.id ); } From 650f64fa9bfc712bc7db39c0f9d96ed0b46fb4d5 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:51:44 +0200 Subject: [PATCH 2/2] docs(agents): update structure section for warp collection --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cff9b03..ba5ab93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ boha/ │ ├── puzzle.rs # Puzzle, Address, Key, Status, Chain, Profile structs │ ├── balance.rs # Multi-chain async balance fetch (BTC/LTC/ETH) │ ├── verify.rs # Cryptographic key→address verification (--features cli) -│ └── collections/ # Eight collection modules with generated data +│ └── collections/ # Nine collection modules with generated data ├── data/ -│ ├── *.jsonc # Source of truth (arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision, zden) +│ ├── *.jsonc # Source of truth (arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision, warp, zden) │ ├── solvers.jsonc # Solver definitions (referenced by ID in puzzle files) │ ├── schemas/ # JSON Schema files for validation │ └── cache/ # API response cache for scripts