1use crate::rpc::{MicropaymentInstruction, TaxDispatchPlan};
40use serde::{Deserialize, Serialize};
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub enum PaymentStatus {
46 Sent,
48 Queued,
50 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#[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
76pub trait IlpTransport: Send + Sync {
80 fn send(&self, ilp_address: &str, amount_micro_cents: u64, via_nym: bool)
84 -> Result<(), String>;
85}
86
87pub struct HttpIlpTransport {
94 pub connector_url: String, }
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 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(); 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 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
148pub fn resolve_payment_pointer(pointer: &str) -> Result<String, String> {
150 if pointer.starts_with('$') {
151 let stripped = &pointer[1..];
152 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 Ok(pointer.to_string())
163 } else {
164 Err(format!("Unrecognised payment address format: {pointer}"))
165 }
166}
167
168pub struct MockTransport {
171 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
184pub 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 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 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
277pub 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; 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#[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 assert_eq!(
361 resolve_payment_pointer("did:wallet:0xABCD").unwrap(),
362 "did:wallet:0xABCD"
363 );
364 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 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); }
412}