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 fixed fields a ledger is born with (solidpayorg/teller `newLedger`).
103///
104/// A ledger's identity is `sha256(JCS(genesis))`, never a hash of the moving
105/// balances: two documents with the same genesis are the same ledger at
106/// different moments, and a document whose `hash` is not the hash of its
107/// genesis is not that ledger ([`WebLedger::check_genesis`]).
108///
109/// ```
110/// use solid_pod_rs::payments::LedgerGenesis;
111///
112/// let operator = format!("did:nostr:{}", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");
113/// let genesis = LedgerGenesis::new(&operator, "Pod Credits", "satoshi", 1_759_300_000, 1).unwrap();
114/// // The value solidpayorg/teller 7c00cea computes for the same genesis.
115/// assert_eq!(
116///     genesis.hash(),
117///     "6edef538d26a824df5cad902ffd2d767639dddec256af206f4fafb2777cb78fe"
118/// );
119/// ```
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct LedgerGenesis {
122    /// The operator's account, `did:nostr:<64 hex>`.
123    pub operator: String,
124    /// The ledger's name, 1 to 80 characters.
125    pub name: String,
126    /// The ledger's unit (its `defaultCurrency`).
127    pub currency: String,
128    /// Creation time, Unix seconds.
129    pub created: u64,
130    /// Confirmations a deposit needs before it is credited.
131    pub confirmations: u64,
132}
133
134impl LedgerGenesis {
135    /// Build a genesis, normalising `operator` to `did:nostr:<x>` and
136    /// refusing an empty name or one longer than 80 characters (teller
137    /// `newLedger`).
138    pub fn new(
139        operator: &str,
140        name: &str,
141        currency: &str,
142        created: u64,
143        confirmations: u64,
144    ) -> Result<Self, PaymentError> {
145        let genesis = Self {
146            operator: account_of(operator)?,
147            name: name.to_string(),
148            currency: currency.to_string(),
149            created,
150            confirmations,
151        };
152        genesis.validate()?;
153        Ok(genesis)
154    }
155
156    fn validate(&self) -> Result<(), PaymentError> {
157        let chars = self.name.chars().count();
158        if chars == 0 || chars > 80 {
159            return Err(PaymentError::InvalidState(
160                "a ledger has a name of up to 80 characters".into(),
161            ));
162        }
163        Ok(())
164    }
165
166    /// The ledger hash: lowercase hex `sha256(JCS(genesis))`.
167    pub fn hash(&self) -> String {
168        let value = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
169        crate::mrc20::sha256_hex(&crate::mrc20::jcs(&value))
170    }
171}
172
173/// A deposit's receipt: the outpoint that paid it (teller `credit`).
174///
175/// An outpoint credits its account once; [`WebLedger::credit_by_outpoint`]
176/// refuses a second credit for an outpoint already recorded here.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct DepositReceipt {
179    /// `<txid>:<vout>`, lowercase hex txid.
180    pub outpoint: String,
181    /// The credited account, `did:nostr:<64 hex>`.
182    pub account: String,
183    /// The credited amount in `currency` units.
184    pub value: u64,
185    /// Block height of the paying output, when known.
186    #[serde(default)]
187    pub height: Option<u64>,
188    /// Unix seconds at which the credit was applied.
189    pub at: u64,
190    /// The unit credited when it is not the ledger's own (an MRC20 ticker).
191    /// Absent for the ledger's default currency, as teller writes it.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub currency: Option<String>,
194}
195
196/// A payout's receipt: the debit a withdrawal made (teller `debit`).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct PayoutReceipt {
199    /// The payout id the debit is applied once by (here, its txid).
200    pub id: String,
201    /// The debited account, `did:nostr:<64 hex>`.
202    pub account: String,
203    /// The debited amount in satoshis.
204    pub value: u64,
205    /// Where the payout went, when recorded.
206    #[serde(default)]
207    pub to: Option<String>,
208    /// The payout transaction id.
209    #[serde(default)]
210    pub txid: Option<String>,
211    /// Unix seconds at which the debit was applied.
212    pub at: u64,
213}
214
215/// Whether a receipt-keyed ledger operation changed the ledger.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum ReceiptOutcome {
218    /// The operation was applied and its receipt recorded.
219    Applied,
220    /// The receipt was already recorded; nothing changed (teller
221    /// `{applied: false}`).
222    AlreadyApplied,
223}
224
225/// A spend taken by [`WebLedger::charge`]; [`WebLedger::refund`] gives it
226/// back. It cannot be built, cloned or deserialised outside this crate, so a
227/// refund only ever returns an amount a charge actually took.
228#[derive(Debug, PartialEq, Eq)]
229pub struct Charge {
230    account: String,
231    amount: u64,
232}
233
234impl Charge {
235    /// The account charged.
236    pub fn account(&self) -> &str {
237        &self.account
238    }
239
240    /// The amount charged, in satoshis.
241    pub fn amount(&self) -> u64 {
242        self.amount
243    }
244}
245
246/// The full Web Ledger document at `/.well-known/webledgers/webledgers.json`.
247///
248/// Balances change only through receipt-keyed operations:
249/// [`credit_by_outpoint`](Self::credit_by_outpoint) (the outpoint is the
250/// receipt) and [`debit_by_payout`](Self::debit_by_payout) (the payout id is
251/// the receipt), as in solidpayorg/teller. [`reverse_payout`](Self::reverse_payout),
252/// [`charge`](Self::charge) and [`refund`](Self::refund) serve the pod's
253/// broadcast recovery and access fees; none of them can raise a balance
254/// beyond what a recorded deposit put there.
255///
256/// A ledger built with [`with_genesis`](Self::with_genesis) carries teller's
257/// identity, `id = urn:webledgers:<sha256(JCS(genesis))>`; documents written
258/// before the genesis existed still load, without one.
259///
260/// The receipt-less mutators are crate-private. Outside this crate none of
261/// these compile:
262///
263/// ```compile_fail
264/// let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
265/// ledger.credit("did:nostr:aaaa", 1_000);
266/// ```
267///
268/// ```compile_fail
269/// let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
270/// let _ = ledger.debit("did:nostr:aaaa", 1);
271/// ```
272///
273/// ```compile_fail
274/// let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
275/// ledger.credit_currency("did:nostr:aaaa", "PODS", 1_000);
276/// ```
277///
278/// ```compile_fail
279/// let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
280/// ledger.entries.clear();
281/// ```
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct WebLedger {
284    #[serde(rename = "@context")]
285    pub context: String,
286    #[serde(rename = "type")]
287    pub ledger_type: String,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub(crate) id: Option<String>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub(crate) hash: Option<String>,
292    pub name: String,
293    #[serde(default)]
294    pub description: String,
295    #[serde(rename = "defaultCurrency")]
296    pub default_currency: String,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub(crate) genesis: Option<LedgerGenesis>,
299    #[serde(default)]
300    pub created: u64,
301    pub updated: u64,
302    pub(crate) entries: Vec<LedgerEntry>,
303    #[serde(default)]
304    pub(crate) deposits: Vec<DepositReceipt>,
305    #[serde(default)]
306    pub(crate) applied: Vec<String>,
307    #[serde(default)]
308    pub(crate) payouts: Vec<PayoutReceipt>,
309}
310
311impl WebLedger {
312    /// A new, empty ledger without a genesis (the pre-teller shape).
313    pub fn new(name: &str) -> Self {
314        let now = now_secs();
315        Self {
316            context: "https://w3id.org/webledgers".into(),
317            ledger_type: "WebLedger".into(),
318            id: None,
319            hash: None,
320            name: name.into(),
321            description: "Paid API balance ledger".into(),
322            default_currency: "satoshi".into(),
323            genesis: None,
324            created: now,
325            updated: now,
326            entries: Vec::new(),
327            deposits: Vec::new(),
328            applied: Vec::new(),
329            payouts: Vec::new(),
330        }
331    }
332
333    /// A new, empty ledger born with `genesis` (teller `newLedger`): its
334    /// `hash` is `sha256(JCS(genesis))`, its `id` `urn:webledgers:<hash>`,
335    /// its name and `defaultCurrency` those of the genesis.
336    pub fn with_genesis(genesis: LedgerGenesis) -> Result<Self, PaymentError> {
337        genesis.validate()?;
338        let hash = genesis.hash();
339        let mut ledger = Self::new(&genesis.name);
340        ledger.id = Some(format!("urn:webledgers:{hash}"));
341        ledger.hash = Some(hash);
342        ledger.default_currency = genesis.currency.clone();
343        ledger.created = genesis.created;
344        ledger.updated = genesis.created;
345        ledger.genesis = Some(genesis);
346        Ok(ledger)
347    }
348
349    /// The ledger's `id`, when it has one.
350    pub fn id(&self) -> Option<&str> {
351        self.id.as_deref()
352    }
353
354    /// The ledger hash recorded in the document, when it has a genesis.
355    pub fn hash(&self) -> Option<&str> {
356        self.hash.as_deref()
357    }
358
359    /// The genesis the ledger was born with, if any.
360    pub fn genesis(&self) -> Option<&LedgerGenesis> {
361        self.genesis.as_ref()
362    }
363
364    /// The ledger hash recomputed from the genesis (teller `ledgerHash`).
365    pub fn ledger_hash(&self) -> Option<String> {
366        self.genesis.as_ref().map(LedgerGenesis::hash)
367    }
368
369    /// Teller `checkLedger`: a document with a genesis must record the hash
370    /// of that genesis. Documents without a genesis (written before it
371    /// existed) pass; a genesis without a matching hash does not.
372    pub fn check_genesis(&self) -> Result<(), PaymentError> {
373        let Some(genesis) = &self.genesis else {
374            return Ok(());
375        };
376        if self.hash.as_deref() != Some(genesis.hash().as_str()) {
377            return Err(PaymentError::InvalidState(
378                "the ledger's hash is not the hash of its genesis".into(),
379            ));
380        }
381        Ok(())
382    }
383
384    /// The balance entries, one per account.
385    pub fn entries(&self) -> &[LedgerEntry] {
386        &self.entries
387    }
388
389    /// Every deposit credited, by outpoint.
390    pub fn deposits(&self) -> &[DepositReceipt] {
391        &self.deposits
392    }
393
394    /// Every payout debited, by payout id.
395    pub fn payouts(&self) -> &[PayoutReceipt] {
396        &self.payouts
397    }
398
399    /// Whether `outpoint` (`<txid>:<vout>`) has already credited an account.
400    pub fn has_deposit(&self, txid: &str, vout: u32) -> bool {
401        let key = format!("{txid}:{vout}");
402        self.deposits.iter().any(|d| d.outpoint == key)
403    }
404
405    /// Whether `currency` names this ledger's satoshi balance: `satoshi`,
406    /// `sat`, or the ledger's own `defaultCurrency`.
407    pub fn is_sats_unit(&self, currency: &str) -> bool {
408        currency == "satoshi" || currency == "sat" || currency == self.default_currency
409    }
410
411    /// The satoshi balance of `did` (exact key match).
412    pub fn get_balance(&self, did: &str) -> u64 {
413        self.entries
414            .iter()
415            .find(|e| e.url == did)
416            .map(|e| e.amount.sats())
417            .unwrap_or(0)
418    }
419
420    /// Credit a deposit seen on-chain, once: the outpoint is the receipt
421    /// (solidpayorg/teller `credit`, `lib/teller.mjs:40-44`).
422    ///
423    /// `currency` is the unit credited: the ledger's satoshi balance when
424    /// [`is_sats_unit`](Self::is_sats_unit) holds, otherwise that unit's own
425    /// balance (an MRC20 deposit credits its ticker, never sats). `account`
426    /// is normalised as teller reads it (`did:nostr:<x>`, a bare x, or a
427    /// Multikey's x). The txid must be 64 lowercase hex digits and `value` a
428    /// whole number no greater than 21 million coins in satoshis.
429    ///
430    /// A second credit for an outpoint already recorded changes nothing and
431    /// returns [`ReceiptOutcome::AlreadyApplied`], whatever its account or
432    /// unit.
433    ///
434    /// ```
435    /// use solid_pod_rs::payments::{ReceiptOutcome, WebLedger};
436    ///
437    /// let alice = format!("did:nostr:{}", "a".repeat(64));
438    /// let txid = "ab".repeat(32);
439    /// let mut ledger = WebLedger::new("Pod Credits");
440    /// assert_eq!(
441    ///     ledger.credit_by_outpoint(&alice, "satoshi", &txid, 0, 50_000).unwrap(),
442    ///     ReceiptOutcome::Applied
443    /// );
444    /// assert_eq!(
445    ///     ledger.credit_by_outpoint(&alice, "satoshi", &txid, 0, 50_000).unwrap(),
446    ///     ReceiptOutcome::AlreadyApplied
447    /// );
448    /// assert_eq!(ledger.get_balance(&alice), 50_000);
449    /// ```
450    pub fn credit_by_outpoint(
451        &mut self,
452        account: &str,
453        currency: &str,
454        txid: &str,
455        vout: u32,
456        value: u64,
457    ) -> Result<ReceiptOutcome, PaymentError> {
458        let account = account_of(account)?;
459        if txid.len() != 64 || !txid.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
460            return Err(PaymentError::InvalidTxo(
461                "a deposit is an outpoint txid:vout".into(),
462            ));
463        }
464        if currency.is_empty() {
465            return Err(PaymentError::InvalidState(
466                "a deposit names its unit".into(),
467            ));
468        }
469        check_amount(value)?;
470        if self.has_deposit(txid, vout) {
471            return Ok(ReceiptOutcome::AlreadyApplied);
472        }
473        let sats = self.is_sats_unit(currency);
474        if sats {
475            self.credit(&account, value);
476        } else {
477            self.credit_currency(&account, currency, value);
478        }
479        let now = now_secs();
480        self.deposits.push(DepositReceipt {
481            outpoint: format!("{txid}:{vout}"),
482            account,
483            value,
484            height: None,
485            at: now,
486            currency: (!sats).then(|| currency.to_string()),
487        });
488        self.touch(now);
489        Ok(ReceiptOutcome::Applied)
490    }
491
492    /// Debit a withdrawal of `amount` satoshis, once: the payout's txid is
493    /// the receipt (solidpayorg/teller `debit`, `lib/teller.mjs:53-58`).
494    ///
495    /// Fails with [`PaymentError::InsufficientBalance`] when the account
496    /// holds less than `amount`. A second debit with a payout id already
497    /// applied changes nothing and returns
498    /// [`ReceiptOutcome::AlreadyApplied`]. Unlike teller, no 546-sat
499    /// minimum applies here: the pod's payouts include token transfers
500    /// whose satoshi cost is not itself an on-chain output.
501    pub fn debit_by_payout(
502        &mut self,
503        account: &str,
504        amount: u64,
505        txid: &str,
506    ) -> Result<ReceiptOutcome, PaymentError> {
507        let account = account_of(account)?;
508        if txid.len() != 64 || !txid.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
509            return Err(PaymentError::InvalidTxo(
510                "a payout is identified by its 64-hex txid".into(),
511            ));
512        }
513        check_amount(amount)?;
514        if self.applied.iter().any(|id| id == txid) {
515            return Ok(ReceiptOutcome::AlreadyApplied);
516        }
517        self.debit(&account, amount)?;
518        let now = now_secs();
519        self.applied.push(txid.to_string());
520        self.payouts.push(PayoutReceipt {
521            id: txid.to_string(),
522            account,
523            value: amount,
524            to: None,
525            txid: Some(txid.to_string()),
526            at: now,
527        });
528        self.touch(now);
529        Ok(ReceiptOutcome::Applied)
530    }
531
532    /// Undo a recorded payout whose transaction never reached the chain:
533    /// its amount goes back to its account and its receipt is removed.
534    /// Returns the amount restored, or `None` when no payout with that id is
535    /// recorded (nothing changes).
536    pub fn reverse_payout(&mut self, txid: &str) -> Option<u64> {
537        let index = self.payouts.iter().position(|p| p.id == txid)?;
538        let payout = self.payouts.remove(index);
539        self.applied.retain(|id| id != txid);
540        self.credit(&payout.account, payout.value);
541        Some(payout.value)
542    }
543
544    /// Spend `amount` satoshis from `did` for an access fee (a paid request
545    /// or an anchor). Fails closed with
546    /// [`PaymentError::InsufficientBalance`]; the returned [`Charge`] is the
547    /// only way to [`refund`](Self::refund) it.
548    pub fn charge(&mut self, did: &str, amount: u64) -> Result<Charge, PaymentError> {
549        self.debit(did, amount)?;
550        Ok(Charge {
551            account: did.to_string(),
552            amount,
553        })
554    }
555
556    /// Give back a [`Charge`] whose service was not delivered.
557    pub fn refund(&mut self, charge: Charge) {
558        self.credit(&charge.account, charge.amount);
559    }
560
561    fn touch(&mut self, now: u64) {
562        self.updated = self.updated.max(now);
563    }
564
565    pub(crate) fn credit(&mut self, did: &str, amount: u64) {
566        self.updated = now_secs();
567        if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
568            let current = entry.amount.sats();
569            entry.amount.set_sats(current.saturating_add(amount));
570        } else {
571            self.entries.push(LedgerEntry {
572                entry_type: "Entry".into(),
573                url: did.into(),
574                amount: LedgerAmount::Simple(amount.to_string()),
575            });
576        }
577    }
578
579    pub(crate) fn debit(&mut self, did: &str, amount: u64) -> Result<u64, PaymentError> {
580        self.updated = now_secs();
581        let entry = self.entries.iter_mut().find(|e| e.url == did).ok_or(
582            PaymentError::InsufficientBalance {
583                balance: 0,
584                cost: amount,
585            },
586        )?;
587        let current = entry.amount.sats();
588        if current < amount {
589            return Err(PaymentError::InsufficientBalance {
590                balance: current,
591                cost: amount,
592            });
593        }
594        entry.amount.set_sats(current - amount);
595        Ok(current - amount)
596    }
597}
598
599/// Teller `accountOf`: a `did:nostr:<64 hex>` from a did, a bare x, or a
600/// Multikey's x (`fe70102`/`fe70103` prefix), lowercased.
601pub fn account_of(id: &str) -> Result<String, PaymentError> {
602    let s = id.trim().to_ascii_lowercase();
603    let is_x = |x: &str| x.len() == 64 && x.bytes().all(|b| b.is_ascii_hexdigit());
604    let x = s
605        .strip_prefix("did:nostr:")
606        .filter(|x| is_x(x))
607        .or_else(|| Some(s.as_str()).filter(|x| is_x(x)))
608        .or_else(|| {
609            s.strip_prefix("fe70102")
610                .or_else(|| s.strip_prefix("fe70103"))
611                .filter(|x| is_x(x))
612        })
613        .ok_or_else(|| {
614            PaymentError::InvalidState(
615                "an account is a did:nostr identifier (did:nostr:<64 hex>)".into(),
616            )
617        })?;
618    Ok(format!("did:nostr:{x}"))
619}
620
621/// Teller `sats`: a whole amount never beyond 21 million coins in satoshis.
622fn check_amount(value: u64) -> Result<(), PaymentError> {
623    const MAX: u64 = 2_100_000_000_000_000;
624    if value > MAX {
625        return Err(PaymentError::InvalidState(
626            "an amount is a whole number of satoshis".into(),
627        ));
628    }
629    Ok(())
630}
631
632// ---------------------------------------------------------------------------
633// Payment configuration
634// ---------------------------------------------------------------------------
635
636/// Pod payment configuration (mirrors JSS `--pay-*` flags).
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct PayConfig {
639    pub enabled: bool,
640    pub cost_sats: u64,
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub token: Option<TokenConfig>,
643    #[serde(default, skip_serializing_if = "Vec::is_empty")]
644    pub chains: Vec<ChainConfig>,
645}
646
647impl Default for PayConfig {
648    fn default() -> Self {
649        Self {
650            enabled: false,
651            cost_sats: 1,
652            token: None,
653            chains: Vec::new(),
654        }
655    }
656}
657
658/// MRC20 token configuration.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct TokenConfig {
661    pub ticker: String,
662    pub rate: u64,
663    pub supply: u64,
664    /// The pod's issuer key (66-hex compressed): its token trail's base key
665    /// and the key deposit addresses derive from.
666    pub issuer: String,
667    /// Further trail issuer keys whose `ticker` trails this pod accepts as
668    /// deposits, besides `issuer` itself. Empty by default.
669    #[serde(default, skip_serializing_if = "Vec::is_empty")]
670    pub accepted_issuers: Vec<String>,
671}
672
673impl TokenConfig {
674    /// Whether an MRC20 trail anchored on `pubkey` is this pod's token: the
675    /// configured `issuer` or one of `accepted_issuers` (hex compared
676    /// case-insensitively, keys never empty).
677    pub fn accepts_issuer(&self, pubkey: &str) -> bool {
678        let pubkey = pubkey.trim();
679        !pubkey.is_empty()
680            && std::iter::once(&self.issuer)
681                .chain(self.accepted_issuers.iter())
682                .any(|k| !k.is_empty() && k.trim().eq_ignore_ascii_case(pubkey))
683    }
684}
685
686/// Chain configuration for multi-chain deposits.
687#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct ChainConfig {
689    pub id: String,
690    pub unit: String,
691    pub name: String,
692    pub explorer_api: String,
693}
694
695impl ChainConfig {
696    pub fn bitcoin_mainnet() -> Self {
697        Self {
698            id: "btc".into(),
699            unit: "sat".into(),
700            name: "Bitcoin".into(),
701            explorer_api: "https://mempool.space/api".into(),
702        }
703    }
704
705    pub fn bitcoin_testnet3() -> Self {
706        Self {
707            id: "tbtc3".into(),
708            unit: "tbtc3".into(),
709            name: "Bitcoin Testnet3".into(),
710            explorer_api: "https://mempool.space/testnet/api".into(),
711        }
712    }
713
714    pub fn bitcoin_testnet4() -> Self {
715        Self {
716            id: "tbtc4".into(),
717            unit: "tbtc4".into(),
718            name: "Bitcoin Testnet4".into(),
719            explorer_api: "https://mempool.space/testnet4/api".into(),
720        }
721    }
722
723    pub fn bitcoin_signet() -> Self {
724        Self {
725            id: "signet".into(),
726            unit: "signet".into(),
727            name: "Bitcoin Signet".into(),
728            explorer_api: "https://mempool.space/signet/api".into(),
729        }
730    }
731}
732
733// ---------------------------------------------------------------------------
734// HTTP 402 response + /pay/.info
735// ---------------------------------------------------------------------------
736
737/// HTTP 402 Payment Required response body.
738pub fn payment_required_body(balance: u64, cost: u64) -> serde_json::Value {
739    serde_json::json!({
740        "error": "Payment Required",
741        "balance": balance,
742        "cost": cost,
743        "unit": "sat",
744        "deposit": "/pay/.deposit",
745        "balance_endpoint": "/pay/.balance",
746        "spec": "https://webledgers.org"
747    })
748}
749
750/// GET /pay/.info response body.
751pub fn pay_info(config: &PayConfig) -> serde_json::Value {
752    let mut info = serde_json::json!({
753        "cost": config.cost_sats,
754        "unit": "sat",
755        "deposit": "/pay/.deposit",
756        "balance": "/pay/.balance"
757    });
758    if let Some(ref token) = config.token {
759        info["token"] = serde_json::json!({
760            "ticker": token.ticker,
761            "rate": token.rate,
762            "buy": "/pay/.buy",
763            "withdraw": "/pay/.withdraw",
764            "supply": token.supply,
765            "issuer": token.issuer
766        });
767    }
768    if !config.chains.is_empty() {
769        info["chains"] = serde_json::json!(config
770            .chains
771            .iter()
772            .map(|c| serde_json::json!({
773                "id": c.id,
774                "unit": c.unit,
775                "name": c.name
776            }))
777            .collect::<Vec<_>>());
778        info["pool"] = serde_json::json!("/pay/.pool");
779    }
780    info
781}
782
783/// Response headers attached to successful paid requests.
784///
785/// JSS parity: on every response that consumed balance, the server adds
786/// `X-Balance`, `X-Cost`, and `X-Pay-Currency` headers so the client
787/// can track spend without a separate `/pay/.balance` call.
788///
789/// Returns a `Vec<(header_name, header_value)>` that the transport layer
790/// appends to the HTTP response. Framework-agnostic — actix-web, axum,
791/// and Worker consumers each adapt these to their header type.
792pub fn payment_response_headers(
793    balance: u64,
794    cost: u64,
795    currency: &str,
796) -> Vec<(&'static str, String)> {
797    vec![
798        ("X-Balance", balance.to_string()),
799        ("X-Cost", cost.to_string()),
800        ("X-Pay-Currency", currency.to_string()),
801    ]
802}
803
804/// GET /pay/.balance response body.
805pub fn balance_response(did: &str, balance: u64, cost: u64) -> serde_json::Value {
806    serde_json::json!({
807        "did": did,
808        "balance": balance,
809        "cost": cost,
810        "unit": "sat"
811    })
812}
813
814/// Web Ledgers discovery document.
815pub fn webledgers_discovery(pod_base: &str) -> serde_json::Value {
816    serde_json::json!({
817        "@context": "https://w3id.org/webledgers",
818        "type": "WebLedger",
819        "name": "Pod Credits",
820        "description": "Satoshi-denominated micropayments for pod resource access",
821        "defaultCurrency": "satoshi",
822        "endpoints": {
823            "info": "/pay/.info",
824            "balance": "/pay/.balance",
825            "deposit": "/pay/.deposit",
826            "ledger": "/.well-known/webledgers/webledgers.json"
827        },
828        "verification": {
829            "method": "mempool-api",
830            "url": "https://mempool.space/api/"
831        },
832        "server": pod_base
833    })
834}
835
836// ---------------------------------------------------------------------------
837// TXO deposit parsing
838// ---------------------------------------------------------------------------
839
840/// Parsed TXO deposit URI.
841#[derive(Debug, Clone)]
842pub struct TxoDeposit {
843    pub chain: Option<String>,
844    pub txid: String,
845    pub vout: u32,
846}
847
848/// Parse a TXO URI: `txid:vout`, `txo:chain:txid:vout`, or `bitcoin:txid:vout`.
849pub fn parse_txo_uri(input: &str) -> Result<TxoDeposit, PaymentError> {
850    let trimmed = input.trim();
851
852    // Try `txo:<chain>:<txid>:<vout>` first
853    if let Some(rest) = trimmed.strip_prefix("txo:") {
854        let parts: Vec<&str> = rest.splitn(3, ':').collect();
855        if parts.len() == 3 {
856            let chain = parts[0].to_lowercase();
857            let txid = parts[1];
858            let vout: u32 = parts[2]
859                .parse()
860                .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
861            validate_txid(txid)?;
862            return Ok(TxoDeposit {
863                chain: Some(chain),
864                txid: txid.to_string(),
865                vout,
866            });
867        }
868    }
869
870    // Try `bitcoin:txid:vout`
871    let cleaned = trimmed.strip_prefix("bitcoin:").unwrap_or(trimmed);
872    let parts: Vec<&str> = cleaned.split(':').collect();
873    if parts.len() != 2 {
874        return Err(PaymentError::InvalidTxo("expected txid:vout format".into()));
875    }
876    let txid = parts[0];
877    let vout: u32 = parts[1]
878        .parse()
879        .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
880    validate_txid(txid)?;
881    Ok(TxoDeposit {
882        chain: None,
883        txid: txid.to_string(),
884        vout,
885    })
886}
887
888fn validate_txid(txid: &str) -> Result<(), PaymentError> {
889    if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
890        return Err(PaymentError::InvalidTxo("txid must be 64 hex chars".into()));
891    }
892    Ok(())
893}
894
895// ---------------------------------------------------------------------------
896// MRC20 state chain types — re-exported from `crate::mrc20`.
897// ---------------------------------------------------------------------------
898
899/// Backward-compatibility re-exports. The canonical definitions and full
900/// implementation (JCS, BIP-341, state-chain verification) live in
901/// [`crate::mrc20`]. These re-exports let existing consumers that import
902/// MRC20 types from `payments` continue to compile without changes.
903pub use crate::mrc20::{verify_state_link, Mrc20Op, Mrc20State};
904
905// ---------------------------------------------------------------------------
906// Payment store trait (storage abstraction)
907// ---------------------------------------------------------------------------
908
909/// Abstract payment storage — backends implement this for KV/DO/FS.
910#[async_trait::async_trait(?Send)]
911pub trait PaymentStore: Send + Sync {
912    async fn read_ledger(&self) -> Result<WebLedger, PaymentError>;
913    async fn write_ledger(&self, ledger: &WebLedger) -> Result<(), PaymentError>;
914    async fn check_replay(&self, key: &str) -> Result<bool, PaymentError>;
915    async fn record_replay(&self, key: &str) -> Result<(), PaymentError>;
916}
917
918// ---------------------------------------------------------------------------
919// DID:nostr identity helpers
920// ---------------------------------------------------------------------------
921
922/// Convert a hex pubkey to `did:nostr:<hex>`.
923pub fn pubkey_to_did(pubkey: &str) -> String {
924    format!("did:nostr:{pubkey}")
925}
926
927/// Extract hex pubkey from `did:nostr:<hex>`.
928pub fn did_to_pubkey(did: &str) -> Option<&str> {
929    did.strip_prefix("did:nostr:")
930}
931
932// ---------------------------------------------------------------------------
933// Errors
934// ---------------------------------------------------------------------------
935
936/// Payment-specific errors.
937#[derive(Debug, thiserror::Error)]
938pub enum PaymentError {
939    #[error("insufficient balance: have {balance}, need {cost}")]
940    InsufficientBalance { balance: u64, cost: u64 },
941
942    #[error("invalid TXO: {0}")]
943    InvalidTxo(String),
944
945    #[error("invalid MRC20 state: {0}")]
946    InvalidState(String),
947
948    #[error("replay detected: {0}")]
949    Replay(String),
950
951    #[error("payment store: {0}")]
952    Store(String),
953}
954
955// ---------------------------------------------------------------------------
956// Helpers
957// ---------------------------------------------------------------------------
958
959fn now_secs() -> u64 {
960    #[cfg(target_arch = "wasm32")]
961    {
962        (js_sys::Date::now() / 1000.0) as u64
963    }
964    #[cfg(not(target_arch = "wasm32"))]
965    {
966        std::time::SystemTime::now()
967            .duration_since(std::time::UNIX_EPOCH)
968            .unwrap_or_default()
969            .as_secs()
970    }
971}
972
973// ---------------------------------------------------------------------------
974// Tests
975// ---------------------------------------------------------------------------
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    #[test]
982    fn new_ledger_empty() {
983        let ledger = WebLedger::new("Test");
984        assert!(ledger.entries.is_empty());
985        assert_eq!(ledger.default_currency, "satoshi");
986        assert_eq!(ledger.context, "https://w3id.org/webledgers");
987    }
988
989    #[test]
990    fn credit_creates_entry() {
991        let mut ledger = WebLedger::new("Test");
992        ledger.credit("did:nostr:abc123", 1000);
993        assert_eq!(ledger.get_balance("did:nostr:abc123"), 1000);
994    }
995
996    #[test]
997    fn debit_reduces_balance() {
998        let mut ledger = WebLedger::new("Test");
999        ledger.credit("did:nostr:abc123", 1000);
1000        let remaining = ledger.debit("did:nostr:abc123", 100).unwrap();
1001        assert_eq!(remaining, 900);
1002        assert_eq!(ledger.get_balance("did:nostr:abc123"), 900);
1003    }
1004
1005    #[test]
1006    fn debit_rejects_insufficient() {
1007        let mut ledger = WebLedger::new("Test");
1008        ledger.credit("did:nostr:abc123", 50);
1009        let err = ledger.debit("did:nostr:abc123", 100).unwrap_err();
1010        assert!(matches!(
1011            err,
1012            PaymentError::InsufficientBalance {
1013                balance: 50,
1014                cost: 100
1015            }
1016        ));
1017    }
1018
1019    #[test]
1020    fn debit_rejects_unknown_did() {
1021        let mut ledger = WebLedger::new("Test");
1022        let err = ledger.debit("did:nostr:unknown", 1).unwrap_err();
1023        assert!(matches!(
1024            err,
1025            PaymentError::InsufficientBalance {
1026                balance: 0,
1027                cost: 1
1028            }
1029        ));
1030    }
1031
1032    #[test]
1033    fn credit_accumulates() {
1034        let mut ledger = WebLedger::new("Test");
1035        ledger.credit("did:nostr:abc", 100);
1036        ledger.credit("did:nostr:abc", 200);
1037        assert_eq!(ledger.get_balance("did:nostr:abc"), 300);
1038    }
1039
1040    #[test]
1041    fn agent_agent_payment() {
1042        let mut ledger = WebLedger::new("Test");
1043        let agent_a = "did:nostr:aaaa";
1044        let agent_b = "did:nostr:bbbb";
1045        ledger.credit(agent_a, 500);
1046        ledger.debit(agent_a, 100).unwrap();
1047        ledger.credit(agent_b, 100);
1048        assert_eq!(ledger.get_balance(agent_a), 400);
1049        assert_eq!(ledger.get_balance(agent_b), 100);
1050    }
1051
1052    const ALICE: &str =
1053        "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa";
1054
1055    fn txid(byte: &str) -> String {
1056        byte.repeat(32)
1057    }
1058
1059    #[test]
1060    fn credit_by_outpoint_credits_once() {
1061        let mut ledger = WebLedger::new("Test");
1062        let t = txid("ab");
1063        assert_eq!(
1064            ledger
1065                .credit_by_outpoint(ALICE, "satoshi", &t, 0, 50_000)
1066                .unwrap(),
1067            ReceiptOutcome::Applied
1068        );
1069        assert_eq!(
1070            ledger
1071                .credit_by_outpoint(ALICE, "satoshi", &t, 0, 50_000)
1072                .unwrap(),
1073            ReceiptOutcome::AlreadyApplied
1074        );
1075        // Same outpoint, another account or unit: still the same coin.
1076        let bob = format!("did:nostr:{}", "b".repeat(64));
1077        assert_eq!(
1078            ledger.credit_by_outpoint(&bob, "PODS", &t, 0, 9).unwrap(),
1079            ReceiptOutcome::AlreadyApplied
1080        );
1081        assert_eq!(ledger.get_balance(ALICE), 50_000);
1082        assert_eq!(ledger.get_currency_balance(&bob, "PODS"), 0);
1083        assert_eq!(ledger.deposits().len(), 1);
1084        assert_eq!(ledger.deposits()[0].outpoint, format!("{t}:0"));
1085        assert!(ledger.has_deposit(&t, 0));
1086        assert!(!ledger.has_deposit(&t, 1));
1087        // Another vout of the same transaction is another coin.
1088        assert_eq!(
1089            ledger
1090                .credit_by_outpoint(ALICE, "satoshi", &t, 1, 1)
1091                .unwrap(),
1092            ReceiptOutcome::Applied
1093        );
1094        assert_eq!(ledger.get_balance(ALICE), 50_001);
1095    }
1096
1097    #[test]
1098    fn token_credit_lands_in_its_ticker_never_sats() {
1099        let mut ledger = WebLedger::new("Test");
1100        ledger
1101            .credit_by_outpoint(ALICE, "satoshi", &txid("01"), 0, 777)
1102            .unwrap();
1103        ledger
1104            .credit_by_outpoint(ALICE, "PODS", &txid("02"), 3, 100)
1105            .unwrap();
1106        assert_eq!(ledger.get_balance(ALICE), 777, "sats unchanged");
1107        assert_eq!(ledger.get_currency_balance(ALICE, "PODS"), 100);
1108        assert_eq!(ledger.deposits()[1].currency.as_deref(), Some("PODS"));
1109        assert_eq!(ledger.deposits()[0].currency, None);
1110    }
1111
1112    #[test]
1113    fn credit_by_outpoint_reads_accounts_as_teller_does() {
1114        let x = &ALICE["did:nostr:".len()..];
1115        let mut ledger = WebLedger::new("Test");
1116        ledger
1117            .credit_by_outpoint(&x.to_uppercase(), "satoshi", &txid("03"), 0, 5)
1118            .unwrap();
1119        ledger
1120            .credit_by_outpoint(&format!("fe70102{x}"), "sat", &txid("04"), 0, 5)
1121            .unwrap();
1122        assert_eq!(ledger.get_balance(ALICE), 10);
1123        for bad in ["did:nostr:alice", "npub1x", ""] {
1124            assert!(ledger
1125                .credit_by_outpoint(bad, "satoshi", &txid("05"), 0, 1)
1126                .is_err());
1127        }
1128    }
1129
1130    #[test]
1131    fn credit_by_outpoint_refuses_bad_outpoints_and_amounts() {
1132        let mut ledger = WebLedger::new("Test");
1133        assert!(ledger
1134            .credit_by_outpoint(ALICE, "satoshi", &"AB".repeat(32), 0, 1)
1135            .is_err());
1136        assert!(ledger
1137            .credit_by_outpoint(ALICE, "satoshi", "abcd", 0, 1)
1138            .is_err());
1139        assert!(ledger
1140            .credit_by_outpoint(ALICE, "satoshi", &txid("ab"), 0, 2_100_000_000_000_001)
1141            .is_err());
1142        assert!(ledger
1143            .credit_by_outpoint(ALICE, "", &txid("ab"), 0, 1)
1144            .is_err());
1145        assert!(ledger.entries().is_empty() && ledger.deposits().is_empty());
1146    }
1147
1148    #[test]
1149    fn debit_by_payout_applies_once_and_reverses() {
1150        let mut ledger = WebLedger::new("Test");
1151        ledger
1152            .credit_by_outpoint(ALICE, "satoshi", &txid("ab"), 0, 1_000)
1153            .unwrap();
1154        let payout = txid("cd");
1155        assert_eq!(
1156            ledger.debit_by_payout(ALICE, 400, &payout).unwrap(),
1157            ReceiptOutcome::Applied
1158        );
1159        assert_eq!(
1160            ledger.debit_by_payout(ALICE, 400, &payout).unwrap(),
1161            ReceiptOutcome::AlreadyApplied
1162        );
1163        assert_eq!(ledger.get_balance(ALICE), 600);
1164        assert!(matches!(
1165            ledger.debit_by_payout(ALICE, 601, &txid("ce")),
1166            Err(PaymentError::InsufficientBalance {
1167                balance: 600,
1168                cost: 601
1169            })
1170        ));
1171        assert!(ledger.debit_by_payout(ALICE, 1, "not-a-txid").is_err());
1172        assert_eq!(ledger.payouts().len(), 1);
1173
1174        assert_eq!(ledger.reverse_payout(&payout), Some(400));
1175        assert_eq!(ledger.reverse_payout(&payout), None, "reversed once");
1176        assert_eq!(ledger.get_balance(ALICE), 1_000);
1177        assert!(ledger.payouts().is_empty());
1178        assert_eq!(ledger.reverse_payout(&txid("ef")), None);
1179        assert_eq!(ledger.get_balance(ALICE), 1_000, "no receipt, no credit");
1180    }
1181
1182    #[test]
1183    fn charge_and_refund() {
1184        let mut ledger = WebLedger::new("Test");
1185        ledger
1186            .credit_by_outpoint(ALICE, "satoshi", &txid("ab"), 0, 100)
1187            .unwrap();
1188        let charge = ledger.charge(ALICE, 30).unwrap();
1189        assert_eq!((charge.account(), charge.amount()), (ALICE, 30));
1190        assert_eq!(ledger.get_balance(ALICE), 70);
1191        ledger.refund(charge);
1192        assert_eq!(ledger.get_balance(ALICE), 100);
1193        assert!(matches!(
1194            ledger.charge(ALICE, 101),
1195            Err(PaymentError::InsufficientBalance {
1196                balance: 100,
1197                cost: 101
1198            })
1199        ));
1200    }
1201
1202    #[test]
1203    fn genesis_identity() {
1204        let genesis =
1205            LedgerGenesis::new(ALICE, "Pod Credits", "satoshi", 1_759_300_000, 1).unwrap();
1206        let ledger = WebLedger::with_genesis(genesis.clone()).unwrap();
1207        assert_eq!(ledger.hash(), Some(genesis.hash().as_str()));
1208        assert_eq!(
1209            ledger.id().unwrap(),
1210            format!("urn:webledgers:{}", genesis.hash())
1211        );
1212        ledger.check_genesis().unwrap();
1213        assert!(WebLedger::new("Legacy").check_genesis().is_ok());
1214        assert!(LedgerGenesis::new(ALICE, "", "satoshi", 0, 1).is_err());
1215        assert!(LedgerGenesis::new(ALICE, &"n".repeat(81), "satoshi", 0, 1).is_err());
1216        assert!(LedgerGenesis::new("did:nostr:alice", "x", "satoshi", 0, 1).is_err());
1217
1218        // A legacy document (no genesis, no receipts) still loads.
1219        let legacy = r#"{"@context":"https://w3id.org/webledgers","type":"WebLedger","name":"Pod Credits","description":"d","defaultCurrency":"satoshi","created":1,"updated":2,"entries":[{"type":"Entry","url":"did:nostr:x","amount":"5"}]}"#;
1220        let ledger: WebLedger = serde_json::from_str(legacy).unwrap();
1221        assert_eq!(ledger.get_balance("did:nostr:x"), 5);
1222        assert!(ledger.genesis().is_none() && ledger.check_genesis().is_ok());
1223    }
1224
1225    #[test]
1226    fn token_config_accepts_its_issuers_only() {
1227        let mut token = TokenConfig {
1228            ticker: "PODS".into(),
1229            rate: 1,
1230            supply: 1,
1231            issuer: "02AA".into(),
1232            accepted_issuers: Vec::new(),
1233        };
1234        assert!(token.accepts_issuer("02aa"));
1235        assert!(!token.accepts_issuer("02bb"));
1236        assert!(!token.accepts_issuer(""));
1237        token.accepted_issuers.push("02bb".into());
1238        assert!(token.accepts_issuer("02BB"));
1239        token.issuer.clear();
1240        token.accepted_issuers.clear();
1241        assert!(!token.accepts_issuer(""));
1242    }
1243
1244    #[test]
1245    fn parse_txo_bare() {
1246        let txid = "a".repeat(64);
1247        let uri = format!("{txid}:0");
1248        let txo = parse_txo_uri(&uri).unwrap();
1249        assert!(txo.chain.is_none());
1250        assert_eq!(txo.txid, txid);
1251        assert_eq!(txo.vout, 0);
1252    }
1253
1254    #[test]
1255    fn parse_txo_with_chain() {
1256        let txid = "b".repeat(64);
1257        let uri = format!("txo:tbtc4:{txid}:1");
1258        let txo = parse_txo_uri(&uri).unwrap();
1259        assert_eq!(txo.chain.as_deref(), Some("tbtc4"));
1260        assert_eq!(txo.txid, txid);
1261        assert_eq!(txo.vout, 1);
1262    }
1263
1264    #[test]
1265    fn parse_txo_bitcoin_prefix() {
1266        let txid = "c".repeat(64);
1267        let uri = format!("bitcoin:{txid}:2");
1268        let txo = parse_txo_uri(&uri).unwrap();
1269        assert!(txo.chain.is_none());
1270        assert_eq!(txo.vout, 2);
1271    }
1272
1273    #[test]
1274    fn parse_txo_rejects_short_txid() {
1275        assert!(parse_txo_uri("abc123:0").is_err());
1276    }
1277
1278    #[test]
1279    fn pay_info_basic() {
1280        let config = PayConfig::default();
1281        let info = pay_info(&config);
1282        assert_eq!(info["cost"], 1);
1283        assert_eq!(info["unit"], "sat");
1284        assert!(info.get("token").is_none());
1285    }
1286
1287    #[test]
1288    fn pay_info_with_token() {
1289        let config = PayConfig {
1290            enabled: true,
1291            cost_sats: 2,
1292            token: Some(TokenConfig {
1293                ticker: "PODS".into(),
1294                rate: 10,
1295                supply: 10000,
1296                issuer: "025e60b6".into(),
1297                accepted_issuers: Vec::new(),
1298            }),
1299            chains: vec![ChainConfig::bitcoin_testnet4()],
1300        };
1301        let info = pay_info(&config);
1302        assert_eq!(info["token"]["ticker"], "PODS");
1303        assert!(info["chains"].as_array().is_some());
1304    }
1305
1306    #[test]
1307    fn ledger_serialization_roundtrip() {
1308        let mut ledger = WebLedger::new("Test");
1309        ledger.credit("did:nostr:abc", 42);
1310        let json = serde_json::to_string(&ledger).unwrap();
1311        let parsed: WebLedger = serde_json::from_str(&json).unwrap();
1312        assert_eq!(parsed.get_balance("did:nostr:abc"), 42);
1313    }
1314
1315    #[test]
1316    fn pubkey_did_roundtrip() {
1317        let pk = "abc123def456";
1318        let did = pubkey_to_did(pk);
1319        assert_eq!(did, "did:nostr:abc123def456");
1320        assert_eq!(did_to_pubkey(&did), Some(pk));
1321    }
1322
1323    #[test]
1324    fn multi_currency_balance() {
1325        let entry = LedgerEntry {
1326            entry_type: "Entry".into(),
1327            url: "did:nostr:abc".into(),
1328            amount: LedgerAmount::Multi(vec![
1329                CurrencyAmount {
1330                    currency: "satoshi".into(),
1331                    value: "100".into(),
1332                },
1333                CurrencyAmount {
1334                    currency: "tbtc4".into(),
1335                    value: "50".into(),
1336                },
1337            ]),
1338        };
1339        assert_eq!(entry.amount.sats(), 100);
1340        assert_eq!(entry.amount.chain_balance("tbtc4"), 50);
1341        assert_eq!(entry.amount.chain_balance("ltc"), 0);
1342    }
1343
1344    #[test]
1345    fn default_config_disabled() {
1346        let config = PayConfig::default();
1347        assert!(!config.enabled);
1348        assert_eq!(config.cost_sats, 1);
1349    }
1350
1351    #[test]
1352    fn payment_response_headers_returns_three_headers() {
1353        let headers = super::payment_response_headers(950, 50, "sat");
1354        assert_eq!(headers.len(), 3);
1355        assert_eq!(headers[0], ("X-Balance", "950".to_string()));
1356        assert_eq!(headers[1], ("X-Cost", "50".to_string()));
1357        assert_eq!(headers[2], ("X-Pay-Currency", "sat".to_string()));
1358    }
1359
1360    #[test]
1361    fn payment_response_headers_zero_balance() {
1362        let headers = super::payment_response_headers(0, 1, "sat");
1363        assert_eq!(headers[0].1, "0");
1364    }
1365}