Skip to main content

solid_pod_rs/
payments.rs

1//! HTTP 402 Payment Required — Web Ledgers + multi-chain TXO deposits.
2//!
3//! Implements the JSS payment architecture: per-identity satoshi
4//! balances tracked via the Web Ledgers spec, multi-chain TXO deposit
5//! verification, HTTP 402 negotiation, and payment-store abstraction.
6//!
7//! MRC20 state-chain token types ([`Mrc20Op`], [`Mrc20State`],
8//! [`verify_state_link`]) are re-exported from [`crate::mrc20`] for
9//! backward compatibility. The full MRC20 implementation — JCS
10//! canonicalization, BIP-341 taproot key chaining, and state-chain
11//! verification — lives in [`crate::mrc20`].
12//!
13//! All identities are `did:nostr:<hex-pubkey>` — users and agents are
14//! indistinguishable at the protocol level, enabling user↔user,
15//! user↔agent, and agent↔agent payments.
16//!
17//! Storage is abstracted via [`PaymentStore`] (`?Send` futures for
18//! wasm32 compat) so CF Workers consumers back it with KV/DO while
19//! native servers use filesystem or database backends.
20//!
21//! This module is always-compiled (part of the `core` surface). On
22//! wasm32, timestamps use `js_sys::Date::now()`; on native, `SystemTime`.
23//!
24//! @see <https://webledgers.org>
25//! @see JSS `src/handlers/pay.js`, `src/webledger.js`
26
27use serde::{Deserialize, Serialize};
28
29// ---------------------------------------------------------------------------
30// Web Ledger types (webledgers.org spec)
31// ---------------------------------------------------------------------------
32
33/// A single balance entry in the Web Ledger.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct LedgerEntry {
36    #[serde(rename = "type")]
37    pub entry_type: String,
38    /// Agent URI: `did:nostr:<hex-pubkey>`.
39    pub url: String,
40    /// Balance — string integer (JSS compat) or multi-currency array.
41    pub amount: LedgerAmount,
42}
43
44/// Balance representation — mirrors JSS's flexible amount field.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(untagged)]
47pub enum LedgerAmount {
48    Simple(String),
49    Multi(Vec<CurrencyAmount>),
50}
51
52impl LedgerAmount {
53    pub fn sats(&self) -> u64 {
54        match self {
55            LedgerAmount::Simple(s) => s.parse().unwrap_or(0),
56            LedgerAmount::Multi(v) => v
57                .iter()
58                .find(|a| a.currency == "satoshi" || a.currency == "sat")
59                .map(|a| a.value.parse().unwrap_or(0))
60                .unwrap_or(0),
61        }
62    }
63
64    pub fn set_sats(&mut self, amount: u64) {
65        match self {
66            LedgerAmount::Simple(s) => *s = amount.to_string(),
67            LedgerAmount::Multi(v) => {
68                if let Some(entry) = v
69                    .iter_mut()
70                    .find(|a| a.currency == "satoshi" || a.currency == "sat")
71                {
72                    entry.value = amount.to_string();
73                } else {
74                    v.push(CurrencyAmount {
75                        currency: "satoshi".into(),
76                        value: amount.to_string(),
77                    });
78                }
79            }
80        }
81    }
82
83    pub fn chain_balance(&self, chain: &str) -> u64 {
84        match self {
85            LedgerAmount::Simple(_) => 0,
86            LedgerAmount::Multi(v) => v
87                .iter()
88                .find(|a| a.currency == chain)
89                .map(|a| a.value.parse().unwrap_or(0))
90                .unwrap_or(0),
91        }
92    }
93}
94
95/// A single currency amount within a multi-currency balance.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CurrencyAmount {
98    pub currency: String,
99    pub value: String,
100}
101
102/// The full Web Ledger document at `/.well-known/webledgers/webledgers.json`.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct WebLedger {
105    #[serde(rename = "@context")]
106    pub context: String,
107    #[serde(rename = "type")]
108    pub ledger_type: String,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub id: Option<String>,
111    pub name: String,
112    pub description: String,
113    #[serde(rename = "defaultCurrency")]
114    pub default_currency: String,
115    pub created: u64,
116    pub updated: u64,
117    pub entries: Vec<LedgerEntry>,
118}
119
120impl WebLedger {
121    pub fn new(name: &str) -> Self {
122        let now = now_secs();
123        Self {
124            context: "https://w3id.org/webledgers".into(),
125            ledger_type: "WebLedger".into(),
126            id: None,
127            name: name.into(),
128            description: "Paid API balance ledger".into(),
129            default_currency: "satoshi".into(),
130            created: now,
131            updated: now,
132            entries: Vec::new(),
133        }
134    }
135
136    pub fn get_balance(&self, did: &str) -> u64 {
137        self.entries
138            .iter()
139            .find(|e| e.url == did)
140            .map(|e| e.amount.sats())
141            .unwrap_or(0)
142    }
143
144    pub fn credit(&mut self, did: &str, amount: u64) {
145        self.updated = now_secs();
146        if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
147            let current = entry.amount.sats();
148            entry.amount.set_sats(current.saturating_add(amount));
149        } else {
150            self.entries.push(LedgerEntry {
151                entry_type: "Entry".into(),
152                url: did.into(),
153                amount: LedgerAmount::Simple(amount.to_string()),
154            });
155        }
156    }
157
158    pub fn debit(&mut self, did: &str, amount: u64) -> Result<u64, PaymentError> {
159        self.updated = now_secs();
160        let entry = self
161            .entries
162            .iter_mut()
163            .find(|e| e.url == did)
164            .ok_or(PaymentError::InsufficientBalance {
165                balance: 0,
166                cost: amount,
167            })?;
168        let current = entry.amount.sats();
169        if current < amount {
170            return Err(PaymentError::InsufficientBalance {
171                balance: current,
172                cost: amount,
173            });
174        }
175        entry.amount.set_sats(current - amount);
176        Ok(current - amount)
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Payment configuration
182// ---------------------------------------------------------------------------
183
184/// Pod payment configuration (mirrors JSS `--pay-*` flags).
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct PayConfig {
187    pub enabled: bool,
188    pub cost_sats: u64,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub token: Option<TokenConfig>,
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub chains: Vec<ChainConfig>,
193}
194
195impl Default for PayConfig {
196    fn default() -> Self {
197        Self {
198            enabled: false,
199            cost_sats: 1,
200            token: None,
201            chains: Vec::new(),
202        }
203    }
204}
205
206/// MRC20 token configuration.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct TokenConfig {
209    pub ticker: String,
210    pub rate: u64,
211    pub supply: u64,
212    pub issuer: String,
213}
214
215/// Chain configuration for multi-chain deposits.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ChainConfig {
218    pub id: String,
219    pub unit: String,
220    pub name: String,
221    pub explorer_api: String,
222}
223
224impl ChainConfig {
225    pub fn bitcoin_mainnet() -> Self {
226        Self {
227            id: "btc".into(),
228            unit: "sat".into(),
229            name: "Bitcoin".into(),
230            explorer_api: "https://mempool.space/api".into(),
231        }
232    }
233
234    pub fn bitcoin_testnet3() -> Self {
235        Self {
236            id: "tbtc3".into(),
237            unit: "tbtc3".into(),
238            name: "Bitcoin Testnet3".into(),
239            explorer_api: "https://mempool.space/testnet/api".into(),
240        }
241    }
242
243    pub fn bitcoin_testnet4() -> Self {
244        Self {
245            id: "tbtc4".into(),
246            unit: "tbtc4".into(),
247            name: "Bitcoin Testnet4".into(),
248            explorer_api: "https://mempool.space/testnet4/api".into(),
249        }
250    }
251
252    pub fn bitcoin_signet() -> Self {
253        Self {
254            id: "signet".into(),
255            unit: "signet".into(),
256            name: "Bitcoin Signet".into(),
257            explorer_api: "https://mempool.space/signet/api".into(),
258        }
259    }
260}
261
262// ---------------------------------------------------------------------------
263// HTTP 402 response + /pay/.info
264// ---------------------------------------------------------------------------
265
266/// HTTP 402 Payment Required response body.
267pub fn payment_required_body(balance: u64, cost: u64) -> serde_json::Value {
268    serde_json::json!({
269        "error": "Payment Required",
270        "balance": balance,
271        "cost": cost,
272        "unit": "sat",
273        "deposit": "/pay/.deposit",
274        "balance_endpoint": "/pay/.balance",
275        "spec": "https://webledgers.org"
276    })
277}
278
279/// GET /pay/.info response body.
280pub fn pay_info(config: &PayConfig) -> serde_json::Value {
281    let mut info = serde_json::json!({
282        "cost": config.cost_sats,
283        "unit": "sat",
284        "deposit": "/pay/.deposit",
285        "balance": "/pay/.balance"
286    });
287    if let Some(ref token) = config.token {
288        info["token"] = serde_json::json!({
289            "ticker": token.ticker,
290            "rate": token.rate,
291            "buy": "/pay/.buy",
292            "withdraw": "/pay/.withdraw",
293            "supply": token.supply,
294            "issuer": token.issuer
295        });
296    }
297    if !config.chains.is_empty() {
298        info["chains"] = serde_json::json!(
299            config.chains.iter().map(|c| serde_json::json!({
300                "id": c.id,
301                "unit": c.unit,
302                "name": c.name
303            })).collect::<Vec<_>>()
304        );
305        info["pool"] = serde_json::json!("/pay/.pool");
306    }
307    info
308}
309
310/// Response headers attached to successful paid requests.
311///
312/// JSS parity: on every response that consumed balance, the server adds
313/// `X-Balance`, `X-Cost`, and `X-Pay-Currency` headers so the client
314/// can track spend without a separate `/pay/.balance` call.
315///
316/// Returns a `Vec<(header_name, header_value)>` that the transport layer
317/// appends to the HTTP response. Framework-agnostic — actix-web, axum,
318/// and Worker consumers each adapt these to their header type.
319pub fn payment_response_headers(balance: u64, cost: u64, currency: &str) -> Vec<(&'static str, String)> {
320    vec![
321        ("X-Balance", balance.to_string()),
322        ("X-Cost", cost.to_string()),
323        ("X-Pay-Currency", currency.to_string()),
324    ]
325}
326
327/// GET /pay/.balance response body.
328pub fn balance_response(did: &str, balance: u64, cost: u64) -> serde_json::Value {
329    serde_json::json!({
330        "did": did,
331        "balance": balance,
332        "cost": cost,
333        "unit": "sat"
334    })
335}
336
337/// Web Ledgers discovery document.
338pub fn webledgers_discovery(pod_base: &str) -> serde_json::Value {
339    serde_json::json!({
340        "@context": "https://w3id.org/webledgers",
341        "type": "WebLedger",
342        "name": "Pod Credits",
343        "description": "Satoshi-denominated micropayments for pod resource access",
344        "defaultCurrency": "satoshi",
345        "endpoints": {
346            "info": "/pay/.info",
347            "balance": "/pay/.balance",
348            "deposit": "/pay/.deposit",
349            "ledger": "/.well-known/webledgers/webledgers.json"
350        },
351        "verification": {
352            "method": "mempool-api",
353            "url": "https://mempool.space/api/"
354        },
355        "server": pod_base
356    })
357}
358
359// ---------------------------------------------------------------------------
360// TXO deposit parsing
361// ---------------------------------------------------------------------------
362
363/// Parsed TXO deposit URI.
364#[derive(Debug, Clone)]
365pub struct TxoDeposit {
366    pub chain: Option<String>,
367    pub txid: String,
368    pub vout: u32,
369}
370
371/// Parse a TXO URI: `txid:vout`, `txo:chain:txid:vout`, or `bitcoin:txid:vout`.
372pub fn parse_txo_uri(input: &str) -> Result<TxoDeposit, PaymentError> {
373    let trimmed = input.trim();
374
375    // Try `txo:<chain>:<txid>:<vout>` first
376    if let Some(rest) = trimmed.strip_prefix("txo:") {
377        let parts: Vec<&str> = rest.splitn(3, ':').collect();
378        if parts.len() == 3 {
379            let chain = parts[0].to_lowercase();
380            let txid = parts[1];
381            let vout: u32 = parts[2]
382                .parse()
383                .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
384            validate_txid(txid)?;
385            return Ok(TxoDeposit {
386                chain: Some(chain),
387                txid: txid.to_string(),
388                vout,
389            });
390        }
391    }
392
393    // Try `bitcoin:txid:vout`
394    let cleaned = trimmed.strip_prefix("bitcoin:").unwrap_or(trimmed);
395    let parts: Vec<&str> = cleaned.split(':').collect();
396    if parts.len() != 2 {
397        return Err(PaymentError::InvalidTxo(
398            "expected txid:vout format".into(),
399        ));
400    }
401    let txid = parts[0];
402    let vout: u32 = parts[1]
403        .parse()
404        .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
405    validate_txid(txid)?;
406    Ok(TxoDeposit {
407        chain: None,
408        txid: txid.to_string(),
409        vout,
410    })
411}
412
413fn validate_txid(txid: &str) -> Result<(), PaymentError> {
414    if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
415        return Err(PaymentError::InvalidTxo(
416            "txid must be 64 hex chars".into(),
417        ));
418    }
419    Ok(())
420}
421
422// ---------------------------------------------------------------------------
423// MRC20 state chain types — re-exported from `crate::mrc20`.
424// ---------------------------------------------------------------------------
425
426/// Backward-compatibility re-exports. The canonical definitions and full
427/// implementation (JCS, BIP-341, state-chain verification) live in
428/// [`crate::mrc20`]. These re-exports let existing consumers that import
429/// MRC20 types from `payments` continue to compile without changes.
430pub use crate::mrc20::{Mrc20Op, Mrc20State, verify_state_link};
431
432// ---------------------------------------------------------------------------
433// Payment store trait (storage abstraction)
434// ---------------------------------------------------------------------------
435
436/// Abstract payment storage — backends implement this for KV/DO/FS.
437#[async_trait::async_trait(?Send)]
438pub trait PaymentStore: Send + Sync {
439    async fn read_ledger(&self) -> Result<WebLedger, PaymentError>;
440    async fn write_ledger(&self, ledger: &WebLedger) -> Result<(), PaymentError>;
441    async fn check_replay(&self, key: &str) -> Result<bool, PaymentError>;
442    async fn record_replay(&self, key: &str) -> Result<(), PaymentError>;
443}
444
445// ---------------------------------------------------------------------------
446// DID:nostr identity helpers
447// ---------------------------------------------------------------------------
448
449/// Convert a hex pubkey to `did:nostr:<hex>`.
450pub fn pubkey_to_did(pubkey: &str) -> String {
451    format!("did:nostr:{pubkey}")
452}
453
454/// Extract hex pubkey from `did:nostr:<hex>`.
455pub fn did_to_pubkey(did: &str) -> Option<&str> {
456    did.strip_prefix("did:nostr:")
457}
458
459// ---------------------------------------------------------------------------
460// Errors
461// ---------------------------------------------------------------------------
462
463/// Payment-specific errors.
464#[derive(Debug, thiserror::Error)]
465pub enum PaymentError {
466    #[error("insufficient balance: have {balance}, need {cost}")]
467    InsufficientBalance { balance: u64, cost: u64 },
468
469    #[error("invalid TXO: {0}")]
470    InvalidTxo(String),
471
472    #[error("invalid MRC20 state: {0}")]
473    InvalidState(String),
474
475    #[error("replay detected: {0}")]
476    Replay(String),
477
478    #[error("payment store: {0}")]
479    Store(String),
480}
481
482// ---------------------------------------------------------------------------
483// Helpers
484// ---------------------------------------------------------------------------
485
486fn now_secs() -> u64 {
487    #[cfg(target_arch = "wasm32")]
488    {
489        (js_sys::Date::now() / 1000.0) as u64
490    }
491    #[cfg(not(target_arch = "wasm32"))]
492    {
493        std::time::SystemTime::now()
494            .duration_since(std::time::UNIX_EPOCH)
495            .unwrap_or_default()
496            .as_secs()
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Tests
502// ---------------------------------------------------------------------------
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn new_ledger_empty() {
510        let ledger = WebLedger::new("Test");
511        assert!(ledger.entries.is_empty());
512        assert_eq!(ledger.default_currency, "satoshi");
513        assert_eq!(ledger.context, "https://w3id.org/webledgers");
514    }
515
516    #[test]
517    fn credit_creates_entry() {
518        let mut ledger = WebLedger::new("Test");
519        ledger.credit("did:nostr:abc123", 1000);
520        assert_eq!(ledger.get_balance("did:nostr:abc123"), 1000);
521    }
522
523    #[test]
524    fn debit_reduces_balance() {
525        let mut ledger = WebLedger::new("Test");
526        ledger.credit("did:nostr:abc123", 1000);
527        let remaining = ledger.debit("did:nostr:abc123", 100).unwrap();
528        assert_eq!(remaining, 900);
529        assert_eq!(ledger.get_balance("did:nostr:abc123"), 900);
530    }
531
532    #[test]
533    fn debit_rejects_insufficient() {
534        let mut ledger = WebLedger::new("Test");
535        ledger.credit("did:nostr:abc123", 50);
536        let err = ledger.debit("did:nostr:abc123", 100).unwrap_err();
537        assert!(matches!(
538            err,
539            PaymentError::InsufficientBalance {
540                balance: 50,
541                cost: 100
542            }
543        ));
544    }
545
546    #[test]
547    fn debit_rejects_unknown_did() {
548        let mut ledger = WebLedger::new("Test");
549        let err = ledger.debit("did:nostr:unknown", 1).unwrap_err();
550        assert!(matches!(
551            err,
552            PaymentError::InsufficientBalance {
553                balance: 0,
554                cost: 1
555            }
556        ));
557    }
558
559    #[test]
560    fn credit_accumulates() {
561        let mut ledger = WebLedger::new("Test");
562        ledger.credit("did:nostr:abc", 100);
563        ledger.credit("did:nostr:abc", 200);
564        assert_eq!(ledger.get_balance("did:nostr:abc"), 300);
565    }
566
567    #[test]
568    fn agent_agent_payment() {
569        let mut ledger = WebLedger::new("Test");
570        let agent_a = "did:nostr:aaaa";
571        let agent_b = "did:nostr:bbbb";
572        ledger.credit(agent_a, 500);
573        ledger.debit(agent_a, 100).unwrap();
574        ledger.credit(agent_b, 100);
575        assert_eq!(ledger.get_balance(agent_a), 400);
576        assert_eq!(ledger.get_balance(agent_b), 100);
577    }
578
579    #[test]
580    fn parse_txo_bare() {
581        let txid = "a".repeat(64);
582        let uri = format!("{txid}:0");
583        let txo = parse_txo_uri(&uri).unwrap();
584        assert!(txo.chain.is_none());
585        assert_eq!(txo.txid, txid);
586        assert_eq!(txo.vout, 0);
587    }
588
589    #[test]
590    fn parse_txo_with_chain() {
591        let txid = "b".repeat(64);
592        let uri = format!("txo:tbtc4:{txid}:1");
593        let txo = parse_txo_uri(&uri).unwrap();
594        assert_eq!(txo.chain.as_deref(), Some("tbtc4"));
595        assert_eq!(txo.txid, txid);
596        assert_eq!(txo.vout, 1);
597    }
598
599    #[test]
600    fn parse_txo_bitcoin_prefix() {
601        let txid = "c".repeat(64);
602        let uri = format!("bitcoin:{txid}:2");
603        let txo = parse_txo_uri(&uri).unwrap();
604        assert!(txo.chain.is_none());
605        assert_eq!(txo.vout, 2);
606    }
607
608    #[test]
609    fn parse_txo_rejects_short_txid() {
610        assert!(parse_txo_uri("abc123:0").is_err());
611    }
612
613    #[test]
614    fn pay_info_basic() {
615        let config = PayConfig::default();
616        let info = pay_info(&config);
617        assert_eq!(info["cost"], 1);
618        assert_eq!(info["unit"], "sat");
619        assert!(info.get("token").is_none());
620    }
621
622    #[test]
623    fn pay_info_with_token() {
624        let config = PayConfig {
625            enabled: true,
626            cost_sats: 2,
627            token: Some(TokenConfig {
628                ticker: "PODS".into(),
629                rate: 10,
630                supply: 10000,
631                issuer: "025e60b6".into(),
632            }),
633            chains: vec![ChainConfig::bitcoin_testnet4()],
634        };
635        let info = pay_info(&config);
636        assert_eq!(info["token"]["ticker"], "PODS");
637        assert!(info["chains"].as_array().is_some());
638    }
639
640    #[test]
641    fn ledger_serialization_roundtrip() {
642        let mut ledger = WebLedger::new("Test");
643        ledger.credit("did:nostr:abc", 42);
644        let json = serde_json::to_string(&ledger).unwrap();
645        let parsed: WebLedger = serde_json::from_str(&json).unwrap();
646        assert_eq!(parsed.get_balance("did:nostr:abc"), 42);
647    }
648
649    #[test]
650    fn pubkey_did_roundtrip() {
651        let pk = "abc123def456";
652        let did = pubkey_to_did(pk);
653        assert_eq!(did, "did:nostr:abc123def456");
654        assert_eq!(did_to_pubkey(&did), Some(pk));
655    }
656
657    #[test]
658    fn multi_currency_balance() {
659        let entry = LedgerEntry {
660            entry_type: "Entry".into(),
661            url: "did:nostr:abc".into(),
662            amount: LedgerAmount::Multi(vec![
663                CurrencyAmount {
664                    currency: "satoshi".into(),
665                    value: "100".into(),
666                },
667                CurrencyAmount {
668                    currency: "tbtc4".into(),
669                    value: "50".into(),
670                },
671            ]),
672        };
673        assert_eq!(entry.amount.sats(), 100);
674        assert_eq!(entry.amount.chain_balance("tbtc4"), 50);
675        assert_eq!(entry.amount.chain_balance("ltc"), 0);
676    }
677
678    #[test]
679    fn default_config_disabled() {
680        let config = PayConfig::default();
681        assert!(!config.enabled);
682        assert_eq!(config.cost_sats, 1);
683    }
684
685    #[test]
686    fn payment_response_headers_returns_three_headers() {
687        let headers = super::payment_response_headers(950, 50, "sat");
688        assert_eq!(headers.len(), 3);
689        assert_eq!(headers[0], ("X-Balance", "950".to_string()));
690        assert_eq!(headers[1], ("X-Cost", "50".to_string()));
691        assert_eq!(headers[2], ("X-Pay-Currency", "sat".to_string()));
692    }
693
694    #[test]
695    fn payment_response_headers_zero_balance() {
696        let headers = super::payment_response_headers(0, 1, "sat");
697        assert_eq!(headers[0].1, "0");
698    }
699}