Files
nutshell/cashu/wallet/wallet_deprecated.py
callebtc a0ef44dba0 Blind authentication (#675)
* auth server

* cleaning up

* auth ledger class

* class variables -> instance variables

* annotations

* add models and api route

* custom amount and api prefix

* add auth db

* blind auth token working

* jwt working

* clean up

* JWT works

* using openid connect server

* use oauth server with password flow

* new realm

* add keycloak docker

* hopefully not garbage

* auth works

* auth kinda working

* fix cli

* auth works for send and receive

* pass auth_db to Wallet

* auth in info

* refactor

* fix supported

* cache mint info

* fix settings and endpoints

* add description to .env.example

* track changes for openid connect client

* store mint in db

* store credentials

* clean up v1_api.py

* load mint info into auth wallet

* fix first login

* authenticate if refresh token fails

* clear auth also middleware

* use regex

* add cli command

* pw works

* persist keyset amounts

* add errors.py

* do not start auth server if disabled in config

* upadte poetry

* disvoery url

* fix test

* support device code flow

* adopt latest spec changes

* fix code flow

* mint max bat dynamic

* mypy ignore

* fix test

* do not serialize amount in authproof

* all auth flows working

* fix tests

* submodule

* refactor

* test

* dont sleep

* test

* add wallet auth tests

* test differently

* test only keycloak for now

* fix creds

* daemon

* fix test

* install everything

* install jinja

* delete wallet for every test

* auth: use global rate limiter

* test auth rate limit

* keycloak hostname

* move keycloak test data

* reactivate all tests

* add readme

* load proofs

* remove unused code

* remove unused code

* implement change suggestions by ok300

* add error codes

* test errors
2025-01-29 22:48:51 -06:00

457 lines
16 KiB
Python

import json
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,
BlindedMessage_Deprecated,
BlindedSignature,
MintQuoteState,
Proof,
WalletKeyset,
)
from ..core.crypto.secp import PublicKey
from ..core.models import (
CheckFeesRequest_deprecated,
CheckFeesResponse_deprecated,
CheckSpendableRequest_deprecated,
CheckSpendableResponse_deprecated,
GetInfoResponse,
GetInfoResponse_deprecated,
GetMintResponse_deprecated,
KeysetsResponse_deprecated,
KeysetsResponseKeyset,
PostMeltRequest_deprecated,
PostMeltResponse_deprecated,
PostMintQuoteResponse,
PostMintRequest_deprecated,
PostMintResponse_deprecated,
PostRestoreResponse,
PostSwapRequest_Deprecated,
PostSwapResponse_Deprecated,
)
from ..core.settings import settings
from ..tor.tor import TorProxy
from .protocols import SupportsHttpxClient, SupportsMintURL
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_deprecated(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 LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL):
"""Deprecated wallet class, will be removed in the future."""
httpx: httpx.AsyncClient
url: str
@staticmethod
def raise_on_error(
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()
@async_set_httpx_client
async def _get_info_deprecated(self) -> GetInfoResponse:
"""API that gets the mint info.
Returns:
GetInfoResponse: Current mint info
Raises:
Exception: If the mint info request fails
"""
logger.warning(f"Using deprecated API call: {self.url}/info")
resp = await self.httpx.get(
join(self.url, "/info"),
)
self.raise_on_error(resp)
data: dict = resp.json()
mint_info_deprecated: GetInfoResponse_deprecated = (
GetInfoResponse_deprecated.parse_obj(data)
)
mint_info = GetInfoResponse(
**mint_info_deprecated.dict(exclude={"parameter", "nuts", "contact"})
)
# monkeypatch nuts
mint_info.nuts = {}
return mint_info
@async_set_httpx_client
async def _get_keys_deprecated(self, url: str) -> 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
"""
logger.warning(f"Using deprecated API call: {url}/keys")
resp = await self.httpx.get(
f"{url}/keys",
)
self.raise_on_error(resp)
keys: dict = resp.json()
assert len(keys), Exception("did not receive any keys")
keyset_keys = {
int(amt): PublicKey(bytes.fromhex(val), raw=True)
for amt, val in keys.items()
}
keyset = WalletKeyset(unit="sat", public_keys=keyset_keys, mint_url=url)
return keyset
@async_set_httpx_client
async def _get_keyset_deprecated(self, url: str, keyset_id: str) -> WalletKeyset:
"""API that gets the keys of a specific keyset from the mint.
Args:
url (str): Mint URL
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
"""
logger.warning(f"Using deprecated API call: {url}/keys/{keyset_id}")
keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_")
resp = await self.httpx.get(
f"{url}/keys/{keyset_id_urlsafe}",
)
self.raise_on_error(resp)
keys = resp.json()
assert len(keys), Exception("did not receive any keys")
keyset_keys = {
int(amt): PublicKey(bytes.fromhex(val), raw=True)
for amt, val in keys.items()
}
keyset = WalletKeyset(
unit="sat",
id=keyset_id,
public_keys=keyset_keys,
mint_url=url,
)
return keyset
@async_set_httpx_client
async def _get_keysets_deprecated(self, url: str) -> List[KeysetsResponseKeyset]:
"""API that gets a list of all active keysets of the mint.
Args:
url (str): Mint URL
Returns:
KeysetsResponse (List[str]): List of all active keyset IDs of the mint
Raises:
Exception: If no keysets are received from the mint
"""
logger.warning(f"Using deprecated API call: {url}/keysets")
resp = await self.httpx.get(
f"{url}/keysets",
)
self.raise_on_error(resp)
keysets_dict = resp.json()
keysets = KeysetsResponse_deprecated.parse_obj(keysets_dict)
assert len(keysets.keysets), Exception("did not receive any keysets")
keysets_new = [
KeysetsResponseKeyset(id=id, unit="sat", active=True)
for id in keysets.keysets
]
return keysets_new
@async_set_httpx_client
@async_ensure_mint_loaded_deprecated
async def request_mint_deprecated(self, amount) -> PostMintQuoteResponse:
"""Requests a mint from the server and returns Lightning invoice.
Args:
amount (int): Amount of tokens to mint
Returns:
Invoice: Lightning invoice
Raises:
Exception: If the mint request fails
"""
logger.warning("Using deprecated API call: Requesting mint: GET /mint")
resp = await self.httpx.get(f"{self.url}/mint", params={"amount": amount})
self.raise_on_error(resp)
return_dict = resp.json()
mint_response = GetMintResponse_deprecated.parse_obj(return_dict)
decoded_invoice = bolt11.decode(mint_response.pr)
return PostMintQuoteResponse(
quote=mint_response.hash,
request=mint_response.pr,
paid=False,
state=MintQuoteState.unpaid.value,
expiry=decoded_invoice.date + (decoded_invoice.expiry or 0),
pubkey=None,
)
@async_set_httpx_client
@async_ensure_mint_loaded_deprecated
async def mint_deprecated(
self, outputs: List[BlindedMessage], hash: Optional[str] = None
) -> List[BlindedSignature]:
"""Mints new coins and returns a proof of promise.
Args:
outputs (List[BlindedMessage]): Outputs to mint new tokens with
hash (str, optional): Hash of the paid invoice. Defaults to None.
Returns:
list[Proof]: List of proofs.
Raises:
Exception: If the minting fails
"""
outputs_deprecated = [BlindedMessage_Deprecated(**o.dict()) for o in outputs]
outputs_payload = PostMintRequest_deprecated(outputs=outputs_deprecated)
def _mintrequest_include_fields(outputs: List[BlindedMessage]):
"""strips away fields from the model that aren't necessary for the /mint"""
outputs_include = {"amount", "B_"}
return {
"outputs": {i: outputs_include for i in range(len(outputs))},
}
payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore
logger.warning(
"Using deprecated API call:Checking Lightning invoice. POST /mint"
)
resp = await self.httpx.post(
f"{self.url}/mint",
json=payload,
params={
"hash": hash,
"payment_hash": hash, # backwards compatibility pre 0.12.0
},
)
self.raise_on_error(resp)
response_dict = resp.json()
logger.trace("Lightning invoice checked. POST /mint")
promises = PostMintResponse_deprecated.parse_obj(response_dict).promises
return promises
@async_set_httpx_client
@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.
"""
logger.warning("Using deprecated API call: POST /melt")
outputs_deprecated = (
[BlindedMessage_Deprecated(**o.dict()) for o in outputs]
if outputs
else None
)
payload = PostMeltRequest_deprecated(
proofs=proofs, pr=invoice, outputs=outputs_deprecated
)
def _meltrequest_include_fields(proofs: List[Proof]):
"""strips away fields from the model that aren't necessary for the /melt"""
proofs_include = {"id", "amount", "secret", "C", "script"}
return {
"proofs": {i: proofs_include for i in range(len(proofs))},
"pr": ...,
"outputs": ...,
}
resp = await self.httpx.post(
f"{self.url}/melt",
json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore
)
self.raise_on_error(resp)
return_dict = resp.json()
return PostMeltResponse_deprecated.parse_obj(return_dict)
@async_set_httpx_client
@async_ensure_mint_loaded_deprecated
async def split_deprecated(
self,
proofs: List[Proof],
outputs: List[BlindedMessage],
) -> List[BlindedSignature]:
"""Consume proofs and create new promises based on amount split."""
logger.warning("Using deprecated API call: Calling split. POST /split")
outputs_deprecated = [BlindedMessage_Deprecated(**o.dict()) for o in outputs]
split_payload = PostSwapRequest_Deprecated(
proofs=proofs, outputs=outputs_deprecated
)
# construct payload
def _splitrequest_include_fields(proofs: List[Proof]):
"""strips away fields from the model that aren't necessary for the /split"""
proofs_include = {
"id",
"amount",
"secret",
"C",
"witness",
}
return {
"outputs": ...,
"proofs": {i: proofs_include for i in range(len(proofs))},
}
resp = await self.httpx.post(
join(self.url, "/split"),
json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore
)
self.raise_on_error(resp)
promises_dict = resp.json()
mint_response = PostSwapResponse_Deprecated.parse_obj(promises_dict)
promises = [BlindedSignature(**p.dict()) for p in mint_response.promises]
if len(promises) == 0:
raise Exception("received no splits.")
return promises
@async_set_httpx_client
@async_ensure_mint_loaded_deprecated
async def check_proof_state_deprecated(
self, proofs: List[Proof]
) -> CheckSpendableResponse_deprecated:
"""
Checks whether the secrets in proofs are already spent or not and returns a list of booleans.
"""
logger.warning("Using deprecated API call: POST /check")
payload = CheckSpendableRequest_deprecated(proofs=proofs)
def _check_proof_state_include_fields(proofs):
"""strips away fields from the model that aren't necessary for the /split"""
return {
"proofs": {i: {"secret"} for i in range(len(proofs))},
}
resp = await self.httpx.post(
join(self.url, "/check"),
json=payload.dict(include=_check_proof_state_include_fields(proofs)), # type: ignore
)
self.raise_on_error(resp)
return_dict = resp.json()
states = CheckSpendableResponse_deprecated.parse_obj(return_dict)
return states
@async_set_httpx_client
@async_ensure_mint_loaded_deprecated
async def restore_promises_deprecated(
self, outputs: List[BlindedMessage]
) -> Tuple[List[BlindedMessage], List[BlindedSignature]]:
"""
Asks the mint to restore promises corresponding to outputs.
"""
logger.warning("Using deprecated API call: POST /restore")
outputs_deprecated = [BlindedMessage_Deprecated(**o.dict()) for o in outputs]
payload = PostMintRequest_deprecated(outputs=outputs_deprecated)
resp = await self.httpx.post(join(self.url, "/restore"), json=payload.dict())
self.raise_on_error(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
@async_set_httpx_client
@async_ensure_mint_loaded_deprecated
async def check_fees_deprecated(self, payment_request: str):
"""Checks whether the Lightning payment is internal."""
payload = CheckFeesRequest_deprecated(pr=payment_request)
resp = await self.httpx.post(
join(self.url, "/checkfees"),
json=payload.dict(),
)
self.raise_on_error(resp)
return_dict = resp.json()
return CheckFeesResponse_deprecated.parse_obj(return_dict)