use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest as _, Sha256};
use crate::ids::{AgentPubkey, NetworkId};
pub const POW_PREFIX: &[u8] = b"parley-pow:";
pub const POW_VERSION: u8 = 1;
pub const DEFAULT_MAX_NONCE_BYTES: usize = 64;
#[must_use]
pub fn challenge_bytes(
version: u8,
network: &NetworkId,
pubkey: &AgentPubkey,
difficulty: u8,
) -> Vec<u8> {
let n = network.as_str().as_bytes();
let n_len = u8::try_from(n.len()).unwrap_or(u8::MAX);
let mut out = Vec::with_capacity(POW_PREFIX.len() + 1 + 1 + n.len() + 32 + 1);
out.extend_from_slice(POW_PREFIX);
out.push(version);
out.push(n_len);
out.extend_from_slice(n);
out.extend_from_slice(pubkey.as_bytes());
out.push(difficulty);
out
}
#[must_use]
pub fn leading_zero_bits_of_hash(challenge: &[u8], nonce: &[u8]) -> u32 {
let mut h = Sha256::new();
h.update(challenge);
h.update(nonce);
let digest = h.finalize();
let mut bits = 0u32;
for byte in digest.iter() {
if *byte == 0 {
bits += 8;
} else {
bits += byte.leading_zeros();
break;
}
}
bits
}
#[derive(Debug, thiserror::Error)]
pub enum PowVerifyError {
#[error("nonce too long: {actual} > {max}")]
NonceTooLong { actual: usize, max: usize },
#[error("difficulty mismatch: submitted {submitted}, server {server}")]
DifficultyMismatch { submitted: u8, server: u8 },
#[error("version unsupported: {0}")]
VersionUnsupported(u8),
#[error("hash does not satisfy difficulty: got {got} leading zero bits, need {need}")]
Insufficient { got: u32, need: u32 },
}
#[allow(clippy::too_many_arguments)]
pub fn verify(
server_version: u8,
server_difficulty: u8,
max_nonce_bytes: usize,
submitted_version: u8,
submitted_difficulty: u8,
network: &NetworkId,
pubkey: &AgentPubkey,
nonce: &[u8],
) -> Result<(), PowVerifyError> {
if submitted_version != server_version {
return Err(PowVerifyError::VersionUnsupported(submitted_version));
}
if submitted_difficulty != server_difficulty {
return Err(PowVerifyError::DifficultyMismatch {
submitted: submitted_difficulty,
server: server_difficulty,
});
}
if nonce.len() > max_nonce_bytes {
return Err(PowVerifyError::NonceTooLong {
actual: nonce.len(),
max: max_nonce_bytes,
});
}
let challenge = challenge_bytes(server_version, network, pubkey, server_difficulty);
let bits = leading_zero_bits_of_hash(&challenge, nonce);
let need = u32::from(server_difficulty);
if bits < need {
return Err(PowVerifyError::Insufficient { got: bits, need });
}
Ok(())
}
pub fn solve(
version: u8,
network: &NetworkId,
pubkey: &AgentPubkey,
difficulty: u8,
mut progress: impl FnMut(u64),
) -> Vec<u8> {
let challenge = challenge_bytes(version, network, pubkey, difficulty);
let need = u32::from(difficulty);
let mut nonce: u64 = 0;
loop {
let nonce_bytes = nonce.to_be_bytes();
let bits = leading_zero_bits_of_hash(&challenge, &nonce_bytes);
if bits >= need {
return nonce_bytes.to_vec();
}
nonce = nonce.wrapping_add(1);
if nonce.is_multiple_of(50_000) {
progress(nonce);
}
}
}
#[must_use]
pub fn encode_nonce(nonce: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(nonce)
}
pub fn decode_nonce(nonce_b64: &str) -> Result<Vec<u8>, base64::DecodeError> {
URL_SAFE_NO_PAD.decode(nonce_b64)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn pk() -> AgentPubkey {
AgentPubkey::from_bytes([7u8; 32])
}
fn net() -> NetworkId {
NetworkId::new("parley-test").unwrap()
}
#[test]
fn challenge_layout_is_deterministic() {
let a = challenge_bytes(1, &net(), &pk(), 8);
let b = challenge_bytes(1, &net(), &pk(), 8);
assert_eq!(a, b);
assert!(a.starts_with(POW_PREFIX));
assert_eq!(a[POW_PREFIX.len()], 1);
assert_eq!(a[POW_PREFIX.len() + 1] as usize, b"parley-test".len());
assert_eq!(*a.last().unwrap(), 8);
}
#[test]
fn solve_then_verify_roundtrip_low_difficulty() {
let nonce = solve(1, &net(), &pk(), 8, |_| {});
verify(1, 8, 64, 1, 8, &net(), &pk(), &nonce).expect("should verify");
}
#[test]
fn verify_rejects_wrong_version() {
let nonce = solve(1, &net(), &pk(), 8, |_| {});
let err = verify(1, 8, 64, 2, 8, &net(), &pk(), &nonce).unwrap_err();
assert!(matches!(err, PowVerifyError::VersionUnsupported(2)));
}
#[test]
fn verify_rejects_difficulty_mismatch() {
let nonce = solve(1, &net(), &pk(), 8, |_| {});
let err = verify(1, 12, 64, 1, 8, &net(), &pk(), &nonce).unwrap_err();
assert!(matches!(err, PowVerifyError::DifficultyMismatch { .. }));
}
#[test]
fn verify_rejects_overlong_nonce() {
let big_nonce = vec![0u8; 100];
let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &big_nonce).unwrap_err();
assert!(matches!(err, PowVerifyError::NonceTooLong { .. }));
}
#[test]
fn verify_rejects_garbage_nonce() {
let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &[0u8; 8]).unwrap_err();
assert!(matches!(err, PowVerifyError::Insufficient { .. }));
}
#[test]
fn nonce_round_trips_through_b64() {
let n = vec![0xab, 0xcd, 0xef];
let s = encode_nonce(&n);
let back = decode_nonce(&s).unwrap();
assert_eq!(back, n);
}
}