mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 10:34:20 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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 -------
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user