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 created_time: Union[int, None] = None
paid_time: Union[int, None] = None paid_time: Union[int, None] = None
fee_paid: int = 0 fee_paid: int = 0
proof: str = "" payment_preimage: str = ""
expiry: Optional[int] = None expiry: Optional[int] = None
change: Optional[List[BlindedSignature]] = None
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: Row):
try: try:
created_time = int(row["created_time"]) if row["created_time"] else None created_time = int(row["created_time"]) if row["created_time"] else None
paid_time = int(row["paid_time"]) if row["paid_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: except Exception:
created_time = ( created_time = (
int(row["created_time"].timestamp()) if row["created_time"] else None int(row["created_time"].timestamp()) if row["created_time"] else None
) )
paid_time = int(row["paid_time"].timestamp()) if row["paid_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( return cls(
quote=row["quote"], quote=row["quote"],
@@ -317,7 +325,9 @@ class MeltQuote(LedgerEvent):
created_time=created_time, created_time=created_time,
paid_time=paid_time, paid_time=paid_time,
fee_paid=row["fee_paid"], fee_paid=row["fee_paid"],
proof=row["proof"], change=change,
expiry=expiry,
payment_preimage=row["proof"],
) )
@property @property

View File

@@ -183,6 +183,8 @@ class PostMeltQuoteResponse(BaseModel):
paid: bool # whether the request has been paid # DEPRECATED as per NUT PR #136 paid: bool # whether the request has been paid # DEPRECATED as per NUT PR #136
state: str # state of the quote state: str # state of the quote
expiry: Optional[int] # expiry of the quote expiry: Optional[int] # expiry of the quote
payment_preimage: Optional[str] = None # payment preimage
change: Union[List[BlindedSignature], None] = None
@classmethod @classmethod
def from_melt_quote(self, melt_quote: MeltQuote) -> "PostMeltQuoteResponse": 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] paid: Union[bool, None]
payment_preimage: Union[str, None] preimage: Union[str, None]
change: Union[List[BlindedSignature], None] = 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 ------- # ------- API: SPLIT -------

View File

@@ -1,3 +1,4 @@
import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, List, Optional from typing import Any, List, Optional
@@ -548,8 +549,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'melt_quotes')} 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) (quote, method, request, checking_id, unit, amount, fee_reserve, paid, state, created_time, paid_time, fee_paid, proof, change, expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
quote.quote, quote.quote,
@@ -564,7 +565,9 @@ class LedgerCrudSqlite(LedgerCrud):
timestamp_from_seconds(db, quote.created_time), timestamp_from_seconds(db, quote.created_time),
timestamp_from_seconds(db, quote.paid_time), timestamp_from_seconds(db, quote.paid_time),
quote.fee_paid, 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: ) -> None:
await (conn or db).execute( await (conn or db).execute(
f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, state = ?," 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.paid,
quote.state.name, quote.state.name,
quote.fee_paid, quote.fee_paid,
timestamp_from_seconds(db, quote.paid_time), 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, quote.quote,
), ),
) )

View File

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

View File

@@ -815,3 +815,13 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database):
await conn.execute( await conn.execute(
f"UPDATE {table_with_schema(db, 'melt_quotes')} SET state = '{state}' WHERE quote = '{row['quote']}'" 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, PostMeltQuoteRequest,
PostMeltQuoteResponse, PostMeltQuoteResponse,
PostMeltRequest, PostMeltRequest,
PostMeltResponse,
PostMintQuoteRequest, PostMintQuoteRequest,
PostMintQuoteResponse, PostMintQuoteResponse,
PostMintRequest, 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" "Melt tokens for a Bitcoin payment that the mint will make for the user in"
" exchange" " exchange"
), ),
response_model=PostMeltResponse, response_model=PostMeltQuoteResponse,
response_description=( response_description=(
"The state of the payment, a preimage as proof of payment, and a list of" "The state of the payment, a preimage as proof of payment, and a list of"
" promises for change." " promises for change."
), ),
) )
@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") @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. Requests tokens to be destroyed and sent out via Lightning.
""" """
logger.trace(f"> POST /v1/melt/bolt11: {payload}") 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 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}") logger.trace(f"< POST /v1/melt/bolt11: {resp}")
return resp return resp

View File

@@ -230,11 +230,11 @@ async def melt_deprecated(
quote = await ledger.melt_quote( quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=payload.pr, unit="sat") 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 proofs=payload.proofs, quote=quote.quote, outputs=outputs
) )
resp = PostMeltResponse_deprecated( 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}") logger.trace(f"< POST /melt: {resp}")
return resp return resp

View File

