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.entries.iter_mut().find(|e| e.url == did).ok_or(
161            PaymentError::InsufficientBalance {
162                balance: 0,
163                cost: amount,
164            },
165        )?;
166        let current = entry.amount.sats();
167        if current < amount {
168            return Err(PaymentError::InsufficientBalance {
169                balance: current,
170                cost: amount,
171            });
172        }
173        entry.amount.set_sats(current - amount);
174        Ok(current - amount)
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Payment configuration
180// ---------------------------------------------------------------------------
181
182/// Pod payment configuration (mirrors JSS `--pay-*` flags).
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct PayConfig {
185    pub enabled: bool,
186    pub cost_sats: u64,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub token: Option<TokenConfig>,
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub chains: Vec<ChainConfig>,
191}
192
193impl Default for PayConfig {
194    fn default() -> Self {
195        Self {
196            enabled: false,
197            cost_sats: 1,
198            token: None,
199            chains: Vec::new(),
200        }
201    }
202}
203
204/// MRC20 token configuration.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TokenConfig {
207    pub ticker: String,
208    pub rate: u64,
209    pub supply: u64,
210    pub issuer: String,
211}
212
213/// Chain configuration for multi-chain deposits.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ChainConfig {
216    pub id: String,
217    pub unit: String,
218    pub name: String,
219    pub explorer_api: String,
220}
221
222impl ChainConfig {
223    pub fn bitcoin_mainnet() -> Self {
224        Self {
225            id: "btc".into(),
226            unit: "sat".into(),
227            name: "Bitcoin".into(),
228            explorer_api: "https://mempool.space/api".into(),
229        }
230    }
231
232    pub fn bitcoin_testnet3() -> Self {
233        Self {
234            id: "tbtc3".into(),
235            unit: "tbtc3".into(),
236            name: "Bitcoin Testnet3".into(),
237            explorer_api: "https://mempool.space/testnet/api".into(),
238        }
239    }
240
241    pub fn bitcoin_testnet4() -> Self {
242        Self {
243            id: "tbtc4".into(),
244            unit: "tbtc4".into(),
245            name: "Bitcoin Testnet4".into(),
246            explorer_api: "https://mempool.space/testnet4/api".into(),
247        }
248    }
249
250    pub fn bitcoin_signet() -> Self {
251        Self {
252            id: "signet".into(),
253            unit: "signet".into(),
254            name: "Bitcoin Signet".into(),
255            explorer_api: "https://mempool.space/signet/api".into(),
256        }
257    }
258}
259
260// ---------------------------------------------------------------------------
261// HTTP 402 response + /pay/.info
262// ---------------------------------------------------------------------------
263
264/// HTTP 402 Payment Required response body.
265pub fn payment_required_body(balance: u64, cost: u64) -> serde_json::Value {
266    serde_json::json!({
267        "error": "Payment Required",
268        "balance": balance,
269        "cost": cost,
270        "unit": "sat",
271        "deposit": "/pay/.deposit",
272        "balance_endpoint": "/pay/.balance",
273        "spec": "https://webledgers.org"
274    })
275}
276
277/// GET /pay/.info response body.
278pub fn pay_info(config: &PayConfig) -> serde_json::Value {
279    let mut info = serde_json::json!({
280        "cost": config.cost_sats,
281        "unit": "sat",
282        "deposit": "/pay/.deposit",
283        "balance": "/pay/.balance"
284    });
285    if let Some(ref token) = config.token {
286        info["token"] = serde_json::json!({
287            "ticker": token.ticker,
288            "rate": token.rate,
289            "buy": "/pay/.buy",
290            "withdraw": "/pay/.withdraw",
291            "supply": token.supply,
292            "issuer": token.issuer
293        });
294    }
295    if !config.chains.is_empty() {
296        info["chains"] = serde_json::json!(config
297            .chains
298            .iter()
299            .map(|c| serde_json::json!({
300                "id": c.id,
301                "unit": c.unit,
302                "name": c.name
303            }))
304            .collect::<Vec<_>>());
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(
320    balance: u64,
321    cost: u64,
322    currency: &str,
323) -> Vec<(&'static str, String)> {
324    vec![
325        ("X-Balance", balance.to_string()),
326        ("X-Cost", cost.to_string()),
327        ("X-Pay-Currency", currency.to_string()),
328    ]
329}
330
331/// GET /pay/.balance response body.
332pub fn balance_response(did: &str, balance: u64, cost: u64) -> serde_json::Value {
333    serde_json::json!({
334        "did": did,
335        "balance": balance,
336        "cost": cost,
337        "unit": "sat"
338    })
339}
340
341/// Web Ledgers discovery document.
342pub fn webledgers_discovery(pod_base: &str) -> serde_json::Value {
343    serde_json::json!({
344        "@context": "https://w3id.org/webledgers",
345        "type": "WebLedger",
346        "name": "Pod Credits",
347        "description": "Satoshi-denominated micropayments for pod resource access",
348        "defaultCurrency": "satoshi",
349        "endpoints": {
350            "info": "/pay/.info",
351            "balance": "/pay/.balance",
352            "deposit": "/pay/.deposit",
353            "ledger": "/.well-known/webledgers/webledgers.json"
354        },
355        "verification": {
356            "method": "mempool-api",
357            "url": "https://mempool.space/api/"
358        },
359        "server": pod_base
360    })
361}
362
363// ---------------------------------------------------------------------------
364// TXO deposit parsing
365// ---------------------------------------------------------------------------
366
367/// Parsed TXO deposit URI.
368#[derive(Debug, Clone)]
369pub struct TxoDeposit {
370    pub chain: Option<String>,
371    pub txid: String,
372    pub vout: u32,
373}
374
375/// Parse a TXO URI: `txid:vout`, `txo:chain:txid:vout`, or `bitcoin:txid:vout`.
376pub fn parse_txo_uri(input: &str) -> Result<TxoDeposit, PaymentError> {
377    let trimmed = input.trim();
378
379    // Try `txo:<chain>:<txid>:<vout>` first
380    if let Some(rest) = trimmed.strip_prefix("txo:") {
381        let parts: Vec<&str> = rest.splitn(3, ':').collect();
382        if parts.len() == 3 {
383            let chain = parts[0].to_lowercase();
384            let txid = parts[1];
385            let vout: u32 = parts[2]
386                .parse()
387                .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
388            validate_txid(txid)?;
389            return Ok(TxoDeposit {
390                chain: Some(chain),
391                txid: txid.to_string(),
392                vout,
393            });
394        }
395    }
396
397    // Try `bitcoin:txid:vout`
398    let cleaned = trimmed.strip_prefix("bitcoin:").unwrap_or(trimmed);
399    let parts: Vec<&str> = cleaned.split(':').collect();
400    if parts.len() != 2 {
401        return Err(PaymentError::InvalidTxo("expected txid:vout format".into()));
402    }
403    let txid = parts[0];
404    let vout: u32 = parts[1]
405        .parse()
406        .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
407    validate_txid(txid)?;
408    Ok(TxoDeposit {
409        chain: None,
410        txid: txid.to_string(),
411        vout,
412    })
413}
414
415fn validate_txid(txid: &str) -> Result<(), PaymentError> {
416    if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
417        return Err(PaymentError::InvalidTxo("txid must be 64 hex chars".into()));
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::{verify_state_link, Mrc20Op, Mrc20State};
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}