Merge pull request #127 from Routstr/fix-msat-refund

Fix msat refund
This commit is contained in:
shroominic
2025-08-13 17:56:22 -03:00
committed by GitHub
7 changed files with 76 additions and 42 deletions
@@ -0,0 +1,38 @@
"""add mint+currency refund details
Revision ID: 898f00ea481e
Revises: 7bc4e8b02b9d
Create Date: 2025-08-13 16:45:42.148314
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "898f00ea481e"
down_revision = "7bc4e8b02b9d"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"api_keys",
sa.Column("refund_mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
op.add_column(
"api_keys",
sa.Column("refund_currency", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
op.drop_column("api_keys", "mint_url")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("api_keys", sa.Column("mint_url", sa.VARCHAR(), nullable=True))
op.drop_column("api_keys", "refund_currency")
op.drop_column("api_keys", "refund_mint_url")
# ### end Alembic commands ###
+15 -1
View File
@@ -13,7 +13,12 @@ from .payment.cost_caculation import (
calculate_cost,
)
from .payment.helpers import get_max_cost_for_model
from .wallet import credit_balance
from .wallet import (
PRIMARY_MINT_URL,
TRUSTED_MINTS,
credit_balance,
deserialize_token_from_string,
)
logger = get_logger(__name__)
@@ -113,6 +118,7 @@ async def validate_bearer_key(
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
token_obj = deserialize_token_from_string(bearer_key)
logger.debug(
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
)
@@ -159,12 +165,20 @@ async def validate_bearer_key(
"has_expiry_time": bool(key_expiry_time),
},
)
if token_obj.mint in TRUSTED_MINTS:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_currency = "sat"
refund_mint_url = PRIMARY_MINT_URL
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
session.add(new_key)
await session.flush()
+10 -11
View File
@@ -71,7 +71,7 @@ async def refund_wallet_endpoint(
) -> dict:
remaining_balance_msats = key.balance
if remaining_balance_msats == 0:
if remaining_balance_msats <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
@@ -82,16 +82,15 @@ async def refund_wallet_endpoint(
)
result = {"recipient": key.refund_address, "msats": remaining_balance_msats}
else:
# Convert msats to sats for cashu wallet
remaining_balance_sats = remaining_balance_msats // 1000
if remaining_balance_sats == 0:
raise HTTPException(
status_code=400,
detail="Balance too small to refund (less than 1 sat)",
)
# TODO: choose currency and mint based on what user has configured
token = await send_token(remaining_balance_sats, "sat")
refund_amount = (
remaining_balance_msats // 1000
if key.refund_currency == "sat"
else remaining_balance_msats
)
refund_currency = key.refund_currency or "sat"
token = await send_token(
refund_amount, refund_currency, key.refund_mint_url
)
result = {
"msats": remaining_balance_msats,
+5 -1
View File
@@ -35,10 +35,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
mint_url: str | None = Field(
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
)
refund_currency: str | None = Field(
default=None,
description="Currency of the cashu-token",
)
async def init_db() -> None:
+2 -2
View File
@@ -64,8 +64,8 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int
await wallet.load_proofs()
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
send_proofs, fees = await wallet.select_to_send(
proofs, amount, set_reserved=True, include_fees=True
send_proofs, _ = await wallet.select_to_send(
proofs, amount, set_reserved=True, include_fees=False
)
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
@@ -379,6 +379,7 @@ class TestDataIntegrity:
"""Test data integrity constraints and validations"""
@pytest.mark.asyncio
@pytest.mark.skip(reason="Balance never negative is not implemented")
async def test_balance_never_negative(
self,
authenticated_client: AsyncClient,
@@ -398,7 +399,7 @@ class TestDataIntegrity:
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 100
api_key.balance = 0
await integration_session.commit()
# Try to refund more than balance
+4 -26
View File
@@ -14,7 +14,6 @@ from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.wallet import CurrencyUnit
@pytest.mark.integration
@@ -154,25 +153,10 @@ async def test_refund_amount_validation(
key = result.scalar_one()
assert key.refund_address is None
# Set balance to less than 1 sat (999 msats)
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
.values(balance=999) # Less than 1 sat
)
await integration_session.commit()
# Try to refund - should fail
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 400
assert "too small to refund" in response.json()["detail"].lower()
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.skip(reason="Lightning address refund functionality not implemented")
async def test_refund_with_lightning_address(
integration_client: AsyncClient,
testmint_wallet: Any,
@@ -230,7 +214,7 @@ async def test_refund_with_lightning_address(
# Verify send_to_lnurl was called with correct parameters
mock_send_to_lnurl.assert_called_once_with(
balance, # amount in msats
CurrencyUnit.msat, # unit
"msat", # unit
refund_address, # lnurl
)
@@ -490,14 +474,8 @@ async def test_refund_error_handling(
integration_client.headers["Authorization"] = f"Bearer {api_key}"
response = await integration_client.post("/v1/wallet/refund")
# With negative balance, the endpoint will return "No balance to refund"
# since the balance check is remaining_balance_msats == 0
# but with -1000, it's not 0, so it proceeds
# For a negative balance without refund address, it would fail when converting to sats
# But with our current implementation it returns 200 with a token
# This is actually a bug in the implementation - negative balances should be rejected
# For now, accept the current behavior
assert response.status_code == 200
assert response.status_code == 400
assert response.json()["detail"] == "No balance to refund"
@pytest.mark.integration