Fix: Nut 05 mint response model (#564)

* change response model of NUT-05 to include payment_preimage and change (NUT-08)

* fix tests

* crud: same expiry as timestamp

* fix expiry handling

* add api tests to check new models
This commit is contained in:
callebtc
2024-06-27 14:35:03 +02:00
committed by GitHub
parent 8af1b61b30
commit 1d8b5cd5ca
12 changed files with 151 additions and 49 deletions

View File

@@ -290,19 +290,27 @@ class MeltQuote(LedgerEvent):
created_time: Union[int, None] = None
paid_time: Union[int, None] = None
fee_paid: int = 0
proof: str = ""
payment_preimage: str = ""
expiry: Optional[int] = None
change: Optional[List[BlindedSignature]] = None
@classmethod
def from_row(cls, row: Row):
try:
created_time = int(row["created_time"]) if row["created_time"] else None
paid_time = int(row["paid_time"]) if row["paid_time"] else None
expiry = int(row["expiry"]) if row["expiry"] else None
except Exception:
created_time = (
int(row["created_time"].timestamp()) if row["created_time"] else None
)
paid_time = int(row["paid_time"].timestamp()) if row["paid_time"] else None
expiry = int(row["expiry"].timestamp()) if row["expiry"] else None
# parse change from row as json
change = None
if row["change"]:
change = json.loads(row["change"])
return cls(
quote=row["quote"],
@@ -317,7 +325,9 @@ class MeltQuote(LedgerEvent):
created_time=created_time,
paid_time=paid_time,
fee_paid=row["fee_paid"],
proof=row["proof"],
change=change,
expiry=expiry,
payment_preimage=row["proof"],
)
@property

View File

@@ -183,6 +183,8 @@ class PostMeltQuoteResponse(BaseModel):
paid: bool # whether the request has been paid # DEPRECATED as per NUT PR #136
state: str # state of the quote
expiry: Optional[int] # expiry of the quote
payment_preimage: Optional[str] = None # payment preimage
change: Union[List[BlindedSignature], None] = None
@classmethod
def from_melt_quote(self, melt_quote: MeltQuote) -> "PostMeltQuoteResponse":
@@ -203,9 +205,9 @@ class PostMeltRequest(BaseModel):
)
class PostMeltResponse(BaseModel):
class PostMeltResponse_deprecated(BaseModel):
paid: Union[bool, None]
payment_preimage: Union[str, None]
preimage: Union[str, None]
change: Union[List[BlindedSignature], None] = None
@@ -217,12 +219,6 @@ class PostMeltRequest_deprecated(BaseModel):
)
class PostMeltResponse_deprecated(BaseModel):
paid: Union[bool, None]
preimage: Union[str, None]
change: Union[List[BlindedSignature], None] = None
# ------- API: SPLIT -------

View File

