Skip to main content

solid_pod_rs/
payments.rs

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