diff --git a/migrations/versions/a2b3c4d5e6f7_add_constraints_to_lightning_invoices.py b/migrations/versions/a2b3c4d5e6f7_add_constraints_to_lightning_invoices.py new file mode 100644 index 00000000..d565a2e7 --- /dev/null +++ b/migrations/versions/a2b3c4d5e6f7_add_constraints_to_lightning_invoices.py @@ -0,0 +1,26 @@ +"""Add balance_limit, balance_limit_reset, validity_date to lightning_invoices + +Revision ID: a2b3c4d5e6f7 +Revises: f1a2b3c4d5e6 +Create Date: 2026-06-03 00:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "a2b3c4d5e6f7" +down_revision = "f1a2b3c4d5e6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("lightning_invoices", sa.Column("balance_limit", sa.Integer(), nullable=True)) + op.add_column("lightning_invoices", sa.Column("balance_limit_reset", sa.String(), nullable=True)) + op.add_column("lightning_invoices", sa.Column("validity_date", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("lightning_invoices", "validity_date") + op.drop_column("lightning_invoices", "balance_limit_reset") + op.drop_column("lightning_invoices", "balance_limit") diff --git a/routstr/core/db.py b/routstr/core/db.py index 182dca36..0028234e 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -140,6 +140,18 @@ class LightningInvoice(SQLModel, table=True): # type: ignore ) expires_at: int = Field(description="Unix timestamp when invoice expires") paid_at: int | None = Field(default=None, description="Unix timestamp when paid") + balance_limit: int | None = Field( + default=None, + description="Max spendable msats for the created key", + ) + balance_limit_reset: str | None = Field( + default=None, + description="Reset policy for balance limit (daily, weekly, monthly)", + ) + validity_date: int | None = Field( + default=None, + description="Unix timestamp after which the created key expires", + ) class CashuTransaction(SQLModel, table=True): # type: ignore diff --git a/routstr/lightning.py b/routstr/lightning.py index 81423198..b0bbc63b 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -269,6 +269,9 @@ async def create_api_key_from_invoice( balance=invoice.amount_sats * 1000, # Convert to msats refund_currency="sat", refund_mint_url=settings.primary_mint, + balance_limit=invoice.balance_limit, + balance_limit_reset=invoice.balance_limit_reset, + validity_date=invoice.validity_date, ) session.add(api_key) diff --git a/tests/integration/test_lightning_invoice_constraints.py b/tests/integration/test_lightning_invoice_constraints.py new file mode 100644 index 00000000..a26b9083 --- /dev/null +++ b/tests/integration/test_lightning_invoice_constraints.py @@ -0,0 +1,159 @@ +"""Integration tests for Lightning invoice key constraint fields. + +Covers two things: +- The three constraint fields (balance_limit, balance_limit_reset, validity_date) + are persisted on LightningInvoice and survive a DB round-trip. +- create_api_key_from_invoice propagates those fields to the created ApiKey, + so the constraints are actually enforced when the key is used. +""" + +from __future__ import annotations + +import time +from unittest.mock import AsyncMock, patch + +import pytest +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.db import ApiKey, LightningInvoice +from routstr.lightning import create_api_key_from_invoice + + +def _make_invoice(**kwargs: object) -> LightningInvoice: + base = dict( + id="inv_test_001", + bolt11="lnbc1000n1test", + amount_sats=1000, + description="test invoice", + payment_hash="deadbeef" * 8, + status="paid", + purpose="create", + expires_at=int(time.time()) + 3600, + paid_at=int(time.time()), + ) + base.update(kwargs) + return LightningInvoice(**base) # type: ignore[arg-type] + + +@pytest.fixture(autouse=True) +def mock_wallet_mint() -> object: + with patch("routstr.lightning.get_wallet") as mock_get_wallet: + wallet = AsyncMock() + wallet.mint = AsyncMock(return_value=[]) + mock_get_wallet.return_value = wallet + yield mock_get_wallet + + +# --------------------------------------------------------------------------- +# Persistence +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_invoice_persists_balance_limit( + integration_session: AsyncSession, +) -> None: + invoice = _make_invoice(balance_limit=5000) + integration_session.add(invoice) + await integration_session.commit() + + stored = await integration_session.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.balance_limit == 5000 + + +@pytest.mark.asyncio +async def test_invoice_persists_balance_limit_reset( + integration_session: AsyncSession, +) -> None: + invoice = _make_invoice(balance_limit=5000, balance_limit_reset="daily") + integration_session.add(invoice) + await integration_session.commit() + + stored = await integration_session.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.balance_limit_reset == "daily" + + +@pytest.mark.asyncio +async def test_invoice_persists_validity_date( + integration_session: AsyncSession, +) -> None: + expiry = int(time.time()) + 86400 + invoice = _make_invoice(validity_date=expiry) + integration_session.add(invoice) + await integration_session.commit() + + stored = await integration_session.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.validity_date == expiry + + +# --------------------------------------------------------------------------- +# Propagation to ApiKey +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_created_key_receives_balance_limit( + integration_session: AsyncSession, +) -> None: + invoice = _make_invoice(balance_limit=8000) + integration_session.add(invoice) + await integration_session.flush() + + api_key = await create_api_key_from_invoice(invoice, integration_session) + await integration_session.commit() + + stored_key = await integration_session.get(ApiKey, api_key.hashed_key) + assert stored_key is not None + assert stored_key.balance_limit == 8000 + + +@pytest.mark.asyncio +async def test_created_key_receives_balance_limit_reset( + integration_session: AsyncSession, +) -> None: + invoice = _make_invoice(balance_limit=8000, balance_limit_reset="monthly") + integration_session.add(invoice) + await integration_session.flush() + + api_key = await create_api_key_from_invoice(invoice, integration_session) + await integration_session.commit() + + stored_key = await integration_session.get(ApiKey, api_key.hashed_key) + assert stored_key is not None + assert stored_key.balance_limit_reset == "monthly" + + +@pytest.mark.asyncio +async def test_created_key_receives_validity_date( + integration_session: AsyncSession, +) -> None: + expiry = int(time.time()) + 86400 + invoice = _make_invoice(validity_date=expiry) + integration_session.add(invoice) + await integration_session.flush() + + api_key = await create_api_key_from_invoice(invoice, integration_session) + await integration_session.commit() + + stored_key = await integration_session.get(ApiKey, api_key.hashed_key) + assert stored_key is not None + assert stored_key.validity_date == expiry + + +@pytest.mark.asyncio +async def test_created_key_without_constraints_has_none_fields( + integration_session: AsyncSession, +) -> None: + invoice = _make_invoice() + integration_session.add(invoice) + await integration_session.flush() + + api_key = await create_api_key_from_invoice(invoice, integration_session) + await integration_session.commit() + + stored_key = await integration_session.get(ApiKey, api_key.hashed_key) + assert stored_key is not None + assert stored_key.balance_limit is None + assert stored_key.balance_limit_reset is None + assert stored_key.validity_date is None