@@ -1,3 +1,4 @@
import json
from abc import ABC, abstractmethod
from typing import Any, List, Optional
@@ -548,8 +549,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute(
f"""
INSERT INTO {table_with_schema(db, 'melt_quotes')}
(quote, method, request, checking_id, unit, amount, fee_reserve, paid, state, created_time, paid_time, fee_paid, proof)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(quote, method, request, checking_id, unit, amount, fee_reserve, paid, state, created_time, paid_time, fee_paid, proof, change, expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
quote.quote,
@@ -564,7 +565,9 @@ class LedgerCrudSqlite(LedgerCrud):
timestamp_from_seconds(db, quote.created_time),
timestamp_from_seconds(db, quote.paid_time),
quote.fee_paid,
quote.proof,
quote.payment_preimage,
json.dumps(quote.change) if quote.change else None,
timestamp_from_seconds(db, quote.expiry),
),
)
@@ -612,13 +615,14 @@ class LedgerCrudSqlite(LedgerCrud):
) -> None:
await (conn or db).execute(
f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, state = ?,"
" fee_paid = ?, paid_time = ?, proof = ? WHERE quote = ?",
" fee_paid = ?, paid_time = ?, proof = ?, change = ? WHERE quote = ?",
(
quote.paid,
quote.state.name,
quote.fee_paid,
timestamp_from_seconds(db, quote.paid_time),
quote.proof,
quote.payment_preimage,
json.dumps([s.dict() for s in quote.change]) if quote.change else None,
quote.quote,
),
)

View File

@@ -158,7 +158,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
quote.state = MeltQuoteState.paid
if payment.fee:
quote.fee_paid = payment.fee.to(Unit[quote.unit]).amount
quote.proof = payment.preimage or ""
quote.payment_preimage = payment.preimage or ""
await self.crud.update_melt_quote(quote=quote, db=self.db)
# invalidate proofs
await self._invalidate_proofs(
@@ -740,7 +740,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
if status.fee:
melt_quote.fee_paid = status.fee.to(unit).amount
if status.preimage:
melt_quote.proof = status.preimage
melt_quote.payment_preimage = status.preimage
melt_quote.paid_time = int(time.time())
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
@@ -831,7 +831,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
proofs: List[Proof],
quote: str,
outputs: Optional[List[BlindedMessage]] = None,
) -> Tuple[str, List[BlindedSignature]]:
) -> PostMeltQuoteResponse:
"""Invalidates proofs and pays a Lightning invoice.
Args:
@@ -915,13 +915,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
to_unit=unit, round="up"
).amount
if payment.preimage:
melt_quote.proof = payment.preimage
melt_quote.payment_preimage = payment.preimage
# set quote as paid
melt_quote.paid = True
melt_quote.state = MeltQuoteState.paid
melt_quote.paid_time = int(time.time())
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
# melt successful, invalidate proofs
await self._invalidate_proofs(proofs=proofs, quote_id=melt_quote.quote)
@@ -936,6 +934,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
keyset=self.keysets[outputs[0].id],
)
melt_quote.change = return_promises
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
except Exception as e:
logger.trace(f"Melt exception: {e}")
raise e
@@ -943,7 +946,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
# delete proofs from pending list
await self.db_write._unset_proofs_pending(proofs)
return melt_quote.proof or "", return_promises
return PostMeltQuoteResponse.from_melt_quote(melt_quote)
async def split(
self,

View File

@@ -815,3 +815,13 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database):
await conn.execute(
f"UPDATE {table_with_schema(db, 'melt_quotes')} SET state = '{state}' WHERE quote = '{row['quote']}'"
)
async def m021_add_change_and_expiry_to_melt_quotes(db: Database):
async with db.connect() as conn:
await conn.execute(
f"ALTER TABLE {table_with_schema(db, 'melt_quotes')} ADD COLUMN change TEXT"
)
await conn.execute(
f"ALTER TABLE {table_with_schema(db, 'melt_quotes')} ADD COLUMN expiry TIMESTAMP"
)

View File

