diff --git a/migrations/versions/9c4d8e2f1a6b_repair_fee_payout_checkpoint_columns.py b/migrations/versions/9c4d8e2f1a6b_repair_fee_payout_checkpoint_columns.py new file mode 100644 index 00000000..8decfe25 --- /dev/null +++ b/migrations/versions/9c4d8e2f1a6b_repair_fee_payout_checkpoint_columns.py @@ -0,0 +1,47 @@ +"""repair missing fee payout checkpoint columns + +Revision ID: 9c4d8e2f1a6b +Revises: 7f2843d3f4e4 +Create Date: 2026-07-25 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "9c4d8e2f1a6b" +down_revision = "7f2843d3f4e4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Repair databases stamped past the original checkpoint migration.""" + conn = op.get_bind() + columns = { + column["name"] for column in sa.inspect(conn).get_columns("routstr_fees") + } + + if "payout_in_progress_msats" not in columns: + op.add_column( + "routstr_fees", + sa.Column( + "payout_in_progress_msats", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + + if "payout_started_at" not in columns: + op.add_column( + "routstr_fees", + sa.Column("payout_started_at", sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + # The preceding revision already expects both columns. This migration only + # repairs schema drift, so downgrading it must preserve the expected schema. + pass diff --git a/migrations/versions/8a1b2c3d4e5f_add_refund_sweep_claim_lease.py b/migrations/versions/aa50fde387a2_add_refund_sweep_claim_lease.py similarity index 77% rename from migrations/versions/8a1b2c3d4e5f_add_refund_sweep_claim_lease.py rename to migrations/versions/aa50fde387a2_add_refund_sweep_claim_lease.py index f2964c26..40c7e577 100644 --- a/migrations/versions/8a1b2c3d4e5f_add_refund_sweep_claim_lease.py +++ b/migrations/versions/aa50fde387a2_add_refund_sweep_claim_lease.py @@ -1,15 +1,17 @@ -"""add recoverable refund sweep claim lease +"""add refund sweep claim lease -Revision ID: 8a1b2c3d4e5f -Revises: 7f2843d3f4e4 -Create Date: 2026-07-24 23:15:00.000000 +Revision ID: aa50fde387a2 +Revises: 9c4d8e2f1a6b +Create Date: 2026-07-26 12:50:10.509217 """ +from __future__ import annotations + import sqlalchemy as sa from alembic import op -revision = "8a1b2c3d4e5f" -down_revision = "7f2843d3f4e4" +revision = "aa50fde387a2" +down_revision = "9c4d8e2f1a6b" branch_labels = None depends_on = None diff --git a/routstr/balance.py b/routstr/balance.py index 03ddf36c..91b19ce5 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -109,13 +109,19 @@ async def account_info( # Note: validate_bearer_key already supports refund_address and key_expiry_time params -@router.get("/create") -async def create_balance( +class BalanceCreateRequest(BaseModel): + initial_balance_token: str + balance_limit: int | None = None + balance_limit_reset: str | None = None + validity_date: int | None = None + + +async def _create_balance( initial_balance_token: str, - balance_limit: int | None = None, - balance_limit_reset: str | None = None, - validity_date: int | None = None, - session: AsyncSession = Depends(get_session), + balance_limit: int | None, + balance_limit_reset: str | None, + validity_date: int | None, + session: AsyncSession, ) -> dict: key = await validate_bearer_key(initial_balance_token, session) @@ -135,6 +141,37 @@ async def create_balance( } +@router.post("/create") +async def create_balance_from_body( + payload: BalanceCreateRequest, + session: AsyncSession = Depends(get_session), +) -> dict: + return await _create_balance( + payload.initial_balance_token, + payload.balance_limit, + payload.balance_limit_reset, + payload.validity_date, + session, + ) + + +@router.get("/create") +async def create_balance( + initial_balance_token: str, + balance_limit: int | None = None, + balance_limit_reset: str | None = None, + validity_date: int | None = None, + session: AsyncSession = Depends(get_session), +) -> dict: + return await _create_balance( + initial_balance_token, + balance_limit, + balance_limit_reset, + validity_date, + session, + ) + + @router.get("/info") async def wallet_info( key: ApiKey = Depends(get_key_from_header), diff --git a/tests/unit/test_balance_create_api.py b/tests/unit/test_balance_create_api.py new file mode 100644 index 00000000..e1f70f5b --- /dev/null +++ b/tests/unit/test_balance_create_api.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from routstr import balance as balance_module +from routstr.core.db import get_session + + +@pytest.mark.asyncio +async def test_create_balance_accepts_large_cashu_token_in_post_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + token = "cashuA" + "x" * 20_000 + key = SimpleNamespace(hashed_key="hashed", balance=123_000) + validate_bearer_key = AsyncMock(return_value=key) + session = AsyncMock() + monkeypatch.setattr(balance_module, "validate_bearer_key", validate_bearer_key) + + async def override_get_session(): # type: ignore[no-untyped-def] + yield session + + app = FastAPI() + app.include_router(balance_module.balance_router) + app.dependency_overrides[get_session] = override_get_session + + async with AsyncClient( + transport=ASGITransport(app=app), # type: ignore[arg-type] + base_url="http://test", + ) as client: + response = await client.post( + "/v1/balance/create", + json={"initial_balance_token": token}, + ) + + assert response.status_code == 200 + assert response.json() == {"api_key": "sk-hashed", "balance": 123_000} + validate_bearer_key.assert_awaited_once_with(token, session) diff --git a/tests/unit/test_fee_payout_migration.py b/tests/unit/test_fee_payout_migration.py index fe549194..8eae9829 100644 --- a/tests/unit/test_fee_payout_migration.py +++ b/tests/unit/test_fee_payout_migration.py @@ -18,6 +18,37 @@ def _run_alembic(root: Path, database_url: str, revision: str) -> None: ) +def test_fresh_node_migrates_fee_payout_schema_to_head(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[2] + database_path = tmp_path / "fresh-node.db" + database_url = f"sqlite+aiosqlite:///{database_path}" + + _run_alembic(root, database_url, "head") + + with sqlite3.connect(database_path) as connection: + version = connection.execute( + "SELECT version_num FROM alembic_version" + ).fetchone() + columns = { + row[1] for row in connection.execute("PRAGMA table_info(routstr_fees)") + } + fee = connection.execute( + "SELECT id, accumulated_msats, total_paid_msats, last_paid_at, " + "payout_in_progress_msats, payout_started_at FROM routstr_fees" + ).fetchone() + + assert version == ("aa50fde387a2",) + assert { + "id", + "accumulated_msats", + "total_paid_msats", + "last_paid_at", + "payout_in_progress_msats", + "payout_started_at", + } <= columns + assert fee == (1, 0, 0, None, 0, None) + + def test_fee_payout_checkpoint_migration_preserves_existing_row( tmp_path: Path, ) -> None: @@ -44,3 +75,36 @@ def test_fee_payout_checkpoint_migration_preserves_existing_row( ).fetchone() assert row == (5000, 1000, 123, 0, None) + + +def test_fee_payout_checkpoint_repair_restores_columns_missing_at_old_head( + tmp_path: Path, +) -> None: + root = Path(__file__).resolve().parents[2] + database_path = tmp_path / "migration.db" + database_url = f"sqlite+aiosqlite:///{database_path}" + old_head = "7f2843d3f4e4" + _run_alembic(root, database_url, old_head) + + # Reproduce a database that was stamped to head after a duplicate-column or + # unknown-revision recovery skipped part of the migration chain. + with sqlite3.connect(database_path) as connection: + connection.execute("ALTER TABLE routstr_fees DROP COLUMN payout_started_at") + connection.execute( + "ALTER TABLE routstr_fees DROP COLUMN payout_in_progress_msats" + ) + connection.commit() + + _run_alembic(root, database_url, "head") + + with sqlite3.connect(database_path) as connection: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(routstr_fees)") + } + row = connection.execute( + "SELECT payout_in_progress_msats, payout_started_at " + "FROM routstr_fees WHERE id = 1" + ).fetchone() + + assert {"payout_in_progress_msats", "payout_started_at"} <= columns + assert row == (0, None) diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index e2244ea0..0835269c 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -91,25 +91,27 @@ export function CashuPaymentWorkflow({ setIsCreatingKey(true); try { - const params = new URLSearchParams({ + const requestPayload: { + initial_balance_token: string; + balance_limit?: number; + balance_limit_reset?: string; + validity_date?: number; + } = { initial_balance_token: initialToken.trim(), - }); - if (balanceLimit) params.append('balance_limit', balanceLimit); + }; + if (balanceLimit) requestPayload.balance_limit = Number(balanceLimit); if (balanceLimitReset) - params.append('balance_limit_reset', balanceLimitReset); + requestPayload.balance_limit_reset = balanceLimitReset; if (validityDate) { - const timestamp = Math.floor( + requestPayload.validity_date = Math.floor( new Date(validityDate + 'T23:59:59').getTime() / 1000 ); - params.append('validity_date', timestamp.toString()); } - const response = await fetch( - `${baseUrl}/v1/balance/create?${params.toString()}`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - } - ); + const response = await fetch(`${baseUrl}/v1/balance/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestPayload), + }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to create API key'); diff --git a/ui/components/providers/RoutstrCreateKeySection.tsx b/ui/components/providers/RoutstrCreateKeySection.tsx index 2fecf5eb..2741a0a6 100644 --- a/ui/components/providers/RoutstrCreateKeySection.tsx +++ b/ui/components/providers/RoutstrCreateKeySection.tsx @@ -161,13 +161,11 @@ export function RoutstrCreateKeySection({ setIsCreatingCashu(true); try { - const params = new URLSearchParams({ - initial_balance_token: cashuToken.trim(), + const resp = await fetch(`${cleanUrl}/v1/balance/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initial_balance_token: cashuToken.trim() }), }); - const resp = await fetch( - `${cleanUrl}/v1/balance/create?${params.toString()}`, - { method: 'GET', headers: { 'Content-Type': 'application/json' } } - ); if (!resp.ok) { const errorText = await resp.text();