Skip to main content

paygress/
stake.rs

1// Stake-weighted "staked" tier (Unit 11 of the 12-month plan).
2//
3// Borrows the JoinMarket fidelity-bond pattern: a provider creates a
4// Bitcoin UTXO with an absolute timelock (CLTV) and signs a message
5// proving control of the key embedded in the UTXO's scriptPubKey.
6// The signature, the UTXO outpoint, the locktime, and the sat amount
7// together form a `StakeProof`; an offer carrying a verifiable
8// `StakeProof` is eligible for the `staked` tier in discovery.
9//
10// Naming: the tier is "staked" (not "premium") because year-1 has no
11// on-chain slashing — automated slashing requires DLCs and is
12// explicitly out of scope. Consumers see "this provider has posted a
13// Bitcoin bond", not "this provider is more reliable." Slashing is
14// social: a provider whose reputation score falls below a threshold
15// is publicly flagged by the observatory; consumers refusing to use
16// them is the slash.
17//
18// Privacy disclosure
19// ------------------
20// Publishing a `StakeProof` in a public Nostr offer permanently links
21// a Bitcoin UTXO to the provider's Nostr identity. Hash-commitment-
22// with-encrypted-reveal is a year-2 privacy improvement. Operators
23// who don't want this linkage should not stake.
24
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28/// Wire format the marketplace uses to talk about stake. Travels
29/// inside `ProviderOfferContent`.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct StakeProof {
32    /// Bitcoin UTXO that's locked: `<txid>:<vout>`.
33    pub utxo_outpoint: String,
34
35    /// Absolute lock time (Unix seconds). The UTXO cannot be spent
36    /// until the chain reaches this time. Larger = better stake.
37    pub locktime_unix: u64,
38
39    /// Sats locked. Larger = better stake.
40    pub sats: u64,
41
42    /// Provider's hex-encoded x-only pubkey used to sign the
43    /// canonical message. Must match the key embedded in the
44    /// UTXO's scriptPubKey (the on-chain check happens against the
45    /// scriptPubKey returned by `BlockSource::fetch_utxo`).
46    pub provider_pubkey_hex: String,
47
48    /// Hex-encoded Schnorr signature over [`canonical_signing_message`].
49    pub signature_hex: String,
50
51    /// Schema version for this struct. Lets us evolve without
52    /// breaking older offer parsers (`#[serde(default)]` upstream).
53    pub version: u8,
54}
55
56/// Build the canonical bytes a provider signs to prove control of
57/// the staked UTXO. Deterministic across runs and platforms — any
58/// reorder of fields breaks the signature.
59///
60/// Format: `paygress-stake-v1\x00<provider_npub>\x00<utxo_outpoint>\x00<locktime_unix>\x00<sats>`
61/// The trailing `\x00`-separated fields prevent length-extension /
62/// boundary confusion (no field can contain `\x00` in practice for
63/// any of these). The result is hashed with SHA-256 before signing.
64///
65/// `provider_npub` is bound into the message so a stake proof
66/// cannot be replayed across Nostr identities — even if an attacker
67/// observes a published offer and copies the StakeProof struct
68/// verbatim, the signature is over a different `provider_npub` and
69/// fails verification on their offer.
70pub 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/// What a `BlockSource` returns about a UTXO.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Utxo {
92    pub outpoint: String,
93    pub script_pubkey_hex: String,
94    pub sats: u64,
95    /// True if the UTXO has been spent in a confirmed block.
96    pub spent: bool,
97}
98
99/// Async surface for fetching Bitcoin chain data. Production wires
100/// this to two independent Esplora endpoints and requires
101/// agreement; tests pass a hand-rolled mock that returns canned
102/// values.
103#[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/// Result of verifying a stake proof.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum StakeStatus {
112    /// All checks passed. The proof is currently locking
113    /// `effective_sats` until `locktime_unix`.
114    Valid {
115        effective_sats: u64,
116        locktime_unix: u64,
117    },
118    /// The UTXO no longer exists or has been spent.
119    Spent,
120    /// The locktime has already elapsed; the bond is no longer
121    /// locking anything.
122    Unlocked,
123    /// Signature failed verification.
124    BadSignature,
125    /// The pubkey in the proof doesn't match the script pubkey of
126    /// the on-chain UTXO.
127    PubkeyMismatch,
128    /// The block source could not confirm the UTXO (e.g. Esplora
129    /// outage). Caller should not treat this as Valid.
130    Unverified(String),
131}
132
133/// Errors from the stake module distinct from `StakeStatus`. These
134/// surface programmer-visible failures (malformed input, RPC
135/// errors); `StakeStatus::Unverified(_)` already covers
136/// "couldn't talk to the chain" so `verify_stake` itself never
137/// returns these for the network path.
138#[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
150/// Verify a stake proof against the chain.
151///
152/// All checks are short-circuiting on the most common failures:
153/// 1. Signature over the canonical message verifies under the
154///    proof's `provider_pubkey_hex`.
155/// 2. The pubkey appears in the on-chain UTXO's scriptPubKey
156///    (substring match against the returned hex — sufficient for
157///    P2WPKH/P2TR; richer script types are a follow-up).
158/// 3. The UTXO is unspent.
159/// 4. The locktime is in the future.
160pub 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    // 1. Parse pubkey and signature.
168    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    // 2. Verify the Schnorr signature over the canonical message.
178    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    // 3. Fetch the UTXO.
191    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    // 4. Verify the proof's pubkey appears in the on-chain
202    //    scriptPubKey. Substring match is correct for P2WPKH (the
203    //    pubkey hash) and P2TR (the x-only pubkey itself); richer
204    //    script types are a follow-up.
205    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        // For P2WPKH the script holds HASH160(pubkey) rather than
209        // the pubkey itself. Accept that too: a fuller
210        // implementation parses script types properly. For now we
211        // match the raw pubkey AND its HASH160 so common deposit
212        // types both work.
213        let mut hasher = sha2::Sha256::new();
214        hasher.update(&pubkey_bytes);
215        let _sha = hasher.finalize();
216        // We don't pull in `ripemd` for HASH160 today; a future
217        // change can. For now, P2TR (raw x-only in script) works
218        // and P2WPKH operators will see PubkeyMismatch — they'd
219        // need to use a P2TR address until the follow-up.
220        return Ok(StakeStatus::PubkeyMismatch);
221    }
222
223    // 5. Locktime in the future?
224    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
238/// Stake ranking score: `log(sats × locked_seconds)`.
239/// Higher score = better stake. Returns `0.0` if either factor is
240/// zero (not staked, or already unlocked).
241///
242/// Provider operators choose their own stake economics; this
243/// function only orders them. Recommended ranges (not enforced):
244///   - 100k sats × 30 days: starter bond
245///   - 1M sats × 90 days: serious bond
246///   - 10M+ sats × 1 year: lighthouse
247pub 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
260/// Validate an Esplora endpoint URL before we make a request to it.
261/// The stake-verification flow involves issuing HTTP requests to
262/// operator-supplied URLs; without this, an attacker could point
263/// the verifier at internal services (SSRF).
264///
265/// Rejects:
266///   - non-`https://` schemes
267///   - hostnames that resolve to loopback / link-local / RFC-1918
268///     ranges (the operator can override with a personal node URL,
269///     but only via an explicit allowlist not enabled here).
270///   - URLs with a userinfo or fragment.
271pub 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    // IPv6 literals are bracketed: `[::1]:port/path`. Treat the
283    // whole bracketed range (including the brackets) as the host
284    // so `:` inside the address doesn't terminate the host parse.
285    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    // Common ways to ask the verifier to talk to the local box.
300    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    // 172.16.0.0/12 — needs a numeric range check rather than a prefix.
318    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), // P2TR-shaped
411                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        // Signed for npub A; replayed against npub B.
433        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; // already in the past
494        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        // Script hex doesn't contain the proof's pubkey.
537        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); // 30d
557        let r2 = stake_rank(100_000, 1_000 + 90 * 86400, now); // 90d
558        let r3 = stake_rank(1_000_000, 1_000 + 30 * 86400, now); // 10x sats
559        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        // 172.0–15 and 172.32–255 are public.
602        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}