@@ -15,7 +15,6 @@ from ..core.models import (
PostMeltQuoteRequest,
PostMeltQuoteResponse,
PostMeltRequest,
PostMeltResponse,
PostMintQuoteRequest,
PostMintQuoteResponse,
PostMintRequest,
@@ -290,24 +289,21 @@ async def get_melt_quote(request: Request, quote: str) -> PostMeltQuoteResponse:
"Melt tokens for a Bitcoin payment that the mint will make for the user in"
" exchange"
),
response_model=PostMeltResponse,
response_model=PostMeltQuoteResponse,
response_description=(
"The state of the payment, a preimage as proof of payment, and a list of"
" promises for change."
),
)
@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute")
async def melt(request: Request, payload: PostMeltRequest) -> PostMeltResponse:
async def melt(request: Request, payload: PostMeltRequest) -> PostMeltQuoteResponse:
"""
Requests tokens to be destroyed and sent out via Lightning.
"""
logger.trace(f"> POST /v1/melt/bolt11: {payload}")
preimage, change_promises = await ledger.melt(
resp = await ledger.melt(
proofs=payload.inputs, quote=payload.quote, outputs=payload.outputs
)
resp = PostMeltResponse(
paid=True, payment_preimage=preimage, change=change_promises
)
logger.trace(f"< POST /v1/melt/bolt11: {resp}")
return resp

View File

@@ -230,11 +230,11 @@ async def melt_deprecated(
quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=payload.pr, unit="sat")
)
preimage, change_promises = await ledger.melt(
melt_resp = await ledger.melt(
proofs=payload.proofs, quote=quote.quote, outputs=outputs
)
resp = PostMeltResponse_deprecated(
paid=True, preimage=preimage, change=change_promises
paid=True, preimage=melt_resp.payment_preimage, change=melt_resp.change
)
logger.trace(f"< POST /melt: {resp}")
return resp

View File

@@ -33,7 +33,6 @@ from ..core.models import (
PostMeltRequest,
PostMeltRequestOptionMpp,
PostMeltRequestOptions,
PostMeltResponse,
PostMeltResponse_deprecated,
PostMintQuoteRequest,
PostMintQuoteResponse,
@@ -406,7 +405,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
quote: str,
proofs: List[Proof],
outputs: Optional[List[BlindedMessage]],
) -> PostMeltResponse:
) -> PostMeltQuoteResponse:
"""
Accepts proofs and a lightning invoice to pay in exchange.
"""
@@ -438,13 +437,24 @@ class LedgerAPI(LedgerAPIDeprecated, object):
ret: PostMeltResponse_deprecated = await self.melt_deprecated(
proofs=proofs, outputs=outputs, invoice=invoice.bolt11
)
return PostMeltResponse(
paid=ret.paid, payment_preimage=ret.preimage, change=ret.change
return PostMeltQuoteResponse(
quote=quote,
amount=0,
fee_reserve=0,
paid=ret.paid or False,
state=(
MeltQuoteState.paid.value
if ret.paid
else MeltQuoteState.unpaid.value
),
payment_preimage=ret.preimage,
change=ret.change,
expiry=None,
)
# END backwards compatibility < 0.15.0
self.raise_on_error_request(resp)
return_dict = resp.json()
return PostMeltResponse.parse_obj(return_dict)
return PostMeltQuoteResponse.parse_obj(return_dict)
@async_set_httpx_client
@async_ensure_mint_loaded

View File

@@ -28,7 +28,6 @@ from ..core.migrations import migrate_databases
from ..core.models import (
PostCheckStateResponse,
PostMeltQuoteResponse,
PostMeltResponse,
)
from ..core.p2pk import Secret
from ..core.settings import settings
@@ -639,7 +638,7 @@ class Wallet(
async def melt(
self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str
) -> PostMeltResponse:
) -> PostMeltQuoteResponse:
"""Pays a lightning invoice and returns the status of the payment.
Args:

View File

@@ -306,7 +306,7 @@ class LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL):
@async_ensure_mint_loaded_deprecated
async def melt_deprecated(
self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]]
):
) -> PostMeltResponse_deprecated:
"""
Accepts proofs and a lightning invoice to pay in exchange.
"""

View File

@@ -3,12 +3,14 @@ import httpx
import pytest
import pytest_asyncio
from cashu.core.base import ProofSpentState
from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState
from cashu.core.models import (
GetInfoResponse,
MintMeltMethodSetting,
PostCheckStateRequest,
PostCheckStateResponse,
PostMeltQuoteResponse,
PostMintQuoteResponse,
PostRestoreRequest,
PostRestoreResponse,
)
@@ -186,6 +188,16 @@ async def test_mint_quote(ledger: Ledger):
result = response.json()
assert result["quote"]
assert result["request"]
# deserialize the response
resp_quote = PostMintQuoteResponse(**result)
assert resp_quote.quote == result["quote"]
assert resp_quote.state == MintQuoteState.unpaid.value
# check if DEPRECATED paid flag is also returned
assert result["paid"] is False
assert resp_quote.paid is False
invoice = bolt11.decode(result["request"])
assert invoice.amount_msat == 100 * 1000
@@ -195,6 +207,9 @@ async def test_mint_quote(ledger: Ledger):
assert result["expiry"] == expiry
# pay the invoice
pay_if_regtest(result["request"])
# get mint quote again from api
response = httpx.get(
f"{BASE_URL}/v1/mint/quote/bolt11/{result['quote']}",
@@ -202,6 +217,14 @@ async def test_mint_quote(ledger: Ledger):
assert response.status_code == 200, f"{response.url} {response.status_code}"
result2 = response.json()
assert result2["quote"] == result["quote"]
# deserialize the response
resp_quote = PostMintQuoteResponse(**result2)
assert resp_quote.quote == result["quote"]
assert resp_quote.state == MintQuoteState.paid.value
# check if DEPRECATED paid flag is also returned
assert result2["paid"] is True
assert resp_quote.paid is True
@pytest.mark.asyncio
@@ -255,6 +278,18 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet):
assert result["amount"] == 64
# TODO: internal invoice, fee should be 0
assert result["fee_reserve"] == 0
# deserialize the response
resp_quote = PostMeltQuoteResponse(**result)
assert resp_quote.quote == result["quote"]
assert resp_quote.payment_preimage is None
assert resp_quote.change is None
assert resp_quote.state == MeltQuoteState.unpaid.value
# check if DEPRECATED paid flag is also returned
assert result["paid"] is False
assert resp_quote.paid is False
invoice_obj = bolt11.decode(request)
expiry = None
@@ -263,13 +298,25 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet):
assert result["expiry"] == expiry
# get melt quote again from api
response = httpx.get(
f"{BASE_URL}/v1/melt/quote/bolt11/{result['quote']}",
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result2 = response.json()
assert result2["quote"] == result["quote"]
# # get melt quote again from api
# response = httpx.get(
# f"{BASE_URL}/v1/melt/quote/bolt11/{result['quote']}",
# )
# assert response.status_code == 200, f"{response.url} {response.status_code}"
# result2 = response.json()
# assert result2["quote"] == result["quote"]
# # deserialize the response
# resp_quote = PostMeltQuoteResponse(**result2)
# assert resp_quote.quote == result["quote"]
# assert resp_quote.payment_preimage is not None
# assert len(resp_quote.payment_preimage) == 64
# assert resp_quote.change is not None
# assert resp_quote.state == MeltQuoteState.paid.value
# # check if DEPRECATED paid flag is also returned
# assert result2["paid"] is True
# assert resp_quote.paid is True
@pytest.mark.asyncio
@@ -338,6 +385,19 @@ async def test_melt_internal(ledger: Ledger, wallet: Wallet):
assert result.get("payment_preimage") is not None
assert result["paid"] is True
# deserialize the response
resp_quote = PostMeltQuoteResponse(**result)
assert resp_quote.quote == quote.quote
# internal invoice, no preimage, no change
assert resp_quote.payment_preimage == ""
assert resp_quote.change == []
assert resp_quote.state == MeltQuoteState.paid.value
# check if DEPRECATED paid flag is also returned
assert result["paid"] is True
assert resp_quote.paid is True
@pytest.mark.asyncio
@pytest.mark.skipif(
@@ -387,6 +447,19 @@ async def test_melt_external(ledger: Ledger, wallet: Wallet):
# we get back 2 sats because Lightning was free to pay on regtest
assert result["change"][0]["amount"] == 2
# deserialize the response
resp_quote = PostMeltQuoteResponse(**result)
assert resp_quote.quote == quote.quote
assert resp_quote.payment_preimage is not None
assert len(resp_quote.payment_preimage) == 64
assert resp_quote.change is not None
assert resp_quote.change[0].amount == 2
assert resp_quote.state == MeltQuoteState.paid.value
# check if DEPRECATED paid flag is also returned
assert result["paid"] is True
assert resp_quote.paid is True
@pytest.mark.asyncio
@pytest.mark.skipif(

View File

@@ -357,11 +357,12 @@ async def test_melt_with_more_inputs_than_invoice(wallet1: Wallet, ledger: Ledge
# make sure we have more inputs than the melt quote needs
assert sum_proofs(wallet1.proofs) >= melt_quote.amount + melt_quote.fee_reserve
payment_proof, return_outputs = await ledger.melt(
melt_resp = await ledger.melt(
proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs
)
# we get 2 sats back because we overpaid
assert sum([o.amount for o in return_outputs]) == 2
assert melt_resp.change
assert sum([o.amount for o in melt_resp.change]) == 2
@pytest.mark.asyncio