From 4ba8d257498deabad9f73138bc6b327f5d501be2 Mon Sep 17 00:00:00 2001 From: Kimi Agent Date: Fri, 20 Mar 2026 13:07:02 -0400 Subject: [PATCH] feat: Lightning Network integration for tool usage (#610) Co-authored-by: Kimi Agent Co-committed-by: Kimi Agent --- src/dashboard/routes/system.py | 51 ++---------- src/lightning/__init__.py | 1 + src/lightning/factory.py | 69 ++++++++++++++++ src/lightning/ledger.py | 146 +++++++++++++++++++++++++++++++++ tests/unit/test_lightning.py | 109 ++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 46 deletions(-) create mode 100644 src/lightning/__init__.py create mode 100644 src/lightning/factory.py create mode 100644 src/lightning/ledger.py create mode 100644 tests/unit/test_lightning.py diff --git a/src/dashboard/routes/system.py b/src/dashboard/routes/system.py index af638b7f..8102ee97 100644 --- a/src/dashboard/routes/system.py +++ b/src/dashboard/routes/system.py @@ -16,52 +16,11 @@ router = APIRouter(tags=["system"]) @router.get("/lightning/ledger", response_class=HTMLResponse) async def lightning_ledger(request: Request): - """Ledger and balance page.""" - # Mock data for now, as this seems to be a UI-first feature - balance = { - "available_sats": 1337, - "incoming_total_sats": 2000, - "outgoing_total_sats": 663, - "fees_paid_sats": 5, - "net_sats": 1337, - "pending_incoming_sats": 0, - "pending_outgoing_sats": 0, - } + """Ledger and balance page backed by the in-memory Lightning ledger.""" + from lightning.ledger import get_balance, get_transactions - # Mock transactions - from collections import namedtuple - from enum import Enum - - class TxType(Enum): - incoming = "incoming" - outgoing = "outgoing" - - class TxStatus(Enum): - completed = "completed" - pending = "pending" - - Tx = namedtuple( - "Tx", ["tx_type", "status", "amount_sats", "payment_hash", "memo", "created_at"] - ) - - transactions = [ - Tx( - TxType.outgoing, - TxStatus.completed, - 50, - "hash1", - "Model inference", - "2026-03-04 10:00:00", - ), - Tx( - TxType.incoming, - TxStatus.completed, - 1000, - "hash2", - "Manual deposit", - "2026-03-03 15:00:00", - ), - ] + balance = get_balance() + transactions = get_transactions() return templates.TemplateResponse( request, @@ -70,7 +29,7 @@ async def lightning_ledger(request: Request): "balance": balance, "transactions": transactions, "tx_types": ["incoming", "outgoing"], - "tx_statuses": ["completed", "pending"], + "tx_statuses": ["pending", "settled", "failed", "expired"], "filter_type": None, "filter_status": None, "stats": {}, diff --git a/src/lightning/__init__.py b/src/lightning/__init__.py new file mode 100644 index 00000000..69be654d --- /dev/null +++ b/src/lightning/__init__.py @@ -0,0 +1 @@ +"""Lightning Network integration for tool-usage micro-payments.""" diff --git a/src/lightning/factory.py b/src/lightning/factory.py new file mode 100644 index 00000000..259f6165 --- /dev/null +++ b/src/lightning/factory.py @@ -0,0 +1,69 @@ +"""Lightning backend factory. + +Returns a mock or real LND backend based on ``settings.lightning_backend``. +""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +from dataclasses import dataclass + +from config import settings + +logger = logging.getLogger(__name__) + + +@dataclass +class Invoice: + """Minimal Lightning invoice representation.""" + + payment_hash: str + payment_request: str + amount_sats: int + memo: str + + +class MockBackend: + """In-memory mock Lightning backend for development and testing.""" + + def create_invoice(self, amount_sats: int, memo: str = "") -> Invoice: + """Create a fake invoice with a random payment hash.""" + raw = secrets.token_bytes(32) + payment_hash = hashlib.sha256(raw).hexdigest() + payment_request = f"lnbc{amount_sats}mock{payment_hash[:20]}" + logger.debug("Mock invoice: %s sats — %s", amount_sats, payment_hash[:12]) + return Invoice( + payment_hash=payment_hash, + payment_request=payment_request, + amount_sats=amount_sats, + memo=memo, + ) + + +# Singleton — lazily created +_backend: MockBackend | None = None + + +def get_backend() -> MockBackend: + """Return the configured Lightning backend (currently mock-only). + + Raises ``ValueError`` if an unsupported backend is requested. + """ + global _backend # noqa: PLW0603 + if _backend is not None: + return _backend + + kind = settings.lightning_backend + if kind == "mock": + _backend = MockBackend() + elif kind == "lnd": + # LND gRPC integration is on the roadmap — for now fall back to mock. + logger.warning("LND backend not yet implemented — using mock") + _backend = MockBackend() + else: + raise ValueError(f"Unknown lightning_backend: {kind!r}") + + logger.info("Lightning backend: %s", kind) + return _backend diff --git a/src/lightning/ledger.py b/src/lightning/ledger.py new file mode 100644 index 00000000..c43c7961 --- /dev/null +++ b/src/lightning/ledger.py @@ -0,0 +1,146 @@ +"""In-memory Lightning transaction ledger. + +Tracks invoices, settlements, and balances per the schema in +``docs/adr/018-lightning-ledger.md``. Uses a simple in-memory list so the +dashboard can display real (ephemeral) data without requiring SQLite yet. +""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import StrEnum + +logger = logging.getLogger(__name__) + + +class TxType(StrEnum): + incoming = "incoming" + outgoing = "outgoing" + + +class TxStatus(StrEnum): + pending = "pending" + settled = "settled" + failed = "failed" + expired = "expired" + + +@dataclass +class LedgerEntry: + """Single ledger row matching the ADR-018 schema.""" + + id: str + tx_type: TxType + status: TxStatus + payment_hash: str + amount_sats: int + memo: str + source: str + created_at: str + invoice: str = "" + preimage: str = "" + task_id: str = "" + agent_id: str = "" + settled_at: str = "" + fee_sats: int = 0 + + +# ── In-memory store ────────────────────────────────────────────────── +_entries: list[LedgerEntry] = [] + + +def create_invoice_entry( + payment_hash: str, + amount_sats: int, + memo: str = "", + source: str = "tool_usage", + task_id: str = "", + agent_id: str = "", + invoice: str = "", +) -> LedgerEntry: + """Record a new incoming invoice in the ledger.""" + entry = LedgerEntry( + id=uuid.uuid4().hex[:16], + tx_type=TxType.incoming, + status=TxStatus.pending, + payment_hash=payment_hash, + amount_sats=amount_sats, + memo=memo, + source=source, + task_id=task_id, + agent_id=agent_id, + invoice=invoice, + created_at=datetime.now(UTC).isoformat(), + ) + _entries.append(entry) + logger.debug("Ledger entry created: %s (%s sats)", entry.id, amount_sats) + return entry + + +def mark_settled(payment_hash: str, preimage: str = "") -> LedgerEntry | None: + """Mark a pending entry as settled by payment hash.""" + for entry in _entries: + if entry.payment_hash == payment_hash and entry.status == TxStatus.pending: + entry.status = TxStatus.settled + entry.preimage = preimage + entry.settled_at = datetime.now(UTC).isoformat() + logger.debug("Ledger settled: %s", payment_hash[:12]) + return entry + return None + + +def get_balance() -> dict: + """Compute the current balance from settled and pending entries.""" + incoming_total = sum( + e.amount_sats + for e in _entries + if e.tx_type == TxType.incoming and e.status == TxStatus.settled + ) + outgoing_total = sum( + e.amount_sats + for e in _entries + if e.tx_type == TxType.outgoing and e.status == TxStatus.settled + ) + fees = sum(e.fee_sats for e in _entries if e.status == TxStatus.settled) + pending_in = sum( + e.amount_sats + for e in _entries + if e.tx_type == TxType.incoming and e.status == TxStatus.pending + ) + pending_out = sum( + e.amount_sats + for e in _entries + if e.tx_type == TxType.outgoing and e.status == TxStatus.pending + ) + net = incoming_total - outgoing_total - fees + return { + "incoming_total_sats": incoming_total, + "outgoing_total_sats": outgoing_total, + "fees_paid_sats": fees, + "net_sats": net, + "pending_incoming_sats": pending_in, + "pending_outgoing_sats": pending_out, + "available_sats": net - pending_out, + } + + +def get_transactions( + tx_type: str | None = None, + status: str | None = None, + limit: int = 50, +) -> list[LedgerEntry]: + """Return ledger entries, optionally filtered.""" + result = _entries + if tx_type: + result = [e for e in result if e.tx_type.value == tx_type] + if status: + result = [e for e in result if e.status.value == status] + return list(reversed(result))[:limit] + + +def clear() -> None: + """Reset the ledger (for testing).""" + _entries.clear() diff --git a/tests/unit/test_lightning.py b/tests/unit/test_lightning.py new file mode 100644 index 00000000..156240bc --- /dev/null +++ b/tests/unit/test_lightning.py @@ -0,0 +1,109 @@ +"""Unit tests for the lightning package (factory + ledger).""" + +from __future__ import annotations + +import pytest + +from lightning.factory import Invoice, MockBackend, get_backend +from lightning.ledger import ( + TxStatus, + TxType, + clear, + create_invoice_entry, + get_balance, + get_transactions, + mark_settled, +) + + +@pytest.fixture(autouse=True) +def _clean_ledger(): + """Reset the in-memory ledger between tests.""" + clear() + yield + clear() + + +# ── Factory tests ──────────────────────────────────────────────────── + + +class TestMockBackend: + def test_create_invoice_returns_invoice(self): + backend = MockBackend() + inv = backend.create_invoice(100, "test memo") + assert isinstance(inv, Invoice) + assert inv.amount_sats == 100 + assert inv.memo == "test memo" + assert len(inv.payment_hash) == 64 # SHA-256 hex + assert inv.payment_request.startswith("lnbc") + + def test_invoices_have_unique_hashes(self): + backend = MockBackend() + a = backend.create_invoice(10) + b = backend.create_invoice(10) + assert a.payment_hash != b.payment_hash + + +class TestGetBackend: + def test_returns_mock_backend(self): + backend = get_backend() + assert isinstance(backend, MockBackend) + + +# ── Ledger tests ───────────────────────────────────────────────────── + + +class TestLedger: + def test_create_invoice_entry(self): + entry = create_invoice_entry( + payment_hash="abc123", + amount_sats=500, + memo="test", + source="unit_test", + ) + assert entry.tx_type == TxType.incoming + assert entry.status == TxStatus.pending + assert entry.amount_sats == 500 + + def test_mark_settled(self): + create_invoice_entry(payment_hash="hash1", amount_sats=100) + result = mark_settled("hash1", preimage="secret") + assert result is not None + assert result.status == TxStatus.settled + assert result.preimage == "secret" + assert result.settled_at != "" + + def test_mark_settled_unknown_hash(self): + assert mark_settled("nonexistent") is None + + def test_get_balance_empty(self): + bal = get_balance() + assert bal["net_sats"] == 0 + assert bal["available_sats"] == 0 + + def test_get_balance_with_settled(self): + create_invoice_entry(payment_hash="h1", amount_sats=1000) + mark_settled("h1") + bal = get_balance() + assert bal["incoming_total_sats"] == 1000 + assert bal["net_sats"] == 1000 + assert bal["available_sats"] == 1000 + + def test_get_balance_pending_not_counted(self): + create_invoice_entry(payment_hash="h2", amount_sats=500) + bal = get_balance() + assert bal["incoming_total_sats"] == 0 + assert bal["pending_incoming_sats"] == 500 + + def test_get_transactions_returns_entries(self): + create_invoice_entry(payment_hash="t1", amount_sats=10) + create_invoice_entry(payment_hash="t2", amount_sats=20) + txs = get_transactions() + assert len(txs) == 2 + + def test_get_transactions_filter_by_status(self): + create_invoice_entry(payment_hash="f1", amount_sats=10) + create_invoice_entry(payment_hash="f2", amount_sats=20) + mark_settled("f1") + assert len(get_transactions(status="settled")) == 1 + assert len(get_transactions(status="pending")) == 1