agent-pay 0.1.0

L402 + DID-signed invoices: agent-to-agent Lightning payments (Rust port of @p-vbordei/agent-pay)
Documentation
//! DID-signed envelopes for invoices and receipts.

use base64::engine::general_purpose::STANDARD as B64_STD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};

use crate::error::Error;
use crate::jws::{sign_compact, verify_compact, ResolveKey};
use crate::keys::verification_method_id;

pub const VERSION: &str = "agent-pay/0.1";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvoiceEnvelope {
    pub v: String,
    pub invoice_hash: String,
    pub did: String,
    pub price_msat: String,
    pub resource: String,
    pub expires_at: String,
    pub nonce: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptEnvelope {
    pub v: String,
    pub invoice_hash: String,
    pub preimage_hash: String,
    pub resource: String,
    pub paid_at: String,
    pub did: String,
}

fn invoice_hash_hex(bolt11: &str) -> String {
    let mut h = Sha256::new();
    h.update(bolt11.as_bytes());
    hex::encode(h.finalize())
}

pub struct SignInvoiceOpts<'a> {
    pub bolt11: &'a str,
    pub did: &'a str,
    pub private_key: &'a [u8; 32],
    pub price_msat: u64,
    pub resource: &'a str,
    pub expires_at: &'a str,
    pub nonce: &'a [u8],
}

pub async fn sign_invoice_envelope(opts: SignInvoiceOpts<'_>) -> Result<String, Error> {
    let payload = json!({
        "v": VERSION,
        "invoice_hash": invoice_hash_hex(opts.bolt11),
        "did": opts.did,
        "price_msat": opts.price_msat.to_string(),
        "resource": opts.resource,
        "expires_at": opts.expires_at,
        "nonce": B64_STD.encode(opts.nonce),
    });
    let kid = verification_method_id(opts.did)?;
    sign_compact(&payload, opts.private_key, &kid).await
}

pub async fn verify_invoice_envelope(
    token: &str,
    bolt11: &str,
    resolver: &ResolveKey,
) -> Result<InvoiceEnvelope, Error> {
    let (payload, _kid) = verify_compact(token, resolver).await?;
    let env: InvoiceEnvelope = serde_json::from_value(payload)
        .map_err(|e| Error::Envelope(format!("invalid envelope shape: {e}")))?;
    if env.v != VERSION {
        return Err(Error::Envelope(format!(
            "unsupported envelope version: {}",
            env.v
        )));
    }
    let expected = invoice_hash_hex(bolt11);
    if env.invoice_hash != expected {
        return Err(Error::Envelope(format!(
            "invoice_hash mismatch: envelope={} bolt11={}",
            env.invoice_hash, expected
        )));
    }
    Ok(env)
}

pub struct SignReceiptOpts<'a> {
    pub bolt11: &'a str,
    pub did: &'a str,
    pub private_key: &'a [u8; 32],
    pub preimage: &'a [u8],
    pub resource: &'a str,
    pub paid_at: &'a str,
}

pub async fn sign_receipt(opts: SignReceiptOpts<'_>) -> Result<String, Error> {
    let mut h = Sha256::new();
    h.update(opts.preimage);
    let preimage_hash = hex::encode(h.finalize());
    let payload = json!({
        "v": VERSION,
        "invoice_hash": invoice_hash_hex(opts.bolt11),
        "preimage_hash": preimage_hash,
        "resource": opts.resource,
        "paid_at": opts.paid_at,
        "did": opts.did,
    });
    let kid = verification_method_id(opts.did)?;
    sign_compact(&payload, opts.private_key, &kid).await
}

pub async fn verify_receipt(
    token: &str,
    bolt11: &str,
    resolver: &ResolveKey,
) -> Result<ReceiptEnvelope, Error> {
    let (payload, _kid) = verify_compact(token, resolver).await?;
    let env: ReceiptEnvelope = serde_json::from_value(payload)
        .map_err(|e| Error::Envelope(format!("invalid receipt shape: {e}")))?;
    if env.v != VERSION {
        return Err(Error::Envelope(format!(
            "unsupported receipt version: {}",
            env.v
        )));
    }
    if env.invoice_hash != invoice_hash_hex(bolt11) {
        return Err(Error::Envelope("receipt invoice_hash mismatch".into()));
    }
    Ok(env)
}