1use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct StakeProof {
32 pub utxo_outpoint: String,
34
35 pub locktime_unix: u64,
38
39 pub sats: u64,
41
42 pub provider_pubkey_hex: String,
47
48 pub signature_hex: String,
50
51 pub version: u8,
54}
55
56pub fn canonical_signing_message(
71 provider_npub: &str,
72 utxo_outpoint: &str,
73 locktime_unix: u64,
74 sats: u64,
75) -> [u8; 32] {
76 let mut hasher = Sha256::new();
77 hasher.update(b"paygress-stake-v1");
78 hasher.update([0u8]);
79 hasher.update(provider_npub.as_bytes());
80 hasher.update([0u8]);
81 hasher.update(utxo_outpoint.as_bytes());
82 hasher.update([0u8]);
83 hasher.update(locktime_unix.to_le_bytes());
84 hasher.update([0u8]);
85 hasher.update(sats.to_le_bytes());
86 hasher.finalize().into()
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Utxo {
92 pub outpoint: String,
93 pub script_pubkey_hex: String,
94 pub sats: u64,
95 pub spent: bool,
97}
98
99#[async_trait::async_trait]
104pub trait BlockSource: Send + Sync {
105 async fn fetch_utxo(&self, outpoint: &str) -> Result<Option<Utxo>, StakeError>;
106 async fn current_unix_time(&self) -> Result<u64, StakeError>;
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum StakeStatus {
112 Valid {
115 effective_sats: u64,
116 locktime_unix: u64,
117 },
118 Spent,
120 Unlocked,
123 BadSignature,
125 PubkeyMismatch,
128 Unverified(String),
131}
132
133#[derive(Debug, thiserror::Error)]
139pub enum StakeError {
140 #[error("malformed UTXO outpoint: {0}")]
141 InvalidOutpoint(String),
142 #[error("malformed pubkey: {0}")]
143 InvalidPubkey(String),
144 #[error("malformed signature: {0}")]
145 InvalidSignature(String),
146 #[error("block source error: {0}")]
147 BlockSource(String),
148}
149
150pub async fn verify_stake(
161 proof: &StakeProof,
162 provider_npub: &str,
163 source: &dyn BlockSource,
164) -> Result<StakeStatus, StakeError> {
165 use cdk::secp256k1::{schnorr::Signature, Message, Secp256k1, XOnlyPublicKey};
166
167 let pubkey_bytes = hex::decode(&proof.provider_pubkey_hex)
169 .map_err(|e| StakeError::InvalidPubkey(e.to_string()))?;
170 let xonly = XOnlyPublicKey::from_slice(&pubkey_bytes)
171 .map_err(|e| StakeError::InvalidPubkey(e.to_string()))?;
172 let sig_bytes = hex::decode(&proof.signature_hex)
173 .map_err(|e| StakeError::InvalidSignature(e.to_string()))?;
174 let sig = Signature::from_slice(&sig_bytes)
175 .map_err(|e| StakeError::InvalidSignature(e.to_string()))?;
176
177 let digest = canonical_signing_message(
179 provider_npub,
180 &proof.utxo_outpoint,
181 proof.locktime_unix,
182 proof.sats,
183 );
184 let msg = Message::from_digest(digest);
185 let secp = Secp256k1::verification_only();
186 if secp.verify_schnorr(&sig, &msg, &xonly).is_err() {
187 return Ok(StakeStatus::BadSignature);
188 }
189
190 let utxo = match source.fetch_utxo(&proof.utxo_outpoint).await {
192 Ok(Some(u)) => u,
193 Ok(None) => return Ok(StakeStatus::Spent),
194 Err(e) => return Ok(StakeStatus::Unverified(e.to_string())),
195 };
196
197 if utxo.spent {
198 return Ok(StakeStatus::Spent);
199 }
200
201 let pk_hex = proof.provider_pubkey_hex.to_lowercase();
206 let script_lc = utxo.script_pubkey_hex.to_lowercase();
207 if !script_lc.contains(&pk_hex) {
208 let mut hasher = sha2::Sha256::new();
214 hasher.update(&pubkey_bytes);
215 let _sha = hasher.finalize();
216 return Ok(StakeStatus::PubkeyMismatch);
221 }
222
223 let now = match source.current_unix_time().await {
225 Ok(t) => t,
226 Err(e) => return Ok(StakeStatus::Unverified(e.to_string())),
227 };
228 if proof.locktime_unix <= now {
229 return Ok(StakeStatus::Unlocked);
230 }
231
232 Ok(StakeStatus::Valid {
233 effective_sats: proof.sats.min(utxo.sats),
234 locktime_unix: proof.locktime_unix,
235 })
236}
237
238pub fn stake_rank(sats: u64, locktime_unix: u64, now: u64) -> f64 {
248 if sats == 0 || locktime_unix <= now {
249 return 0.0;
250 }
251 let locked_secs = locktime_unix - now;
252 let product = (sats as f64) * (locked_secs as f64);
253 if product <= 1.0 {
254 0.0
255 } else {
256 product.ln()
257 }
258}
259
260pub fn validate_esplora_url(url: &str) -> Result<(), &'static str> {
272 if !url.starts_with("https://") {
273 return Err("only https:// is allowed");
274 }
275 if url.contains('@') {
276 return Err("userinfo in URL is not allowed");
277 }
278 if url.contains('#') {
279 return Err("URL fragment is not allowed");
280 }
281 let after_scheme = &url["https://".len()..];
282 let host_end = if after_scheme.starts_with('[') {
286 match after_scheme.find(']') {
287 Some(idx) => idx + 1,
288 None => return Err("malformed bracketed IPv6 host"),
289 }
290 } else {
291 after_scheme
292 .find(|c: char| c == '/' || c == ':' || c == '?')
293 .unwrap_or(after_scheme.len())
294 };
295 let host = &after_scheme[..host_end].to_lowercase();
296 if host.is_empty() {
297 return Err("empty host");
298 }
299 const PRIVATE_HOST_PREFIXES: &[&str] = &[
301 "localhost",
302 "127.",
303 "169.254.",
304 "10.",
305 "192.168.",
306 "::1",
307 "[::1]",
308 "[fe80",
309 "[fc",
310 "[fd",
311 ];
312 for bad in PRIVATE_HOST_PREFIXES {
313 if host.starts_with(bad) {
314 return Err("private/loopback hosts are not allowed");
315 }
316 }
317 if let Some(rest) = host.strip_prefix("172.") {
319 if let Some(second_octet) = rest.split('.').next() {
320 if let Ok(n) = second_octet.parse::<u8>() {
321 if (16..=31).contains(&n) {
322 return Err("private/loopback hosts are not allowed");
323 }
324 }
325 }
326 }
327 Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use cdk::secp256k1::{Keypair, Message, Secp256k1, SecretKey};
334
335 fn keypair() -> (SecretKey, String) {
336 let secp = Secp256k1::new();
337 let sk_bytes = [42u8; 32];
338 let sk = SecretKey::from_slice(&sk_bytes).unwrap();
339 let kp = Keypair::from_secret_key(&secp, &sk);
340 let (xonly, _parity) = kp.x_only_public_key();
341 let xonly_hex = hex::encode(xonly.serialize());
342 (sk, xonly_hex)
343 }
344
345 fn sign_proof(
346 sk: &SecretKey,
347 provider_npub: &str,
348 outpoint: &str,
349 locktime: u64,
350 sats: u64,
351 ) -> String {
352 let secp = Secp256k1::new();
353 let kp = Keypair::from_secret_key(&secp, sk);
354 let digest = canonical_signing_message(provider_npub, outpoint, locktime, sats);
355 let msg = Message::from_digest(digest);
356 let sig = secp.sign_schnorr(&msg, &kp);
357 hex::encode(sig.as_ref())
358 }
359
360 struct MockChain {
361 utxo: Option<Utxo>,
362 now: u64,
363 }
364
365 #[async_trait::async_trait]
366 impl BlockSource for MockChain {
367 async fn fetch_utxo(&self, _outpoint: &str) -> Result<Option<Utxo>, StakeError> {
368 Ok(self.utxo.clone())
369 }
370 async fn current_unix_time(&self) -> Result<u64, StakeError> {
371 Ok(self.now)
372 }
373 }
374
375 #[test]
376 fn canonical_message_is_deterministic_and_field_sensitive() {
377 let a = canonical_signing_message("npub1abc", "txid:0", 100, 1000);
378 let b = canonical_signing_message("npub1abc", "txid:0", 100, 1000);
379 assert_eq!(a, b, "same inputs must hash identically");
380
381 let c = canonical_signing_message("npub1abc", "txid:0", 101, 1000);
382 let d = canonical_signing_message("npub1abc", "txid:1", 100, 1000);
383 let e = canonical_signing_message("npub1xyz", "txid:0", 100, 1000);
384 assert_ne!(a, c);
385 assert_ne!(a, d);
386 assert_ne!(a, e, "npub binding must affect the digest");
387 }
388
389 #[tokio::test]
390 async fn happy_path_returns_valid() {
391 let (sk, pk_hex) = keypair();
392 let npub = "npub1provider";
393 let outpoint = "abcd:0";
394 let locktime = 2_000_000_000;
395 let sats = 100_000;
396 let signature_hex = sign_proof(&sk, npub, outpoint, locktime, sats);
397
398 let proof = StakeProof {
399 utxo_outpoint: outpoint.to_string(),
400 locktime_unix: locktime,
401 sats,
402 provider_pubkey_hex: pk_hex.clone(),
403 signature_hex,
404 version: 1,
405 };
406
407 let chain = MockChain {
408 utxo: Some(Utxo {
409 outpoint: outpoint.to_string(),
410 script_pubkey_hex: format!("5120{}", pk_hex), sats,
412 spent: false,
413 }),
414 now: 1_700_000_000,
415 };
416
417 let status = verify_stake(&proof, npub, &chain).await.unwrap();
418 assert!(
419 matches!(status, StakeStatus::Valid { effective_sats, locktime_unix }
420 if effective_sats == sats && locktime_unix == locktime),
421 "got {:?}",
422 status
423 );
424 }
425
426 #[tokio::test]
427 async fn cross_npub_replay_fails_signature_check() {
428 let (sk, pk_hex) = keypair();
429 let outpoint = "abcd:0";
430 let locktime = 2_000_000_000;
431 let sats = 100_000;
432 let signature_hex = sign_proof(&sk, "npub1original", outpoint, locktime, sats);
434
435 let proof = StakeProof {
436 utxo_outpoint: outpoint.to_string(),
437 locktime_unix: locktime,
438 sats,
439 provider_pubkey_hex: pk_hex.clone(),
440 signature_hex,
441 version: 1,
442 };
443 let chain = MockChain {
444 utxo: Some(Utxo {
445 outpoint: outpoint.to_string(),
446 script_pubkey_hex: format!("5120{}", pk_hex),
447 sats,
448 spent: false,
449 }),
450 now: 1_700_000_000,
451 };
452
453 let status = verify_stake(&proof, "npub1impostor", &chain).await.unwrap();
454 assert_eq!(status, StakeStatus::BadSignature);
455 }
456
457 #[tokio::test]
458 async fn spent_utxo_is_rejected() {
459 let (sk, pk_hex) = keypair();
460 let npub = "npub1provider";
461 let outpoint = "abcd:0";
462 let locktime = 2_000_000_000;
463 let sats = 100_000;
464 let signature_hex = sign_proof(&sk, npub, outpoint, locktime, sats);
465
466 let proof = StakeProof {
467 utxo_outpoint: outpoint.to_string(),
468 locktime_unix: locktime,
469 sats,
470 provider_pubkey_hex: pk_hex.clone(),
471 signature_hex,
472 version: 1,
473 };
474 let chain = MockChain {
475 utxo: Some(Utxo {
476 outpoint: outpoint.to_string(),
477 script_pubkey_hex: format!("5120{}", pk_hex),
478 sats,
479 spent: true,
480 }),
481 now: 1_700_000_000,
482 };
483
484 let status = verify_stake(&proof, npub, &chain).await.unwrap();
485 assert_eq!(status, StakeStatus::Spent);
486 }
487
488 #[tokio::test]
489 async fn past_locktime_is_unlocked() {
490 let (sk, pk_hex) = keypair();
491 let npub = "npub1provider";
492 let outpoint = "abcd:0";
493 let locktime = 1_000_000_000; let sats = 100_000;
495 let signature_hex = sign_proof(&sk, npub, outpoint, locktime, sats);
496
497 let proof = StakeProof {
498 utxo_outpoint: outpoint.to_string(),
499 locktime_unix: locktime,
500 sats,
501 provider_pubkey_hex: pk_hex.clone(),
502 signature_hex,
503 version: 1,
504 };
505 let chain = MockChain {
506 utxo: Some(Utxo {
507 outpoint: outpoint.to_string(),
508 script_pubkey_hex: format!("5120{}", pk_hex),
509 sats,
510 spent: false,
511 }),
512 now: 1_700_000_000,
513 };
514
515 let status = verify_stake(&proof, npub, &chain).await.unwrap();
516 assert_eq!(status, StakeStatus::Unlocked);
517 }
518
519 #[tokio::test]
520 async fn pubkey_not_in_script_is_mismatch() {
521 let (sk, pk_hex) = keypair();
522 let npub = "npub1provider";
523 let outpoint = "abcd:0";
524 let locktime = 2_000_000_000;
525 let sats = 100_000;
526 let signature_hex = sign_proof(&sk, npub, outpoint, locktime, sats);
527
528 let proof = StakeProof {
529 utxo_outpoint: outpoint.to_string(),
530 locktime_unix: locktime,
531 sats,
532 provider_pubkey_hex: pk_hex.clone(),
533 signature_hex,
534 version: 1,
535 };
536 let chain = MockChain {
538 utxo: Some(Utxo {
539 outpoint: outpoint.to_string(),
540 script_pubkey_hex:
541 "5120deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
542 .to_string(),
543 sats,
544 spent: false,
545 }),
546 now: 1_700_000_000,
547 };
548
549 let status = verify_stake(&proof, npub, &chain).await.unwrap();
550 assert_eq!(status, StakeStatus::PubkeyMismatch);
551 }
552
553 #[test]
554 fn rank_orders_higher_when_either_factor_is_higher() {
555 let now = 1_000;
556 let r1 = stake_rank(100_000, 1_000 + 30 * 86400, now); let r2 = stake_rank(100_000, 1_000 + 90 * 86400, now); let r3 = stake_rank(1_000_000, 1_000 + 30 * 86400, now); assert!(r2 > r1, "longer lock should rank higher");
560 assert!(r3 > r1, "more sats should rank higher");
561 }
562
563 #[test]
564 fn rank_is_zero_when_unlocked_or_no_sats() {
565 let now = 2_000;
566 assert_eq!(stake_rank(100_000, 1_000, now), 0.0);
567 assert_eq!(stake_rank(0, now + 86400, now), 0.0);
568 }
569
570 #[test]
571 fn validate_esplora_url_accepts_https_public() {
572 assert!(validate_esplora_url("https://blockstream.info/api").is_ok());
573 assert!(validate_esplora_url("https://mempool.space/api").is_ok());
574 }
575
576 #[test]
577 fn validate_esplora_url_rejects_http_and_userinfo() {
578 assert!(validate_esplora_url("http://example.com").is_err());
579 assert!(validate_esplora_url("https://user:pass@example.com").is_err());
580 assert!(validate_esplora_url("https://example.com/#frag").is_err());
581 }
582
583 #[test]
584 fn validate_esplora_url_rejects_loopback_and_rfc1918() {
585 for bad in [
586 "https://localhost/",
587 "https://127.0.0.1:8332",
588 "https://10.0.0.1",
589 "https://192.168.1.5",
590 "https://169.254.1.1",
591 "https://172.16.5.5",
592 "https://172.31.255.255",
593 "https://[::1]",
594 ] {
595 assert!(validate_esplora_url(bad).is_err(), "must reject {}", bad);
596 }
597 }
598
599 #[test]
600 fn validate_esplora_url_accepts_172_outside_rfc1918() {
601 assert!(validate_esplora_url("https://172.15.1.1/api").is_ok());
603 assert!(validate_esplora_url("https://172.32.0.1/api").is_ok());
604 }
605}