agent-pay 0.1.0

L402 + DID-signed invoices: agent-to-agent Lightning payments (Rust port of @p-vbordei/agent-pay)
Documentation
//! LND REST adapter (BYO node).

use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD as B64_STD;
use base64::Engine;
use reqwest::Client;
use serde::{Deserialize, Serialize};

use crate::error::Error;
use crate::lightning::{
    Invoice, InvoiceCreateRequest, InvoiceLookup, LightningNode, PaymentResult,
};

pub struct LndRestConfig {
    pub url: String,
    pub macaroon_hex: String,
}

pub struct LndRestNode {
    cfg: LndRestConfig,
    client: Client,
}

impl LndRestNode {
    pub fn new(cfg: LndRestConfig) -> Result<Self, Error> {
        let client = Client::builder()
            .danger_accept_invalid_certs(true)
            .build()
            .map_err(|e| Error::Http(e.to_string()))?;
        Ok(Self { cfg, client })
    }

    pub fn with_client(cfg: LndRestConfig, client: Client) -> Self {
        Self { cfg, client }
    }
}

#[derive(Serialize)]
struct CreateInvoiceReq<'a> {
    value_msat: String,
    memo: &'a str,
    expiry: String,
}

#[derive(Deserialize)]
struct CreateInvoiceResp {
    payment_request: String,
    r_hash: String,
}

#[derive(Deserialize)]
struct LookupResp {
    settled: Option<bool>,
    value_msat: Option<String>,
    r_preimage: Option<String>,
}

#[derive(Serialize)]
struct PayReq<'a> {
    payment_request: &'a str,
}

#[derive(Deserialize)]
struct PayResp {
    payment_error: Option<String>,
    payment_preimage: Option<String>,
    payment_route: Option<PaymentRoute>,
}

#[derive(Deserialize)]
struct PaymentRoute {
    total_fees_msat: Option<String>,
}

#[async_trait]
impl LightningNode for LndRestNode {
    async fn create_invoice(&self, req: InvoiceCreateRequest) -> Result<Invoice, Error> {
        let body = CreateInvoiceReq {
            value_msat: req.amount_msat.to_string(),
            memo: req.memo.as_deref().unwrap_or(""),
            expiry: req.expiry_seconds.unwrap_or(300).to_string(),
        };
        let res = self
            .client
            .post(format!("{}/v1/invoices", self.cfg.url))
            .header("grpc-metadata-macaroon", &self.cfg.macaroon_hex)
            .json(&body)
            .send()
            .await
            .map_err(|e| Error::Http(e.to_string()))?;
        if !res.status().is_success() {
            let s = res.status();
            let t = res.text().await.unwrap_or_default();
            return Err(Error::Http(format!("LND createInvoice {s}: {t}")));
        }
        let resp: CreateInvoiceResp = res.json().await.map_err(|e| Error::Http(e.to_string()))?;
        let r_hash_bytes = B64_STD
            .decode(&resp.r_hash)
            .map_err(|e| Error::Http(format!("r_hash b64: {e}")))?;
        Ok(Invoice {
            bolt11: resp.payment_request,
            payment_hash: hex::encode(r_hash_bytes),
        })
    }

    async fn lookup_invoice(&self, payment_hash: &str) -> Result<InvoiceLookup, Error> {
        let bytes =
            hex::decode(payment_hash).map_err(|e| Error::Http(format!("payment_hash hex: {e}")))?;
        let b64 = B64_STD.encode(&bytes);
        let path = urlencoding::encode(&b64).into_owned();
        let res = self
            .client
            .get(format!("{}/v1/invoice/{}", self.cfg.url, path))
            .header("grpc-metadata-macaroon", &self.cfg.macaroon_hex)
            .send()
            .await
            .map_err(|e| Error::Http(e.to_string()))?;
        if !res.status().is_success() {
            let s = res.status();
            let t = res.text().await.unwrap_or_default();
            return Err(Error::Http(format!("LND lookupInvoice {s}: {t}")));
        }
        let resp: LookupResp = res.json().await.map_err(|e| Error::Http(e.to_string()))?;
        let preimage = if let Some(b64) = resp.r_preimage {
            Some(
                B64_STD
                    .decode(b64)
                    .map_err(|e| Error::Http(format!("preimage b64: {e}")))?,
            )
        } else {
            None
        };
        Ok(InvoiceLookup {
            settled: resp.settled.unwrap_or(false),
            amount_msat: resp
                .value_msat
                .as_deref()
                .unwrap_or("0")
                .parse()
                .unwrap_or(0),
            preimage,
        })
    }

    async fn pay_invoice(&self, bolt11: &str) -> Result<PaymentResult, Error> {
        let res = self
            .client
            .post(format!("{}/v1/channels/transactions", self.cfg.url))
            .header("grpc-metadata-macaroon", &self.cfg.macaroon_hex)
            .json(&PayReq {
                payment_request: bolt11,
            })
            .send()
            .await
            .map_err(|e| Error::Http(e.to_string()))?;
        if !res.status().is_success() {
            let s = res.status();
            let t = res.text().await.unwrap_or_default();
            return Err(Error::Http(format!("LND payInvoice {s}: {t}")));
        }
        let resp: PayResp = res.json().await.map_err(|e| Error::Http(e.to_string()))?;
        if let Some(err) = resp.payment_error {
            return Err(Error::Http(format!("LND payment_error: {err}")));
        }
        let preimage = resp
            .payment_preimage
            .ok_or_else(|| Error::Http("missing payment_preimage".into()))?;
        let bytes = B64_STD
            .decode(&preimage)
            .map_err(|e| Error::Http(format!("preimage b64: {e}")))?;
        Ok(PaymentResult {
            preimage: bytes,
            fee_msat: resp
                .payment_route
                .and_then(|r| r.total_fees_msat)
                .and_then(|s| s.parse().ok())
                .unwrap_or(0),
        })
    }
}