@@ -33,7 +33,6 @@ from ..core.models import (
PostMeltRequest, PostMeltRequest,
PostMeltRequestOptionMpp, PostMeltRequestOptionMpp,
PostMeltRequestOptions, PostMeltRequestOptions,
PostMeltResponse,
PostMeltResponse_deprecated, PostMeltResponse_deprecated,
PostMintQuoteRequest, PostMintQuoteRequest,
PostMintQuoteResponse, PostMintQuoteResponse,
@@ -406,7 +405,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
quote: str, quote: str,
proofs: List[Proof], proofs: List[Proof],
outputs: Optional[List[BlindedMessage]], outputs: Optional[List[BlindedMessage]],
) -> PostMeltResponse: ) -> PostMeltQuoteResponse:
""" """
Accepts proofs and a lightning invoice to pay in exchange. 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( ret: PostMeltResponse_deprecated = await self.melt_deprecated(
proofs=proofs, outputs=outputs, invoice=invoice.bolt11 proofs=proofs, outputs=outputs, invoice=invoice.bolt11
) )
return PostMeltResponse( return PostMeltQuoteResponse(
paid=ret.paid, payment_preimage=ret.preimage, change=ret.change 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 # END backwards compatibility < 0.15.0
self.raise_on_error_request(resp) self.raise_on_error_request(resp)
return_dict = resp.json() return_dict = resp.json()
return PostMeltResponse.parse_obj(return_dict) return PostMeltQuoteResponse.parse_obj(return_dict)
@async_set_httpx_client @async_set_httpx_client
@async_ensure_mint_loaded @async_ensure_mint_loaded

View File

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

View File

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

View File

@@ -3,12 +3,14 @@ import httpx
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from cashu.core.base import ProofSpentState from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState
from cashu.core.models import ( from cashu.core.models import (
GetInfoResponse, GetInfoResponse,
MintMeltMethodSetting, MintMeltMethodSetting,
PostCheckStateRequest, PostCheckStateRequest,
PostCheckStateResponse, PostCheckStateResponse,
PostMeltQuoteResponse,
PostMintQuoteResponse,
PostRestoreRequest, PostRestoreRequest,
PostRestoreResponse, PostRestoreResponse,
) )
@@ -186,6 +188,16 @@ async def test_mint_quote(ledger: Ledger):
result = response.json() result = response.json()
assert result["quote"] assert result["quote"]
assert result["request"] 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"]) invoice = bolt11.decode(result["request"])
assert invoice.amount_msat == 100 * 1000 assert invoice.amount_msat == 100 * 1000
@@ -195,6 +207,9 @@ async def test_mint_quote(ledger: Ledger):
assert result["expiry"] == expiry assert result["expiry"] == expiry
# pay the invoice
pay_if_regtest(result["request"])
# get mint quote again from api # get mint quote again from api
response = httpx.get( response = httpx.get(
f"{BASE_URL}/v1/mint/quote/bolt11/{result['quote']}", 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}" assert response.status_code == 200, f"{response.url} {response.status_code}"
result2 = response.json() result2 = response.json()
assert result2["quote"] == result["quote"] 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 @pytest.mark.asyncio
@@ -255,6 +278,18 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet):
assert result["amount"] == 64 assert result["amount"] == 64
# TODO: internal invoice, fee should be 0 # TODO: internal invoice, fee should be 0
assert result["fee_reserve"] == 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) invoice_obj = bolt11.decode(request)
expiry = None expiry = None
@@ -263,13 +298,25 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet):
assert result["expiry"] == expiry assert result["expiry"] == expiry
# get melt quote again from api # # get melt quote again from api
response = httpx.get( # response = httpx.get(
f"{BASE_URL}/v1/melt/quote/bolt11/{result['quote']}", # f"{BASE_URL}/v1/melt/quote/bolt11/{result['quote']}",
) # )
assert response.status_code == 200, f"{response.url} {response.status_code}" # assert response.status_code == 200, f"{response.url} {response.status_code}"
result2 = response.json() # result2 = response.json()
assert result2["quote"] == result["quote"] # 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 @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.get("payment_preimage") is not None
assert result["paid"] is True 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.asyncio
@pytest.mark.skipif( @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 # we get back 2 sats because Lightning was free to pay on regtest
assert result["change"][0]["amount"] == 2 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.asyncio
@pytest.mark.skipif( @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 # make sure we have more inputs than the melt quote needs
assert sum_proofs(wallet1.proofs) >= melt_quote.amount + melt_quote.fee_reserve 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 proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs
) )
# we get 2 sats back because we overpaid # 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 @pytest.mark.asyncio