1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use base64::Engine as _;
6use sha2::{Digest as _, Sha256};
7
8use crate::ids::{AgentPubkey, NetworkId};
9
10pub const POW_PREFIX: &[u8] = b"parley-pow:";
12
13pub const POW_VERSION: u8 = 1;
15
16pub const DEFAULT_MAX_NONCE_BYTES: usize = 64;
19
20#[must_use]
27pub fn challenge_bytes(
28 version: u8,
29 network: &NetworkId,
30 pubkey: &AgentPubkey,
31 difficulty: u8,
32) -> Vec<u8> {
33 let n = network.as_str().as_bytes();
34 let n_len = u8::try_from(n.len()).unwrap_or(u8::MAX);
35 let mut out = Vec::with_capacity(POW_PREFIX.len() + 1 + 1 + n.len() + 32 + 1);
36 out.extend_from_slice(POW_PREFIX);
37 out.push(version);
38 out.push(n_len);
39 out.extend_from_slice(n);
40 out.extend_from_slice(pubkey.as_bytes());
41 out.push(difficulty);
42 out
43}
44
45#[must_use]
47pub fn leading_zero_bits_of_hash(challenge: &[u8], nonce: &[u8]) -> u32 {
48 let mut h = Sha256::new();
49 h.update(challenge);
50 h.update(nonce);
51 let digest = h.finalize();
52 let mut bits = 0u32;
53 for byte in digest.iter() {
54 if *byte == 0 {
55 bits += 8;
56 } else {
57 bits += byte.leading_zeros();
58 break;
59 }
60 }
61 bits
62}
63
64#[derive(Debug, thiserror::Error)]
65pub enum PowVerifyError {
66 #[error("nonce too long: {actual} > {max}")]
67 NonceTooLong { actual: usize, max: usize },
68 #[error("difficulty mismatch: submitted {submitted}, server {server}")]
69 DifficultyMismatch { submitted: u8, server: u8 },
70 #[error("version unsupported: {0}")]
71 VersionUnsupported(u8),
72 #[error("hash does not satisfy difficulty: got {got} leading zero bits, need {need}")]
73 Insufficient { got: u32, need: u32 },
74}
75
76#[allow(clippy::too_many_arguments)]
81pub fn verify(
82 server_version: u8,
83 server_difficulty: u8,
84 max_nonce_bytes: usize,
85 submitted_version: u8,
86 submitted_difficulty: u8,
87 network: &NetworkId,
88 pubkey: &AgentPubkey,
89 nonce: &[u8],
90) -> Result<(), PowVerifyError> {
91 if submitted_version != server_version {
92 return Err(PowVerifyError::VersionUnsupported(submitted_version));
93 }
94 if submitted_difficulty != server_difficulty {
95 return Err(PowVerifyError::DifficultyMismatch {
96 submitted: submitted_difficulty,
97 server: server_difficulty,
98 });
99 }
100 if nonce.len() > max_nonce_bytes {
101 return Err(PowVerifyError::NonceTooLong {
102 actual: nonce.len(),
103 max: max_nonce_bytes,
104 });
105 }
106 let challenge = challenge_bytes(server_version, network, pubkey, server_difficulty);
107 let bits = leading_zero_bits_of_hash(&challenge, nonce);
108 let need = u32::from(server_difficulty);
109 if bits < need {
110 return Err(PowVerifyError::Insufficient { got: bits, need });
111 }
112 Ok(())
113}
114
115pub fn solve(
123 version: u8,
124 network: &NetworkId,
125 pubkey: &AgentPubkey,
126 difficulty: u8,
127 mut progress: impl FnMut(u64),
128) -> Vec<u8> {
129 let challenge = challenge_bytes(version, network, pubkey, difficulty);
130 let need = u32::from(difficulty);
131 let mut nonce: u64 = 0;
132 loop {
133 let nonce_bytes = nonce.to_be_bytes();
134 let bits = leading_zero_bits_of_hash(&challenge, &nonce_bytes);
135 if bits >= need {
136 return nonce_bytes.to_vec();
137 }
138 nonce = nonce.wrapping_add(1);
139 if nonce.is_multiple_of(50_000) {
140 progress(nonce);
141 }
142 }
143}
144
145#[must_use]
147pub fn encode_nonce(nonce: &[u8]) -> String {
148 URL_SAFE_NO_PAD.encode(nonce)
149}
150
151pub fn decode_nonce(nonce_b64: &str) -> Result<Vec<u8>, base64::DecodeError> {
153 URL_SAFE_NO_PAD.decode(nonce_b64)
154}
155
156#[cfg(test)]
157#[allow(clippy::unwrap_used, clippy::expect_used)]
158mod tests {
159 use super::*;
160
161 fn pk() -> AgentPubkey {
162 AgentPubkey::from_bytes([7u8; 32])
163 }
164
165 fn net() -> NetworkId {
166 NetworkId::new("parley-test").unwrap()
167 }
168
169 #[test]
170 fn challenge_layout_is_deterministic() {
171 let a = challenge_bytes(1, &net(), &pk(), 8);
172 let b = challenge_bytes(1, &net(), &pk(), 8);
173 assert_eq!(a, b);
174 assert!(a.starts_with(POW_PREFIX));
175 assert_eq!(a[POW_PREFIX.len()], 1);
176 assert_eq!(a[POW_PREFIX.len() + 1] as usize, b"parley-test".len());
177 assert_eq!(*a.last().unwrap(), 8);
179 }
180
181 #[test]
182 fn solve_then_verify_roundtrip_low_difficulty() {
183 let nonce = solve(1, &net(), &pk(), 8, |_| {});
184 verify(1, 8, 64, 1, 8, &net(), &pk(), &nonce).expect("should verify");
185 }
186
187 #[test]
188 fn verify_rejects_wrong_version() {
189 let nonce = solve(1, &net(), &pk(), 8, |_| {});
190 let err = verify(1, 8, 64, 2, 8, &net(), &pk(), &nonce).unwrap_err();
191 assert!(matches!(err, PowVerifyError::VersionUnsupported(2)));
192 }
193
194 #[test]
195 fn verify_rejects_difficulty_mismatch() {
196 let nonce = solve(1, &net(), &pk(), 8, |_| {});
197 let err = verify(1, 12, 64, 1, 8, &net(), &pk(), &nonce).unwrap_err();
198 assert!(matches!(err, PowVerifyError::DifficultyMismatch { .. }));
199 }
200
201 #[test]
202 fn verify_rejects_overlong_nonce() {
203 let big_nonce = vec![0u8; 100];
204 let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &big_nonce).unwrap_err();
205 assert!(matches!(err, PowVerifyError::NonceTooLong { .. }));
206 }
207
208 #[test]
209 fn verify_rejects_garbage_nonce() {
210 let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &[0u8; 8]).unwrap_err();
211 assert!(matches!(err, PowVerifyError::Insufficient { .. }));
212 }
213
214 #[test]
215 fn nonce_round_trips_through_b64() {
216 let n = vec![0xab, 0xcd, 0xef];
217 let s = encode_nonce(&n);
218 let back = decode_nonce(&s).unwrap();
219 assert_eq!(back, n);
220 }
221}