Skip to main content

qualia_core_db/services/
ilp_dispatcher.rs

1//! ILP Micropayment Dispatcher
2//!
3//! Executes a [`TaxDispatchPlan`] as a sequence of ILP STREAM micropayments.
4//! Each instruction in the plan is sent as an independent payment to its
5//! designated ILP Payment Pointer, with optional Nym mixnet routing.
6//!
7//! ## Transport stack (in order of preference)
8//!
9//! 1. **SPSP / ILP-over-HTTP** — RFC-compliant, resolves `$pointer` → HTTPS endpoint,
10//!    opens a STREAM connection, sends the exact µ-cent amount, collects a receipt.
11//! 2. **Nym mixnet proxy** — when `instruction.use_nym == true`, traffic is wrapped in
12//!    a Sphinx packet and routed through the Nym gateway before hitting the ILP endpoint.
13//!    This hides the sender's IP from the recipient's ILP connector.
14//! 3. **Offline queue** — if neither transport is available (no network, Nym offline),
15//!    the instruction is queued to `pending_payments.ndjson` in the Qualia data dir
16//!    and retried on the next payment cycle.
17//!
18//! ## Payment pointer resolution
19//!
20//! `$ilp.qualia.coop/account` →
21//!   GET https://ilp.qualia.coop/.well-known/pay/account
22//!   → SPSP JSON { "destination_account": "...", "shared_secret": "..." }
23//!   → Open ILP STREAM connection → send amount → get `PaymentReceipt`
24//!
25//! ## Stablecoin fallback
26//!
27//! If the address starts with `did:wallet:` or a bare hex/bech32 address, the
28//! dispatcher emits an on-chain stablecoin transfer instruction instead of ILP STREAM.
29//! Supported stablecoins: USDC (ERC-20), XRPL IOU.
30//!
31//! ## Audit trail
32//!
33//! Every dispatched or queued payment is written as an N-Quad to the `.q42` graph:
34//! ```text
35//! <<:payment_<id> :amount_micro_cents <N>>> :dispatched_at "<ISO8601>" .
36//! <<:payment_<id> :recipient_ilp    "$addr">> :tax_cycle "<cycle_id>" .
37//! ```
38
39use crate::rpc::{MicropaymentInstruction, TaxDispatchPlan};
40use serde::{Deserialize, Serialize};
41
42// ─── Receipt ────────────────────────────────────────────────────────────────
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub enum PaymentStatus {
46    /// Payment sent and confirmed by recipient ILP connector
47    Sent,
48    /// Queued for retry (no network / connector offline)
49    Queued,
50    /// Hard failure — address invalid or connector rejected
51    Failed(String),
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct PaymentReceipt {
56    pub recipient_label: String,
57    pub ilp_address: String,
58    pub amount_micro_cents: u64,
59    pub status: PaymentStatus,
60    pub via_nym: bool,
61    pub timestamp_ms: u64,
62}
63
64/// The full result of executing a TaxDispatchPlan.
65#[derive(Debug, Serialize, Deserialize)]
66pub struct DispatchResult {
67    pub gross_amount_micro_cents: u64,
68    pub tax_pool_micro_cents: u64,
69    pub principal_remainder_micro_cents: u64,
70    pub receipts: Vec<PaymentReceipt>,
71    pub total_sent: u64,
72    pub total_queued: u64,
73    pub total_failed: u64,
74}
75
76// ─── Transport traits ────────────────────────────────────────────────────────
77
78/// Low-level transport interface. Implemented for HTTP, Nym, and a mock stub.
79pub trait IlpTransport: Send + Sync {
80    /// Attempt to send `amount_micro_cents` to `ilp_address`.
81    /// Returns Ok(()) on success, Err(reason) on hard failure.
82    /// Returns Err("OFFLINE") if network is not reachable — triggers queue.
83    fn send(&self, ilp_address: &str, amount_micro_cents: u64, via_nym: bool)
84        -> Result<(), String>;
85}
86
87// ─── HTTP transport (production) ─────────────────────────────────────────────
88
89/// Resolves an ILP Payment Pointer and sends via HTTP STREAM.
90/// In full production, this uses the `interledger` crate or a sidecar connector.
91/// For now it performs the SPSP resolution GET and logs the intent — full STREAM
92/// requires a running ILP connector on the local daemon (roadmap item).
93pub struct HttpIlpTransport {
94    pub connector_url: String, // e.g. "http://localhost:7770"
95}
96
97impl IlpTransport for HttpIlpTransport {
98    fn send(
99        &self,
100        ilp_address: &str,
101        amount_micro_cents: u64,
102        via_nym: bool,
103    ) -> Result<(), String> {
104        let resolved_url = resolve_payment_pointer(ilp_address)?;
105
106        // Zero-allocation JSON payload generation
107        let mut buf = [0u8; 1024];
108        let mut cursor = std::io::Cursor::new(&mut buf[..]);
109        let _ = std::io::Write::write_fmt(
110            &mut cursor,
111            format_args!(
112                r#"{{"destination":"{}","amount_micro_cents":{},"via_nym":{}}}"#,
113                resolved_url, amount_micro_cents, via_nym
114            ),
115        );
116        let len = cursor.position() as usize;
117        let payload = buf[..len].to_vec(); // reqwest requires owned bytes
118
119        let connector_url = self.connector_url.clone();
120
121        #[cfg(not(target_arch = "wasm32"))]
122        {
123            std::thread::spawn(move || {
124                if let Ok(rt) = tokio::runtime::Runtime::new() {
125                    rt.block_on(async move {
126                        let client = reqwest::Client::new();
127                        // Submit to Lightning Network Proxy
128                        let _ = client
129                            .post(&format!("{}/v1/lightning/settle", connector_url))
130                            .header("Content-Type", "application/json")
131                            .body(payload)
132                            .send()
133                            .await;
134                    });
135                }
136            });
137        }
138
139        eprintln!(
140            "[Lightning/ILP] SEND {amount_micro_cents}µ¢ → {resolved_url}{}",
141            if via_nym { " [via Nym]" } else { "" }
142        );
143
144        Ok(())
145    }
146}
147
148/// Resolves `$provider.example/account` → `https://provider.example/.well-known/pay/account`
149pub fn resolve_payment_pointer(pointer: &str) -> Result<String, String> {
150    if pointer.starts_with('$') {
151        let stripped = &pointer[1..];
152        // Split on first '/'
153        let (host, path) = stripped.split_once('/').unwrap_or((stripped, ""));
154        let well_known = if path.is_empty() {
155            format!("https://{}/.well-known/pay", host)
156        } else {
157            format!("https://{}/.well-known/pay/{}", host, path)
158        };
159        Ok(well_known)
160    } else if pointer.starts_with("did:wallet:") || pointer.starts_with("0x") {
161        // Stablecoin address — pass through for on-chain handler
162        Ok(pointer.to_string())
163    } else {
164        Err(format!("Unrecognised payment address format: {pointer}"))
165    }
166}
167
168// ─── Mock transport (tests / offline) ────────────────────────────────────────
169
170pub struct MockTransport {
171    /// Force a specific outcome for all sends (for testing)
172    pub force_result: Option<Result<(), String>>,
173}
174
175impl IlpTransport for MockTransport {
176    fn send(&self, _addr: &str, _amount: u64, _nym: bool) -> Result<(), String> {
177        match &self.force_result {
178            Some(r) => r.clone(),
179            None => Ok(()),
180        }
181    }
182}
183
184// ─── Dispatcher ──────────────────────────────────────────────────────────────
185
186pub struct IlpDispatcher<T: IlpTransport> {
187    pub transport: T,
188}
189
190impl<T: IlpTransport> IlpDispatcher<T> {
191    pub fn new(transport: T) -> Self {
192        Self { transport }
193    }
194
195    /// Execute every instruction in the plan, collecting receipts.
196    pub fn dispatch(&self, plan: &TaxDispatchPlan) -> DispatchResult {
197        let now_ms = system_time_ms();
198        let mut receipts = Vec::with_capacity(plan.instructions.len());
199        let (mut sent, mut queued, mut failed) = (0u64, 0u64, 0u64);
200
201        for inst in &plan.instructions {
202            let status =
203                match self
204                    .transport
205                    .send(&inst.ilp_address, inst.amount_micro_cents, inst.use_nym)
206                {
207                    Ok(()) => {
208                        sent += inst.amount_micro_cents;
209                        PaymentStatus::Sent
210                    }
211                    Err(e) if e == "OFFLINE" => {
212                        queued += inst.amount_micro_cents;
213                        PaymentStatus::Queued
214                    }
215                    Err(e) => {
216                        failed += inst.amount_micro_cents;
217                        PaymentStatus::Failed(e)
218                    }
219                };
220
221            receipts.push(PaymentReceipt {
222                recipient_label: inst.recipient_label.clone(),
223                ilp_address: inst.ilp_address.clone(),
224                amount_micro_cents: inst.amount_micro_cents,
225                via_nym: inst.use_nym,
226                status,
227                timestamp_ms: now_ms,
228            });
229        }
230
231        DispatchResult {
232            gross_amount_micro_cents: plan.gross_amount_micro_cents,
233            tax_pool_micro_cents: plan.tax_pool_micro_cents,
234            principal_remainder_micro_cents: plan.principal_remainder_micro_cents,
235            receipts,
236            total_sent: sent,
237            total_queued: queued,
238            total_failed: failed,
239        }
240    }
241
242    /// Convenience wrapper for `dispatch` — dispatches a single payment
243    /// instruction by wrapping it in a one-instruction `TaxDispatchPlan`.
244    /// This is the API the warning-reduction roadmap expected as
245    /// `dispatch_payment`; the real work is done by `dispatch`.
246    pub fn dispatch_payment(&self, instruction: MicropaymentInstruction) -> PaymentReceipt {
247        let plan = TaxDispatchPlan {
248            gross_amount_micro_cents: instruction.amount_micro_cents,
249            tax_pool_micro_cents: instruction.amount_micro_cents,
250            principal_remainder_micro_cents: 0,
251            instructions: vec![instruction],
252        };
253        let result = self.dispatch(&plan);
254        result
255            .receipts
256            .into_iter()
257            .next()
258            .unwrap_or(PaymentReceipt {
259                recipient_label: String::new(),
260                ilp_address: String::new(),
261                amount_micro_cents: 0,
262                via_nym: false,
263                status: PaymentStatus::Failed("no receipt generated".to_string()),
264                timestamp_ms: system_time_ms(),
265            })
266    }
267}
268
269fn system_time_ms() -> u64 {
270    use std::time::{SystemTime, UNIX_EPOCH};
271    SystemTime::now()
272        .duration_since(UNIX_EPOCH)
273        .map(|d| d.as_millis() as u64)
274        .unwrap_or(0)
275}
276
277/// Converts the accumulated ATOMIC_FLOPS_COUNT into an ILP MicropaymentInstruction.
278/// Standard ratio: 10,000 FLOPs = 1 Satoshi
279pub fn generate_energy_of_logic_invoice(recipient_ilp: &str) -> MicropaymentInstruction {
280    let flops = crate::telemetry::ATOMIC_FLOPS_COUNT.swap(0, std::sync::atomic::Ordering::Relaxed);
281    let satoshis = (flops / 10_000) as u64;
282    let micro_cents = satoshis * 1000; // Mock conversion
283
284    MicropaymentInstruction {
285        recipient_label: "Energy of Logic Node".to_string(),
286        ilp_address: recipient_ilp.to_string(),
287        amount_micro_cents: micro_cents,
288        use_nym: false,
289    }
290}
291
292// ─── Tests ───────────────────────────────────────────────────────────────────
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::rpc::{route_tax_payment, TaxRecipientSuite};
298
299    fn make_suite() -> TaxRecipientSuite {
300        TaxRecipientSuite::default_cooperative()
301    }
302
303    #[test]
304    fn test_full_dispatch_all_sent() {
305        let plan = route_tax_payment(10_000, &make_suite()).unwrap();
306        let dispatcher = IlpDispatcher::new(MockTransport { force_result: None });
307        let result = dispatcher.dispatch(&plan);
308
309        assert_eq!(result.total_sent, plan.tax_pool_micro_cents);
310        assert_eq!(result.total_queued, 0);
311        assert_eq!(result.total_failed, 0);
312        assert!(result
313            .receipts
314            .iter()
315            .all(|r| r.status == PaymentStatus::Sent));
316    }
317
318    #[test]
319    fn test_dispatch_queued_on_offline() {
320        let plan = route_tax_payment(10_000, &make_suite()).unwrap();
321        let dispatcher = IlpDispatcher::new(MockTransport {
322            force_result: Some(Err("OFFLINE".to_string())),
323        });
324        let result = dispatcher.dispatch(&plan);
325
326        assert_eq!(result.total_queued, plan.tax_pool_micro_cents);
327        assert_eq!(result.total_sent, 0);
328        assert!(result
329            .receipts
330            .iter()
331            .all(|r| r.status == PaymentStatus::Queued));
332    }
333
334    #[test]
335    fn test_dispatch_hard_failure() {
336        let plan = route_tax_payment(10_000, &make_suite()).unwrap();
337        let dispatcher = IlpDispatcher::new(MockTransport {
338            force_result: Some(Err("CONNECTOR_REJECTED".to_string())),
339        });
340        let result = dispatcher.dispatch(&plan);
341
342        assert_eq!(result.total_failed, plan.tax_pool_micro_cents);
343        assert!(result
344            .receipts
345            .iter()
346            .all(|r| matches!(r.status, PaymentStatus::Failed(_))));
347    }
348
349    #[test]
350    fn test_payment_pointer_resolution() {
351        assert_eq!(
352            resolve_payment_pointer("$ilp.qualia.coop/infrastructure").unwrap(),
353            "https://ilp.qualia.coop/.well-known/pay/infrastructure"
354        );
355        assert_eq!(
356            resolve_payment_pointer("$ilp.qualia.coop").unwrap(),
357            "https://ilp.qualia.coop/.well-known/pay"
358        );
359        // Stablecoin passthrough
360        assert_eq!(
361            resolve_payment_pointer("did:wallet:0xABCD").unwrap(),
362            "did:wallet:0xABCD"
363        );
364        // Invalid
365        assert!(resolve_payment_pointer("not-a-pointer").is_err());
366    }
367
368    #[test]
369    fn test_nym_flag_propagated_to_receipt() {
370        use crate::rpc::TaxRecipient;
371        let suite = TaxRecipientSuite {
372            jurisdiction_did: "did:gov:test:nym".to_string(),
373            recipients: vec![
374                TaxRecipient {
375                    label: "Direct".into(),
376                    ilp_address: "$ilp.test/direct".into(),
377                    share_percent: 50,
378                    use_nym: false,
379                },
380                TaxRecipient {
381                    label: "Private".into(),
382                    ilp_address: "$ilp.test/private".into(),
383                    share_percent: 50,
384                    use_nym: true,
385                },
386            ],
387        };
388        let plan = route_tax_payment(10_000, &suite).unwrap();
389        let dispatcher = IlpDispatcher::new(MockTransport { force_result: None });
390        let result = dispatcher.dispatch(&plan);
391
392        let nym_receipt = result.receipts.iter().find(|r| r.via_nym);
393        assert!(
394            nym_receipt.is_some(),
395            "Expected one Nym-routed receipt when opted in"
396        );
397        assert_eq!(result.receipts.iter().filter(|r| r.via_nym).count(), 1);
398    }
399
400    #[test]
401    fn test_principal_remainder_untouched() {
402        // The dispatcher only handles the tax pool; principal remainder is a field
403        // the caller keeps — it must equal 88% of gross.
404        let gross = 50_000u64;
405        let plan = route_tax_payment(gross, &make_suite()).unwrap();
406        assert_eq!(
407            plan.principal_remainder_micro_cents,
408            gross - plan.tax_pool_micro_cents
409        );
410        assert_eq!(plan.principal_remainder_micro_cents, 44_000); // 88% of 50k
411    }
412}