fix: recreate refund sweep migration on latest head

This commit is contained in:
9qeklajc
2026-07-26 12:52:24 +02:00
7 changed files with 221 additions and 31 deletions
@@ -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
@@ -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
+43 -6
View File
@@ -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),
+40
View File
@@ -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)
+64
View File
@@ -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)
@@ -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');
@@ -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();