mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 10:34:20 +01:00
* 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
562 lines
20 KiB
Python
562 lines
20 KiB
Python
import json
|
|
import uuid
|
|
from posixpath import join
|
|
from typing import List, Optional, Tuple, Union
|
|
|
|
import bolt11
|
|
import httpx
|
|
from httpx import Response
|
|
from loguru import logger
|
|
|
|
from ..core.base import (
|
|
BlindedMessage,
|
|
BlindedSignature,
|
|
MeltQuoteState,
|
|
Proof,
|
|
ProofSpentState,
|
|
ProofState,
|
|
Unit,
|
|
WalletKeyset,
|
|
)
|
|
from ..core.crypto.secp import PublicKey
|
|
from ..core.db import Database
|
|
from ..core.models import (
|
|
CheckFeesResponse_deprecated,
|
|
GetInfoResponse,
|
|
KeysetsResponse,
|
|
KeysetsResponseKeyset,
|
|
KeysResponse,
|
|
PostCheckStateRequest,
|
|
PostCheckStateResponse,
|
|
PostMeltQuoteRequest,
|
|
PostMeltQuoteResponse,
|
|
PostMeltRequest,
|
|
PostMeltRequestOptionMpp,
|
|
PostMeltRequestOptions,
|
|
PostMeltResponse_deprecated,
|
|
PostMintQuoteRequest,
|
|
PostMintQuoteResponse,
|
|
PostMintRequest,
|
|
PostMintResponse,
|
|
PostRestoreResponse,
|
|
PostSplitRequest,
|
|
PostSplitResponse,
|
|
)
|
|
from ..core.settings import settings
|
|
from ..tor.tor import TorProxy
|
|
from .crud import (
|
|
get_lightning_invoice,
|
|
)
|
|
from .wallet_deprecated import LedgerAPIDeprecated
|
|
|
|
|
|
def async_set_httpx_client(func):
|
|
"""
|
|
Decorator that wraps around any async class method of LedgerAPI that makes
|
|
API calls. Sets some HTTP headers and starts a Tor instance if none is
|
|
already running and and sets local proxy to use it.
|
|
"""
|
|
|
|
async def wrapper(self, *args, **kwargs):
|
|
# set proxy
|
|
proxies_dict = {}
|
|
proxy_url: Union[str, None] = None
|
|
if settings.tor and TorProxy().check_platform():
|
|
self.tor = TorProxy(timeout=True)
|
|
self.tor.run_daemon(verbose=True)
|
|
proxy_url = "socks5://localhost:9050"
|
|
elif settings.socks_proxy:
|
|
proxy_url = f"socks5://{settings.socks_proxy}"
|
|
elif settings.http_proxy:
|
|
proxy_url = settings.http_proxy
|
|
if proxy_url:
|
|
proxies_dict.update({"all://": proxy_url})
|
|
|
|
headers_dict = {"Client-version": settings.version}
|
|
|
|
self.httpx = httpx.AsyncClient(
|
|
verify=not settings.debug,
|
|
proxies=proxies_dict, # type: ignore
|
|
headers=headers_dict,
|
|
base_url=self.url,
|
|
timeout=None if settings.debug else 60,
|
|
)
|
|
return await func(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def async_ensure_mint_loaded(func):
|
|
"""Decorator that ensures that the mint is loaded before calling the wrapped
|
|
function. If the mint is not loaded, it will be loaded first.
|
|
"""
|
|
|
|
async def wrapper(self, *args, **kwargs):
|
|
if not self.keysets:
|
|
await self.load_mint()
|
|
return await func(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
class LedgerAPI(LedgerAPIDeprecated, object):
|
|
tor: TorProxy
|
|
db: Database # we need the db for melt_deprecated
|
|
httpx: httpx.AsyncClient
|
|
|
|
def __init__(self, url: str, db: Database):
|
|
self.url = url
|
|
self.db = db
|
|
|
|
@async_set_httpx_client
|
|
async def _init_s(self):
|
|
"""Dummy function that can be called from outside to use LedgerAPI.s"""
|
|
return
|
|
|
|
@staticmethod
|
|
def raise_on_error_request(
|
|
resp: Response,
|
|
) -> None:
|
|
"""Raises an exception if the response from the mint contains an error.
|
|
|
|
Args:
|
|
resp_dict (Response): Response dict (previously JSON) from mint
|
|
|
|
Raises:
|
|
Exception: if the response contains an error
|
|
"""
|
|
try:
|
|
resp_dict = resp.json()
|
|
except json.JSONDecodeError:
|
|
# if we can't decode the response, raise for status
|
|
resp.raise_for_status()
|
|
return
|
|
if "detail" in resp_dict:
|
|
logger.trace(f"Error from mint: {resp_dict}")
|
|
error_message = f"Mint Error: {resp_dict['detail']}"
|
|
if "code" in resp_dict:
|
|
error_message += f" (Code: {resp_dict['code']})"
|
|
raise Exception(error_message)
|
|
# raise for status if no error
|
|
resp.raise_for_status()
|
|
|
|
"""
|
|
ENDPOINTS
|
|
"""
|
|
|
|
@async_set_httpx_client
|
|
async def _get_keys(self) -> List[WalletKeyset]:
|
|
"""API that gets the current keys of the mint
|
|
|
|
Args:
|
|
url (str): Mint URL
|
|
|
|
Returns:
|
|
WalletKeyset: Current mint keyset
|
|
|
|
Raises:
|
|
Exception: If no keys are received from the mint
|
|
"""
|
|
resp = await self.httpx.get(
|
|
join(self.url, "/v1/keys"),
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self._get_keys_deprecated(self.url)
|
|
return [ret]
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
keys_dict: dict = resp.json()
|
|
assert len(keys_dict), Exception("did not receive any keys")
|
|
keys = KeysResponse.parse_obj(keys_dict)
|
|
logger.debug(
|
|
f"Received {len(keys.keysets)} keysets from mint:"
|
|
f" {' '.join([k.id + f' ({k.unit})' for k in keys.keysets])}."
|
|
)
|
|
ret = [
|
|
WalletKeyset(
|
|
id=keyset.id,
|
|
unit=keyset.unit,
|
|
public_keys={
|
|
int(amt): PublicKey(bytes.fromhex(val), raw=True)
|
|
for amt, val in keyset.keys.items()
|
|
},
|
|
mint_url=self.url,
|
|
)
|
|
for keyset in keys.keysets
|
|
]
|
|
return ret
|
|
|
|
@async_set_httpx_client
|
|
async def _get_keyset(self, keyset_id: str) -> WalletKeyset:
|
|
"""API that gets the keys of a specific keyset from the mint.
|
|
|
|
|
|
Args:
|
|
keyset_id (str): base64 keyset ID, needs to be urlsafe-encoded before sending to mint (done in this method)
|
|
|
|
Returns:
|
|
WalletKeyset: Keyset with ID keyset_id
|
|
|
|
Raises:
|
|
Exception: If no keys are received from the mint
|
|
"""
|
|
keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_")
|
|
resp = await self.httpx.get(
|
|
join(self.url, f"/v1/keys/{keyset_id_urlsafe}"),
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self._get_keyset_deprecated(self.url, keyset_id)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
|
|
keys_dict = resp.json()
|
|
assert len(keys_dict), Exception("did not receive any keys")
|
|
keys = KeysResponse.parse_obj(keys_dict)
|
|
this_keyset = keys.keysets[0]
|
|
keyset_keys = {
|
|
int(amt): PublicKey(bytes.fromhex(val), raw=True)
|
|
for amt, val in this_keyset.keys.items()
|
|
}
|
|
keyset = WalletKeyset(
|
|
id=keyset_id,
|
|
unit=this_keyset.unit,
|
|
public_keys=keyset_keys,
|
|
mint_url=self.url,
|
|
)
|
|
return keyset
|
|
|
|
@async_set_httpx_client
|
|
async def _get_keysets(self) -> List[KeysetsResponseKeyset]:
|
|
"""API that gets a list of all active keysets of the mint.
|
|
|
|
Returns:
|
|
KeysetsResponse (List[str]): List of all active keyset IDs of the mint
|
|
|
|
Raises:
|
|
Exception: If no keysets are received from the mint
|
|
"""
|
|
resp = await self.httpx.get(
|
|
join(self.url, "/v1/keysets"),
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self._get_keysets_deprecated(self.url)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
|
|
keysets_dict = resp.json()
|
|
keysets = KeysetsResponse.parse_obj(keysets_dict).keysets
|
|
if not keysets:
|
|
raise Exception("did not receive any keysets")
|
|
return keysets
|
|
|
|
@async_set_httpx_client
|
|
async def _get_info(self) -> GetInfoResponse:
|
|
"""API that gets the mint info.
|
|
|
|
Returns:
|
|
GetInfoResponse: Current mint info
|
|
|
|
Raises:
|
|
Exception: If the mint info request fails
|
|
"""
|
|
resp = await self.httpx.get(
|
|
join(self.url, "/v1/info"),
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self._get_info_deprecated()
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
data: dict = resp.json()
|
|
mint_info: GetInfoResponse = GetInfoResponse.parse_obj(data)
|
|
return mint_info
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def mint_quote(self, amount: int, unit: Unit) -> PostMintQuoteResponse:
|
|
"""Requests a mint quote from the server and returns a payment request.
|
|
|
|
Args:
|
|
amount (int): Amount of tokens to mint
|
|
|
|
Returns:
|
|
PostMintQuoteResponse: Mint Quote Response
|
|
|
|
Raises:
|
|
Exception: If the mint request fails
|
|
"""
|
|
logger.trace("Requesting mint: GET /v1/mint/bolt11")
|
|
payload = PostMintQuoteRequest(unit=unit.name, amount=amount)
|
|
resp = await self.httpx.post(
|
|
join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict()
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self.request_mint_deprecated(amount)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
return_dict = resp.json()
|
|
return PostMintQuoteResponse.parse_obj(return_dict)
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def mint(
|
|
self, outputs: List[BlindedMessage], quote: str
|
|
) -> List[BlindedSignature]:
|
|
"""Mints new coins and returns a proof of promise.
|
|
|
|
Args:
|
|
outputs (List[BlindedMessage]): Outputs to mint new tokens with
|
|
quote (str): Quote ID.
|
|
|
|
Returns:
|
|
list[Proof]: List of proofs.
|
|
|
|
Raises:
|
|
Exception: If the minting fails
|
|
"""
|
|
outputs_payload = PostMintRequest(outputs=outputs, quote=quote)
|
|
logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11")
|
|
|
|
def _mintrequest_include_fields(outputs: List[BlindedMessage]):
|
|
"""strips away fields from the model that aren't necessary for the /mint"""
|
|
outputs_include = {"id", "amount", "B_"}
|
|
return {
|
|
"quote": ...,
|
|
"outputs": {i: outputs_include for i in range(len(outputs))},
|
|
}
|
|
|
|
payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore
|
|
resp = await self.httpx.post(
|
|
join(self.url, "/v1/mint/bolt11"),
|
|
json=payload, # type: ignore
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self.mint_deprecated(outputs, quote)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
response_dict = resp.json()
|
|
logger.trace("Lightning invoice checked. POST /v1/mint/bolt11")
|
|
promises = PostMintResponse.parse_obj(response_dict).signatures
|
|
return promises
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def melt_quote(
|
|
self, payment_request: str, unit: Unit, amount: Optional[int] = None
|
|
) -> PostMeltQuoteResponse:
|
|
"""Checks whether the Lightning payment is internal."""
|
|
invoice_obj = bolt11.decode(payment_request)
|
|
assert invoice_obj.amount_msat, "invoice must have amount"
|
|
# add mpp amount for partial melts
|
|
melt_options = None
|
|
if amount:
|
|
melt_options = PostMeltRequestOptions(
|
|
mpp=PostMeltRequestOptionMpp(amount=amount)
|
|
)
|
|
|
|
payload = PostMeltQuoteRequest(
|
|
unit=unit.name, request=payment_request, options=melt_options
|
|
)
|
|
|
|
resp = await self.httpx.post(
|
|
join(self.url, "/v1/melt/quote/bolt11"),
|
|
json=payload.dict(),
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret: CheckFeesResponse_deprecated = await self.check_fees_deprecated(
|
|
payment_request
|
|
)
|
|
quote_id = "deprecated_" + str(uuid.uuid4())
|
|
return PostMeltQuoteResponse(
|
|
quote=quote_id,
|
|
amount=amount or invoice_obj.amount_msat // 1000,
|
|
fee_reserve=ret.fee or 0,
|
|
paid=False,
|
|
state=MeltQuoteState.unpaid.value,
|
|
expiry=invoice_obj.expiry,
|
|
)
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
return_dict = resp.json()
|
|
return PostMeltQuoteResponse.parse_obj(return_dict)
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def melt(
|
|
self,
|
|
quote: str,
|
|
proofs: List[Proof],
|
|
outputs: Optional[List[BlindedMessage]],
|
|
) -> PostMeltQuoteResponse:
|
|
"""
|
|
Accepts proofs and a lightning invoice to pay in exchange.
|
|
"""
|
|
|
|
payload = PostMeltRequest(quote=quote, inputs=proofs, outputs=outputs)
|
|
|
|
def _meltrequest_include_fields(
|
|
proofs: List[Proof], outputs: List[BlindedMessage]
|
|
):
|
|
"""strips away fields from the model that aren't necessary for the /melt"""
|
|
proofs_include = {"id", "amount", "secret", "C", "witness"}
|
|
outputs_include = {"id", "amount", "B_"}
|
|
return {
|
|
"quote": ...,
|
|
"inputs": {i: proofs_include for i in range(len(proofs))},
|
|
"outputs": {i: outputs_include for i in range(len(outputs))},
|
|
}
|
|
|
|
resp = await self.httpx.post(
|
|
join(self.url, "/v1/melt/bolt11"),
|
|
json=payload.dict(include=_meltrequest_include_fields(proofs, outputs)), # type: ignore
|
|
timeout=None,
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
invoice = await get_lightning_invoice(id=quote, db=self.db)
|
|
assert invoice, f"no invoice found for id {quote}"
|
|
ret: PostMeltResponse_deprecated = await self.melt_deprecated(
|
|
proofs=proofs, outputs=outputs, invoice=invoice.bolt11
|
|
)
|
|
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 PostMeltQuoteResponse.parse_obj(return_dict)
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def split(
|
|
self,
|
|
proofs: List[Proof],
|
|
outputs: List[BlindedMessage],
|
|
) -> List[BlindedSignature]:
|
|
"""Consume proofs and create new promises based on amount split."""
|
|
logger.debug("Calling split. POST /v1/swap")
|
|
split_payload = PostSplitRequest(inputs=proofs, outputs=outputs)
|
|
|
|
# construct payload
|
|
def _splitrequest_include_fields(proofs: List[Proof]):
|
|
"""strips away fields from the model that aren't necessary for /v1/swap"""
|
|
proofs_include = {
|
|
"id",
|
|
"amount",
|
|
"secret",
|
|
"C",
|
|
"witness",
|
|
}
|
|
return {
|
|
"outputs": ...,
|
|
"inputs": {i: proofs_include for i in range(len(proofs))},
|
|
}
|
|
|
|
resp = await self.httpx.post(
|
|
join(self.url, "/v1/swap"),
|
|
json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self.split_deprecated(proofs, outputs)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
promises_dict = resp.json()
|
|
mint_response = PostSplitResponse.parse_obj(promises_dict)
|
|
promises = [BlindedSignature(**p.dict()) for p in mint_response.signatures]
|
|
|
|
if len(promises) == 0:
|
|
raise Exception("received no splits.")
|
|
|
|
return promises
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def check_proof_state(self, proofs: List[Proof]) -> PostCheckStateResponse:
|
|
"""
|
|
Checks whether the secrets in proofs are already spent or not and returns a list of booleans.
|
|
"""
|
|
payload = PostCheckStateRequest(Ys=[p.Y for p in proofs])
|
|
resp = await self.httpx.post(
|
|
join(self.url, "/v1/checkstate"),
|
|
json=payload.dict(),
|
|
)
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self.check_proof_state_deprecated(proofs)
|
|
# convert CheckSpendableResponse_deprecated to CheckSpendableResponse
|
|
states: List[ProofState] = []
|
|
for spendable, pending, p in zip(ret.spendable, ret.pending, proofs):
|
|
if spendable and not pending:
|
|
states.append(ProofState(Y=p.Y, state=ProofSpentState.unspent))
|
|
elif spendable and pending:
|
|
states.append(ProofState(Y=p.Y, state=ProofSpentState.pending))
|
|
else:
|
|
states.append(ProofState(Y=p.Y, state=ProofSpentState.spent))
|
|
ret = PostCheckStateResponse(states=states)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
return PostCheckStateResponse.parse_obj(resp.json())
|
|
|
|
@async_set_httpx_client
|
|
@async_ensure_mint_loaded
|
|
async def restore_promises(
|
|
self, outputs: List[BlindedMessage]
|
|
) -> Tuple[List[BlindedMessage], List[BlindedSignature]]:
|
|
"""
|
|
Asks the mint to restore promises corresponding to outputs.
|
|
"""
|
|
payload = PostMintRequest(quote="restore", outputs=outputs)
|
|
resp = await self.httpx.post(join(self.url, "/v1/restore"), json=payload.dict())
|
|
# BEGIN backwards compatibility < 0.15.0
|
|
# assume the mint has not upgraded yet if we get a 404
|
|
if resp.status_code == 404:
|
|
ret = await self.restore_promises_deprecated(outputs)
|
|
return ret
|
|
# END backwards compatibility < 0.15.0
|
|
self.raise_on_error_request(resp)
|
|
response_dict = resp.json()
|
|
returnObj = PostRestoreResponse.parse_obj(response_dict)
|
|
|
|
# BEGIN backwards compatibility < 0.15.1
|
|
# if the mint returns promises, duplicate into signatures
|
|
if returnObj.promises:
|
|
returnObj.signatures = returnObj.promises
|
|
# END backwards compatibility < 0.15.1
|
|
|
|
return returnObj.outputs, returnObj.signatures
|