From a518274f7eaba3a747e8df3b00c34c5103d360e7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 8 Jan 2024 00:57:15 +0100 Subject: [PATCH] Nutshell cleanup wishlist (#332) * fix keys * fix tests * backwards compatible api upgrade * upgrade seems to work * fix tests * add deprecated api functions * add more tests of backwards compat * add test serialization for nut00 * remove a redundant test * move mint and melt to new api * mypy works * CI: mypy --check-untyped-defs * add deprecated router * add hints and remove logs * fix tests * cleanup * use new mint and melt endpoints * tests passing? * fix mypy * make format * make format * make format * commit * errors gone * save * adjust the API * store quotes in db * make mypy happy * add fakewallet settings * remove LIGHTNING=True and pass quote id for melt * format * tests passing * add CoreLightningRestWallet * add macaroon loader * add correct config * preimage -> proof * move wallet.status() to cli.helpers.print_status() * remove statuses from tests * remove * make format * Use httpx in deprecated wallet * fix cln interface * create invoice before quote * internal transactions and deprecated api testing * fix tests * add deprecated API tests * fastapi type hints break things * fix duplicate wallet error * make format * update poetry in CI to 1.7.1 * precommit restore * remove bolt11 * oops * default poetry * store fee reserve for melt quotes and refactor melt() * works? * make format * test * finally * fix deprecated models * rename v1 endpoints to bolt11 * raise restore and check to v1, bump version to 0.15.0 * add version byte to keyset id * remove redundant fields in json * checks * generate bip32 keyset wip * migrate old keysets * load duplicate keys * duplicate old keysets * revert router changes * add deprecated /check and /restore endpoints * try except invalidate * parse unit from derivation path, adjust keyset id calculation with bytes * remove keyest id from functions again and rely on self.keyset_id * mosts tests work * mint loads multiple derivation paths * make format * properly print units * fix tests * wallet works with multiple units * add strike wallet and choose backend dynamically * fix mypy * add get_payment_quote to lightning backends * make format * fix startup * fix lnbitswallet * fix tests * LightningWallet -> LightningBackend * remove comments * make format * remove msat conversion * add Amount type * fix regtest * use melt_quote as argument for pay_invoice * test old api * fees in sats * fix deprecated fees * fixes * print balance correctly * internally index keyset response by int * add pydantic validation to input models * add timestamps to mint db * store timestamps for invoices, promises, proofs_used * fix wallet migration * rotate keys correctly for testing * remove print * update latest keyset * fix tests * fix test * make format * make format with correct black version * remove nsat and cheese * test against deprecated mint * fix tests? * actually use env var * mint run with env vars * moar test * cleanup * simplify tests, load all keys * try out testing with internal invoices * fix internal melt test * fix test * deprecated checkfees expects appropriate fees * adjust comment * drop lightning table * split migration for testing for now, remove it later * remove unused lightning table * skip_private_key -> skip_db_read * throw error on migration error * reorder * fix migrations * fix lnbits fee return value negative * fix typo * comments * add type * make format * split must use correct amount * fix tests * test deprecated api with internal/external melts * do not split if not necessary * refactor * fix test * make format with new black * cleanup and add comments * add quote state check endpoints * fix deprecated wallet response * split -> swap endpoint * make format * add expiry to quotes, get quote endpoints, and adjust to nut review comments * allow overpayment of melt * add lightning wallet tests * commiting to save * fix tests a bit * make format * remove comments * get mint info * check_spendable default False, and return payment quote checking id * make format * bump version in pyproject * update to /v1/checkstate * make format * fix mint api checks * return witness on /v1/checkstate * no failfast * try fail-fast: false in ci.yaml * fix db lookup * clean up literals --- .github/actions/prepare/action.yml | 2 +- .github/workflows/checks.yml | 4 +- .github/workflows/ci.yml | 10 +- .github/workflows/tests.yml | 7 +- .pre-commit-config.yaml | 19 +- Makefile | 2 +- README.md | 4 +- cashu/core/base.py | 422 ++++++++++--- cashu/core/crypto/keys.py | 38 +- cashu/core/helpers.py | 4 +- cashu/core/legacy.py | 30 +- cashu/core/logging.py | 49 ++ cashu/core/settings.py | 18 +- cashu/lightning/__init__.py | 1 + cashu/lightning/base.py | 47 +- cashu/lightning/corelightningrest.py | 58 +- cashu/lightning/fake.py | 53 +- cashu/lightning/lnbits.py | 47 +- cashu/lightning/lndrest.py | 61 +- cashu/lightning/strike.py | 219 +++++++ cashu/mint/app.py | 56 +- cashu/mint/conditions.py | 6 +- cashu/mint/crud.py | 863 ++++++++++++++++----------- cashu/mint/ledger.py | 745 +++++++++++++++-------- cashu/mint/lightning.py | 137 ----- cashu/mint/main.py | 2 +- cashu/mint/migrations.py | 86 +++ cashu/mint/protocols.py | 10 +- cashu/mint/router.py | 355 ++++++----- cashu/mint/router_deprecated.py | 363 +++++++++++ cashu/mint/startup.py | 54 +- cashu/mint/verification.py | 85 ++- cashu/nostr/client/client.py | 22 +- cashu/nostr/event.py | 26 +- cashu/wallet/api/api_helpers.py | 6 +- cashu/wallet/api/router.py | 65 +- cashu/wallet/cli/cli.py | 184 +++--- cashu/wallet/cli/cli_helpers.py | 74 ++- cashu/wallet/crud.py | 19 +- cashu/wallet/helpers.py | 28 +- cashu/wallet/lightning/lightning.py | 32 +- cashu/wallet/migrations.py | 11 +- cashu/wallet/p2pk.py | 15 +- cashu/wallet/protocols.py | 10 + cashu/wallet/secrets.py | 41 +- cashu/wallet/wallet.py | 697 +++++++++++++-------- cashu/wallet/wallet_deprecated.py | 425 +++++++++++++ poetry.lock | 262 ++++---- pyproject.toml | 8 +- setup.py | 2 +- tests/conftest.py | 72 ++- tests/helpers.py | 2 +- tests/test_cli.py | 110 +++- tests/test_core.py | 49 +- tests/test_mint.py | 121 +++- tests/test_mint_api.py | 374 ++++++++++-- tests/test_mint_api_deprecated.py | 289 +++++++++ tests/test_mint_operations.py | 199 +++++- tests/test_wallet.py | 176 +++--- tests/test_wallet_api.py | 3 +- tests/test_wallet_htlc.py | 6 +- tests/test_wallet_lightning.py | 130 ++++ tests/test_wallet_p2pk.py | 33 +- tests/test_wallet_restore.py | 54 +- 64 files changed, 5359 insertions(+), 2043 deletions(-) create mode 100644 cashu/core/logging.py create mode 100644 cashu/lightning/strike.py delete mode 100644 cashu/mint/lightning.py create mode 100644 cashu/mint/router_deprecated.py create mode 100644 cashu/wallet/wallet_deprecated.py create mode 100644 tests/test_mint_api_deprecated.py create mode 100644 tests/test_wallet_lightning.py diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 85232716..b2c5f092 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -7,7 +7,7 @@ inputs: default: "3.10" poetry-version: description: "Poetry Version" - default: "1.5.1" + default: "1.7.1" runs: using: "composite" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a537475b..d044b185 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,7 +7,7 @@ on: default: "3.10.4" type: string poetry-version: - default: "1.5.1" + default: "1.7.1" type: string jobs: @@ -46,7 +46,7 @@ jobs: - name: Setup mypy run: yes | poetry run mypy cashu --install-types || true - name: Run mypy - run: poetry run mypy cashu --ignore-missing + run: poetry run mypy cashu --ignore-missing --check-untyped-defs ruff: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52c48d99..873fce80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,25 +10,29 @@ jobs: uses: ./.github/workflows/checks.yml tests: strategy: + fail-fast: false matrix: os: [ubuntu-latest] python-version: ["3.10"] - poetry-version: ["1.5.1"] - mint-cache-secrets: ["true", "false"] + poetry-version: ["1.7.1"] + mint-cache-secrets: ["false", "true"] + mint-only-deprecated: ["false", "true"] # db-url: ["", "postgres://cashu:cashu@localhost:5432/test"] # TODO: Postgres test not working db-url: [""] backend-wallet-class: ["FakeWallet"] uses: ./.github/workflows/tests.yml with: + os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} poetry-version: ${{ matrix.poetry-version }} mint-cache-secrets: ${{ matrix.mint-cache-secrets }} + mint-only-deprecated: ${{ matrix.mint-only-deprecated }} regtest: uses: ./.github/workflows/regtest.yml strategy: matrix: python-version: ["3.10"] - poetry-version: ["1.5.1"] + poetry-version: ["1.7.1"] backend-wallet-class: ["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 927f5674..6659b06a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ on: default: "3.10.4" type: string poetry-version: - default: "1.5.1" + default: "1.7.1" type: string db-url: default: "" @@ -18,9 +18,13 @@ on: mint-cache-secrets: default: "false" type: string + mint-only-deprecated: + default: "false" + type: string jobs: poetry: + name: Run (mint-cache-secrets ${{ inputs.mint-cache-secrets }}, mint-only-deprecated ${{ inputs.mint-only-deprecated }}) runs-on: ${{ inputs.os }} services: postgres: @@ -51,6 +55,7 @@ jobs: MINT_PORT: 3337 MINT_DATABASE: ${{ inputs.db-url }} MINT_CACHE_SECRETS: ${{ inputs.mint-cache-secrets }} + DEBUG_MINT_ONLY_DEPRECATED: ${{ inputs.mint-only-deprecated }} TOR: false run: | make test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0bd751a5..cad022a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,17 +12,18 @@ repos: - id: debug-statements - id: mixed-line-ending - id: check-case-conflict - - repo: https://github.com/psf/black - rev: 23.7.0 - hooks: - - id: black + # - repo: https://github.com/psf/black + # rev: 23.11.0 + # hooks: + # - id: black + # args: [--line-length=150] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.0.283 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v1.6.0 - # hooks: - # - id: mypy - # args: [--ignore-missing] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.6.0 + hooks: + - id: mypy + args: [--ignore-missing, --check-untyped-defs] diff --git a/Makefile b/Makefile index 93964999..7bc6d6e9 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ black-check: poetry run black . --check mypy: - poetry run mypy cashu --ignore-missing --check-untyped-defs + poetry run mypy cashu --check-untyped-defs format: black ruff diff --git a/README.md b/README.md index f50d34ad..4d9806b3 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,9 @@ To run the tests in this repository, first install the dev dependencies with poetry install --with dev ``` -Then, make sure to set up your `.env` file to use your local mint and disable Lightning and Tor: +Then, make sure to set up your mint's `.env` file to use a fake Lightning backend and disable Tor: ```bash -LIGHTNING=FALSE +MINT_LIGHTNING_BACKEND=FakeWallet TOR=FALSE ``` You can run the tests with diff --git a/cashu/core/base.py b/cashu/core/base.py index ede0d8fd..8abc69f5 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1,14 +1,24 @@ import base64 import json +import math +from dataclasses import dataclass +from enum import Enum from sqlite3 import Row -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union from loguru import logger -from pydantic import BaseModel - -from .crypto.keys import derive_keys, derive_keyset_id, derive_pubkeys +from pydantic import BaseModel, Field + +from .crypto.keys import ( + derive_keys, + derive_keys_sha256, + derive_keyset_id, + derive_keyset_id_deprecated, + derive_pubkeys, +) from .crypto.secp import PrivateKey, PublicKey from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 +from .settings import settings class DLEQ(BaseModel): @@ -155,6 +165,9 @@ class BlindedMessage(BaseModel): """ amount: int + id: Optional[ + str + ] # DEPRECATION: Only Optional for backwards compatibility with old clients < 0.15 for deprecated API route. B_: str # Hex-encoded blinded message witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) @@ -196,12 +209,52 @@ class Invoice(BaseModel): time_paid: Union[None, str, int, float] = "" +class MeltQuote(BaseModel): + quote: str + method: str + request: str + checking_id: str + unit: str + amount: int + fee_reserve: int + paid: bool + created_time: int = 0 + paid_time: int = 0 + fee_paid: int = 0 + proof: str = "" + + +class MintQuote(BaseModel): + quote: str + method: str + request: str + checking_id: str + unit: str + amount: int + paid: bool + issued: bool + created_time: int = 0 + paid_time: int = 0 + expiry: int = 0 + + # ------- API ------- # ------- API: INFO ------- class GetInfoResponse(BaseModel): + name: Optional[str] = None + pubkey: Optional[str] = None + version: Optional[str] = None + description: Optional[str] = None + description_long: Optional[str] = None + contact: Optional[List[List[str]]] = None + motd: Optional[str] = None + nuts: Optional[Dict[int, Dict[str, Any]]] = None + + +class GetInfoResponse_deprecated(BaseModel): name: Optional[str] = None pubkey: Optional[str] = None version: Optional[str] = None @@ -216,40 +269,121 @@ class GetInfoResponse(BaseModel): # ------- API: KEYS ------- +class KeysResponseKeyset(BaseModel): + id: str + unit: str + keys: Dict[int, str] + + class KeysResponse(BaseModel): - __root__: Dict[str, str] + keysets: List[KeysResponseKeyset] + + +class KeysetsResponseKeyset(BaseModel): + id: str + unit: str + active: bool class KeysetsResponse(BaseModel): + keysets: list[KeysetsResponseKeyset] + + +class KeysResponse_deprecated(BaseModel): + __root__: Dict[str, str] + + +class KeysetsResponse_deprecated(BaseModel): keysets: list[str] +# ------- API: MINT QUOTE ------- + + +class PostMintQuoteRequest(BaseModel): + unit: str = Field(..., max_length=settings.mint_max_request_length) # output unit + amount: int = Field(..., gt=0) # output amount + + +class PostMintQuoteResponse(BaseModel): + quote: str # quote id + request: str # input payment request + paid: bool # whether the request has been paid + expiry: int # expiry of the quote + + # ------- API: MINT ------- class PostMintRequest(BaseModel): - outputs: List[BlindedMessage] + quote: str = Field(..., max_length=settings.mint_max_request_length) # quote id + outputs: List[BlindedMessage] = Field( + ..., max_items=settings.mint_max_request_length + ) class PostMintResponse(BaseModel): - promises: List[BlindedSignature] = [] + signatures: List[BlindedSignature] = [] -class GetMintResponse(BaseModel): +class GetMintResponse_deprecated(BaseModel): pr: str hash: str +class PostMintRequest_deprecated(BaseModel): + outputs: List[BlindedMessage] = Field( + ..., max_items=settings.mint_max_request_length + ) + + +class PostMintResponse_deprecated(BaseModel): + promises: List[BlindedSignature] = [] + + +# ------- API: MELT QUOTE ------- + + +class PostMeltQuoteRequest(BaseModel): + unit: str = Field(..., max_length=settings.mint_max_request_length) # input unit + request: str = Field( + ..., max_length=settings.mint_max_request_length + ) # output payment request + + +class PostMeltQuoteResponse(BaseModel): + quote: str # quote id + amount: int # input amount + fee_reserve: int # input fee reserve + paid: bool # whether the request has been paid + + # ------- API: MELT ------- class PostMeltRequest(BaseModel): - proofs: List[Proof] - pr: str - outputs: Union[List[BlindedMessage], None] + quote: str = Field(..., max_length=settings.mint_max_request_length) # quote id + inputs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + outputs: Union[List[BlindedMessage], None] = Field( + ..., max_items=settings.mint_max_request_length + ) + + +class PostMeltResponse(BaseModel): + paid: Union[bool, None] + payment_preimage: Union[str, None] + change: Union[List[BlindedSignature], None] = None + + +class PostMeltRequest_deprecated(BaseModel): + proofs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + pr: str = Field(..., max_length=settings.mint_max_request_length) + outputs: Union[List[BlindedMessage], None] = Field( + ..., max_items=settings.mint_max_request_length + ) -class GetMeltResponse(BaseModel): +class PostMeltResponse_deprecated(BaseModel): paid: Union[bool, None] preimage: Union[str, None] change: Union[List[BlindedSignature], None] = None @@ -259,17 +393,30 @@ class GetMeltResponse(BaseModel): class PostSplitRequest(BaseModel): - proofs: List[Proof] - amount: Optional[int] = None # deprecated since 0.13.0 - outputs: List[BlindedMessage] + inputs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + outputs: List[BlindedMessage] = Field( + ..., max_items=settings.mint_max_request_length + ) class PostSplitResponse(BaseModel): - promises: List[BlindedSignature] + signatures: List[BlindedSignature] # deprecated since 0.13.0 +class PostSplitRequest_Deprecated(BaseModel): + proofs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + amount: Optional[int] = None + outputs: List[BlindedMessage] = Field( + ..., max_items=settings.mint_max_request_length + ) + + class PostSplitResponse_Deprecated(BaseModel): + promises: List[BlindedSignature] = [] + + +class PostSplitResponse_Very_Deprecated(BaseModel): fst: List[BlindedSignature] = [] snd: List[BlindedSignature] = [] deprecated: str = "The amount field is deprecated since 0.13.0" @@ -278,23 +425,43 @@ class PostSplitResponse_Deprecated(BaseModel): # ------- API: CHECK ------- -class CheckSpendableRequest(BaseModel): - proofs: List[Proof] +class PostCheckStateRequest(BaseModel): + secrets: List[str] = Field(..., max_items=settings.mint_max_request_length) + + +class SpentState(Enum): + unspent = "UNSPENT" + spent = "SPENT" + pending = "PENDING" + + def __str__(self): + return self.name + + +class ProofState(BaseModel): + secret: str + state: SpentState + witness: Optional[str] = None + +class PostCheckStateResponse(BaseModel): + states: List[ProofState] = [] -class CheckSpendableResponse(BaseModel): + +class CheckSpendableRequest_deprecated(BaseModel): + proofs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + + +class CheckSpendableResponse_deprecated(BaseModel): spendable: List[bool] - pending: Optional[List[bool]] = ( - None # TODO: Uncomment when all mints are updated to 0.12.3 and support /check - ) - # with pending tokens (kept for backwards compatibility of new wallets with old mints) + pending: List[bool] -class CheckFeesRequest(BaseModel): - pr: str +class CheckFeesRequest_deprecated(BaseModel): + pr: str = Field(..., max_length=settings.mint_max_request_length) -class CheckFeesResponse(BaseModel): +class CheckFeesResponse_deprecated(BaseModel): fee: Union[int, None] @@ -319,12 +486,70 @@ class KeyBase(BaseModel): pubkey: str +class Unit(Enum): + sat = 0 + msat = 1 + usd = 2 + + def str(self, amount: int) -> str: + if self == Unit.sat: + return f"{amount} sat" + elif self == Unit.msat: + return f"{amount} msat" + elif self == Unit.usd: + return f"${amount/100:.2f} USD" + else: + raise Exception("Invalid unit") + + def __str__(self): + return self.name + + +@dataclass +class Amount: + unit: Unit + amount: int + + def to(self, to_unit: Unit, round: Optional[str] = None): + if self.unit == to_unit: + return self + + if self.unit == Unit.sat: + if to_unit == Unit.msat: + return Amount(to_unit, self.amount * 1000) + else: + raise Exception(f"Cannot convert {self.unit.name} to {to_unit.name}") + elif self.unit == Unit.msat: + if to_unit == Unit.sat: + if round == "up": + return Amount(to_unit, math.ceil(self.amount / 1000)) + elif round == "down": + return Amount(to_unit, math.floor(self.amount / 1000)) + else: + return Amount(to_unit, self.amount // 1000) + else: + raise Exception(f"Cannot convert {self.unit.name} to {to_unit.name}") + else: + return self + + def str(self) -> str: + return self.unit.str(self.amount) + + def __repr__(self): + return self.unit.str(self.amount) + + +class Method(Enum): + bolt11 = 0 + + class WalletKeyset: """ Contains the keyset from the wallets's perspective. """ id: str + unit: Unit public_keys: Dict[int, PublicKey] mint_url: Union[str, None] = None valid_from: Union[str, None] = None @@ -335,12 +560,14 @@ class WalletKeyset: def __init__( self, public_keys: Dict[int, PublicKey], - id=None, + unit: str, + id: Optional[str] = None, mint_url=None, valid_from=None, valid_to=None, first_seen=None, - active=None, + active=True, + use_deprecated_id=False, # BACKWARDS COMPATIBILITY < 0.15.0 ): self.valid_from = valid_from self.valid_to = valid_to @@ -350,17 +577,34 @@ def __init__( self.public_keys = public_keys # overwrite id by deriving it from the public keys - self.id = derive_keyset_id(self.public_keys) + if not id: + self.id = derive_keyset_id(self.public_keys) + else: + self.id = id + + # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 + if use_deprecated_id: + logger.warning( + "Using deprecated keyset id derivation for backwards compatibility <" + " 0.15.0" + ) + self.id = derive_keyset_id_deprecated(self.public_keys) + # END BACKWARDS COMPATIBILITY < 0.15.0 + + self.unit = Unit[unit] + logger.trace(f"Derived keyset id {self.id} from public keys.") - if id and id != self.id: + if id and id != self.id and use_deprecated_id: logger.warning( f"WARNING: Keyset id {self.id} does not match the given id {id}." + " Overwriting." ) + self.id = id def serialize(self): - return json.dumps( - {amount: key.serialize().hex() for amount, key in self.public_keys.items()} - ) + return json.dumps({ + amount: key.serialize().hex() for amount, key in self.public_keys.items() + }) @classmethod def from_row(cls, row: Row): @@ -372,6 +616,7 @@ def deserialize(serialized: str) -> Dict[int, PublicKey]: return cls( id=row["id"], + unit=row["unit"], public_keys=( deserialize(str(row["public_keys"])) if dict(row).get("public_keys") @@ -391,74 +636,107 @@ class MintKeyset: """ id: str - derivation_path: str private_keys: Dict[int, PrivateKey] + active: bool + unit: Unit + derivation_path: str + seed: Optional[str] = None public_keys: Union[Dict[int, PublicKey], None] = None valid_from: Union[str, None] = None valid_to: Union[str, None] = None first_seen: Union[str, None] = None - active: Union[bool, None] = True version: Union[str, None] = None + duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0 + def __init__( self, + *, id="", valid_from=None, valid_to=None, first_seen=None, active=None, - seed: str = "", - derivation_path: str = "", - version: str = "1", + seed: Optional[str] = None, + derivation_path: Optional[str] = None, + unit: Optional[str] = None, + version: str = "0", ): - self.derivation_path = derivation_path + self.derivation_path = derivation_path or "" + self.seed = seed self.id = id self.valid_from = valid_from self.valid_to = valid_to self.first_seen = first_seen - self.active = active + self.active = bool(active) if active is not None else False self.version = version + + self.version_tuple = tuple( + [int(i) for i in self.version.split(".")] if self.version else [] + ) + + # infer unit from derivation path + if not unit: + logger.warning( + f"Unit for keyset {self.derivation_path} not set – attempting to parse" + " from derivation path" + ) + try: + self.unit = Unit( + int(self.derivation_path.split("/")[2].replace("'", "")) + ) + logger.warning(f"Inferred unit: {self.unit.name}") + except Exception: + logger.warning( + "Could not infer unit from derivation path" + f" {self.derivation_path} – assuming 'sat'" + ) + self.unit = Unit.sat + else: + self.unit = Unit[unit] + # generate keys from seed - if seed: - self.generate_keys(seed) + if self.seed and self.derivation_path: + self.generate_keys() - def generate_keys(self, seed): + logger.debug(f"Keyset id: {self.id} ({self.unit.name})") + + @property + def public_keys_hex(self) -> Dict[int, str]: + assert self.public_keys, "public keys not set" + return { + int(amount): key.serialize().hex() + for amount, key in self.public_keys.items() + } + + def generate_keys(self): """Generates keys of a keyset from a seed.""" - backwards_compatibility_pre_0_12 = False - if ( - self.version - and len(self.version.split(".")) > 1 - and int(self.version.split(".")[0]) == 0 - and int(self.version.split(".")[1]) <= 11 - ): - backwards_compatibility_pre_0_12 = True + assert self.seed, "seed not set" + assert self.derivation_path, "derivation path not set" + + if self.version_tuple < (0, 12): # WARNING: Broken key derivation for backwards compatibility with < 0.12 self.private_keys = derive_keys_backwards_compatible_insecure_pre_0_12( - seed, self.derivation_path + self.seed, self.derivation_path ) - else: - self.private_keys = derive_keys(seed, self.derivation_path) - self.public_keys = derive_pubkeys(self.private_keys) # type: ignore - self.id = derive_keyset_id(self.public_keys) # type: ignore - if backwards_compatibility_pre_0_12: + self.public_keys = derive_pubkeys(self.private_keys) # type: ignore logger.warning( f"WARNING: Using weak key derivation for keyset {self.id} (backwards" " compatibility < 0.12)" ) - - -class MintKeysets: - """ - Collection of keyset IDs and the corresponding keyset of the mint. - """ - - keysets: Dict[str, MintKeyset] - - def __init__(self, keysets: List[MintKeyset]): - self.keysets = {k.id: k for k in keysets} # type: ignore - - def get_ids(self): - return [k for k, _ in self.keysets.items()] + self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore + elif self.version_tuple < (0, 15): + self.private_keys = derive_keys_sha256(self.seed, self.derivation_path) + logger.warning( + f"WARNING: Using non-bip32 derivation for keyset {self.id} (backwards" + " compatibility < 0.15)" + ) + self.public_keys = derive_pubkeys(self.private_keys) # type: ignore + self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore + else: + self.private_keys = derive_keys(self.seed, self.derivation_path) + self.public_keys = derive_pubkeys(self.private_keys) # type: ignore + self.id = derive_keyset_id(self.public_keys) # type: ignore # ------- TOKEN ------- @@ -541,7 +819,7 @@ def get_mints(self): @classmethod def deserialize(cls, tokenv3_serialized: str) -> "TokenV3": """ - Takes a TokenV3 and serializes it as "cashuA. + Ingesta a serialized "cashuA" token and returns a TokenV3. """ prefix = "cashuA" assert tokenv3_serialized.startswith(prefix), Exception( diff --git a/cashu/core/crypto/keys.py b/cashu/core/crypto/keys.py index fcf8c9f0..65869cba 100644 --- a/cashu/core/crypto/keys.py +++ b/cashu/core/crypto/keys.py @@ -3,19 +3,29 @@ import random from typing import Dict +from bip32 import BIP32 + from ..settings import settings from .secp import PrivateKey, PublicKey -# entropy = bytes([random.getrandbits(8) for i in range(16)]) -# mnemonic = bip39.mnemonic_from_bytes(entropy) -# seed = bip39.mnemonic_to_seed(mnemonic) -# root = bip32.HDKey.from_seed(seed, version=NETWORKS["main"]["xprv"]) -# bip44_xprv = root.derive("m/44h/1h/0h") -# bip44_xpub = bip44_xprv.to_public() +def derive_keys(mnemonic: str, derivation_path: str): + """ + Deterministic derivation of keys for 2^n values. + """ + bip32 = BIP32.from_seed(mnemonic.encode()) + orders_str = [f"/{i}'" for i in range(settings.max_order)] + return { + 2 + ** i: PrivateKey( + bip32.get_privkey_from_path(derivation_path + orders_str[i]), + raw=True, + ) + for i in range(settings.max_order) + } -def derive_keys(master_key: str, derivation_path: str = ""): +def derive_keys_sha256(master_key: str, derivation_path: str = ""): """ Deterministic derivation of keys for 2^n values. TODO: Implement BIP32. @@ -40,15 +50,23 @@ def derive_pubkey(master_key: str): def derive_pubkeys(keys: Dict[int, PrivateKey]): - return { - amt: keys[amt].pubkey for amt in [2**i for i in range(settings.max_order)] - } + return {amt: keys[amt].pubkey for amt in [2**i for i in range(settings.max_order)]} def derive_keyset_id(keys: Dict[int, PublicKey]): """Deterministic derivation keyset_id from set of public keys.""" # sort public keys by amount sorted_keys = dict(sorted(keys.items())) + pubkeys_concat = b"".join([p.serialize() for _, p in sorted_keys.items()]) + return "00" + hashlib.sha256(pubkeys_concat).hexdigest()[:14] + + +def derive_keyset_id_deprecated(keys: Dict[int, PublicKey]): + """DEPRECATED 0.15.0: Deterministic derivation keyset_id from set of public keys. + DEPRECATION: This method produces base64 keyset ids. Use `derive_keyset_id` instead. + """ + # sort public keys by amount + sorted_keys = dict(sorted(keys.items())) pubkeys_concat = "".join([p.serialize().hex() for _, p in sorted_keys.items()]) return base64.b64encode( hashlib.sha256((pubkeys_concat).encode("utf-8")).digest() diff --git a/cashu/core/helpers.py b/cashu/core/helpers.py index 6b73d507..ff43e225 100644 --- a/cashu/core/helpers.py +++ b/cashu/core/helpers.py @@ -39,10 +39,8 @@ async def run_and_capture_result(): return async_response[0] -def fee_reserve(amount_msat: int, internal=False) -> int: +def fee_reserve(amount_msat: int) -> int: """Function for calculating the Lightning fee reserve""" - if internal: - return 0 return max( int(settings.lightning_reserve_fee_min), int(amount_msat * settings.lightning_fee_percent / 100.0), diff --git a/cashu/core/legacy.py b/cashu/core/legacy.py index 60535618..87c64e96 100644 --- a/cashu/core/legacy.py +++ b/cashu/core/legacy.py @@ -1,38 +1,10 @@ import hashlib -from secp256k1 import PrivateKey, PublicKey +from secp256k1 import PrivateKey from ..core.settings import settings -def hash_to_point_pre_0_3_3(secret_msg): - """ - NOTE: Clients pre 0.3.3 used a different hash_to_curve - - Generates x coordinate from the message hash and checks if the point lies on the curve. - If it does not, it tries computing again a new x coordinate from the hash of the coordinate. - """ - point = None - msg = secret_msg - while point is None: - _hash = hashlib.sha256(msg).hexdigest().encode("utf-8") # type: ignore - try: - # We construct compressed pub which has x coordinate encoded with even y - _hash_list = list(_hash[:33]) # take the 33 bytes and get a list of bytes - _hash_list[0] = 0x02 # set first byte to represent even y coord - _hash = bytes(_hash_list) - point = PublicKey(_hash, raw=True) - except Exception: - msg = _hash - - return point - - -def verify_pre_0_3_3(a, C, secret_msg): - Y = hash_to_point_pre_0_3_3(secret_msg.encode("utf-8")) - return C == Y.mult(a) # type: ignore - - def derive_keys_backwards_compatible_insecure_pre_0_12( master_key: str, derivation_path: str = "" ): diff --git a/cashu/core/logging.py b/cashu/core/logging.py new file mode 100644 index 00000000..a7d2c3f3 --- /dev/null +++ b/cashu/core/logging.py @@ -0,0 +1,49 @@ +import logging +import sys + +from loguru import logger + +from ..core.settings import settings + + +def configure_logger() -> None: + class Formatter: + def __init__(self): + self.padding = 0 + self.minimal_fmt = ( + "{time:YYYY-MM-DD HH:mm:ss.SS} |" + " {level} | {message}\n" + ) + if settings.debug: + self.fmt = ( + "{time:YYYY-MM-DD HH:mm:ss.SS} | {level:" + " <4} |" + " {name}:{function}:{line}" + " | {message}\n" + ) + else: + self.fmt = self.minimal_fmt + + def format(self, record): + function = "{function}".format(**record) + if function == "emit": # uvicorn logs + return self.minimal_fmt + return self.fmt + + class InterceptHandler(logging.Handler): + def emit(self, record): + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + logger.log(level, record.getMessage()) + + logger.remove() + log_level = settings.log_level + if settings.debug and log_level == "INFO": + log_level = "DEBUG" + formatter = Formatter() + logger.add(sys.stderr, level=log_level, format=formatter.format) + + logging.getLogger("uvicorn").handlers = [InterceptHandler()] + logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 1c35e216..689287ea 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.14.1" +VERSION = "0.15.0" def find_env_file(): @@ -25,7 +25,6 @@ def find_env_file(): class CashuSettings(BaseSettings): env_file: str = Field(default=None) - lightning: bool = Field(default=True) lightning_fee_percent: float = Field(default=1.0) lightning_reserve_fee_min: int = Field(default=2000) max_order: int = Field(default=64) @@ -45,11 +44,13 @@ class EnvSettings(CashuSettings): log_level: str = Field(default="INFO") cashu_dir: str = Field(default=os.path.join(str(Path.home()), ".cashu")) debug_profiling: bool = Field(default=False) + debug_mint_only_deprecated: bool = Field(default=False) class MintSettings(CashuSettings): mint_private_key: str = Field(default=None) - mint_derivation_path: str = Field(default="0/0/0/0") + mint_derivation_path: str = Field(default="m/0'/0'/0'") + mint_derivation_path_list: List[str] = Field(default=[]) mint_listen_host: str = Field(default="127.0.0.1") mint_listen_port: int = Field(default=3338) mint_lightning_backend: str = Field(default="LNbitsWallet") @@ -57,11 +58,19 @@ class MintSettings(CashuSettings): mint_peg_out_only: bool = Field(default=False) mint_max_peg_in: int = Field(default=None) mint_max_peg_out: int = Field(default=None) + mint_max_request_length: int = Field(default=1000) mint_max_balance: int = Field(default=None) mint_lnbits_endpoint: str = Field(default=None) mint_lnbits_key: str = Field(default=None) + mint_strike_key: str = Field(default=None) + + +class FakeWalletSettings(MintSettings): + fakewallet_brr: bool = Field(default=True) + fakewallet_delay_payment: bool = Field(default=False) + fakewallet_stochastic_invoice: bool = Field(default=False) mint_cache_secrets: bool = Field(default=True) @@ -75,7 +84,6 @@ class MintInformation(CashuSettings): class WalletSettings(CashuSettings): - lightning: bool = Field(default=True) tor: bool = Field(default=True) socks_host: str = Field(default=None) # deprecated socks_port: int = Field(default=9050) # deprecated @@ -85,6 +93,7 @@ class WalletSettings(CashuSettings): mint_host: str = Field(default="8333.space") mint_port: int = Field(default=3338) wallet_name: str = Field(default="wallet") + wallet_unit: str = Field(default="sat") api_port: int = Field(default=4448) api_host: str = Field(default="127.0.0.1") @@ -121,6 +130,7 @@ class Settings( EnvSettings, LndRestFundingSource, CoreLightningRestFundingSource, + FakeWalletSettings, MintSettings, MintInformation, WalletSettings, diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index 8b3fbc61..6d6cc3e0 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -4,6 +4,7 @@ from .fake import FakeWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401 +from .strike import StrikeUSDWallet # noqa: F401 if settings.mint_lightning_backend is None: raise Exception("MINT_LIGHTNING_BACKEND not configured") diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 17e681c8..089a0290 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -1,12 +1,25 @@ from abc import ABC, abstractmethod -from typing import Coroutine, Optional +from typing import Coroutine, Optional, Union from pydantic import BaseModel +from ..core.base import Amount, MeltQuote, Unit + class StatusResponse(BaseModel): error_message: Optional[str] - balance_msat: int + balance: Union[int, float] + + +class InvoiceQuoteResponse(BaseModel): + checking_id: str + amount: int + + +class PaymentQuoteResponse(BaseModel): + checking_id: str + amount: Amount + fee: Amount class InvoiceResponse(BaseModel): @@ -19,14 +32,14 @@ class InvoiceResponse(BaseModel): class PaymentResponse(BaseModel): ok: Optional[bool] = None # True: paid, False: failed, None: pending or unknown checking_id: Optional[str] = None - fee_msat: Optional[int] = None + fee: Optional[Amount] = None preimage: Optional[str] = None error_message: Optional[str] = None class PaymentStatus(BaseModel): paid: Optional[bool] = None - fee_msat: Optional[int] = None + fee: Optional[Amount] = None preimage: Optional[str] = None @property @@ -48,7 +61,13 @@ def __str__(self) -> str: return "unknown (should never happen)" -class Wallet(ABC): +class LightningBackend(ABC): + units: set[Unit] + + def assert_unit_supported(self, unit: Unit): + if unit not in self.units: + raise Unsupported(f"Unit {unit} is not supported") + @abstractmethod def status(self) -> Coroutine[None, None, StatusResponse]: pass @@ -56,7 +75,7 @@ def status(self) -> Coroutine[None, None, StatusResponse]: @abstractmethod def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, ) -> Coroutine[None, None, InvoiceResponse]: @@ -64,7 +83,7 @@ def create_invoice( @abstractmethod def pay_invoice( - self, bolt11: str, fee_limit_msat: int + self, quote: MeltQuote, fee_limit_msat: int ) -> Coroutine[None, None, PaymentResponse]: pass @@ -80,6 +99,20 @@ def get_payment_status( ) -> Coroutine[None, None, PaymentStatus]: pass + @abstractmethod + async def get_payment_quote( + self, + bolt11: str, + ) -> PaymentQuoteResponse: + pass + + # @abstractmethod + # async def get_invoice_quote( + # self, + # bolt11: str, + # ) -> InvoiceQuoteResponse: + # pass + # @abstractmethod # def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # pass diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 962f9dd4..066a1b89 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -4,23 +4,30 @@ from typing import AsyncGenerator, Dict, Optional import httpx -from bolt11 import Bolt11Exception -from bolt11.decode import decode +from bolt11 import ( + Bolt11Exception, + decode, +) from loguru import logger +from ..core.base import Amount, MeltQuote, Unit +from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, PaymentResponse, PaymentStatus, StatusResponse, Unsupported, - Wallet, ) from .macaroon import load_macaroon -class CoreLightningRestWallet(Wallet): +class CoreLightningRestWallet(LightningBackend): + units = set([Unit.sat, Unit.msat]) + def __init__(self): macaroon = settings.mint_corelightning_rest_macaroon assert macaroon, "missing cln-rest macaroon" @@ -72,26 +79,27 @@ async def status(self) -> StatusResponse: error_message=( f"Failed to connect to {self.url}, got: '{error_message}...'" ), - balance_msat=0, + balance=0, ) data = r.json() if len(data) == 0: - return StatusResponse(error_message="no data", balance_msat=0) + return StatusResponse(error_message="no data", balance=0) balance_msat = int(data.get("localBalance") * 1000) - return StatusResponse(error_message=None, balance_msat=balance_msat) + return StatusResponse(error_message=None, balance=balance_msat) async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, **kwargs, ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) label = f"lbl{random.random()}" data: Dict = { - "amount": amount * 1000, + "amount": amount.to(Unit.msat, round="up").amount, "description": memo, "label": label, } @@ -139,14 +147,16 @@ async def create_invoice( error_message=None, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: try: - invoice = decode(bolt11) + invoice = decode(quote.request) except Bolt11Exception as exc: return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=str(exc), ) @@ -156,7 +166,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=error_message, ) @@ -164,7 +174,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse r = await self.client.post( f"{self.url}/v1/pay", data={ - "invoice": bolt11, + "invoice": quote.request, "maxfeepercent": f"{fee_limit_percent:.11}", "exemptfee": 0, # so fee_limit_percent is applied even on payments # with fee < 5000 millisatoshi (which is default value of exemptfee) @@ -181,7 +191,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=error_message, ) @@ -192,7 +202,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message="payment failed", ) @@ -204,7 +214,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=self.statuses.get(data["status"]), checking_id=checking_id, - fee_msat=fee_msat, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, error_message=None, ) @@ -249,7 +259,7 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: return PaymentStatus( paid=self.statuses.get(pay["status"]), - fee_msat=fee_msat, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, ) except Exception as e: @@ -274,7 +284,6 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: except Exception: continue logger.trace(f"paid invoice: {inv}") - yield inv["label"] # NOTE: use payment_hash when corelightning-rest returns it # when using waitAnyInvoice # payment_hash = inv["payment_hash"] @@ -299,3 +308,14 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: "reconnecting..." ) await asyncio.sleep(0.02) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + ) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 7a14d3f0..dc6a6d91 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -14,22 +14,21 @@ encode, ) +from ..core.base import Amount, MeltQuote, Unit +from ..core.helpers import fee_reserve +from ..core.settings import settings from .base import ( InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, PaymentResponse, PaymentStatus, StatusResponse, - Wallet, ) -BRR = True -DELAY_PAYMENT = False -STOCHASTIC_INVOICE = False - - -class FakeWallet(Wallet): - """https://github.com/lnbits/lnbits""" +class FakeWallet(LightningBackend): + units = set([Unit.sat, Unit.msat]) queue: asyncio.Queue[Bolt11] = asyncio.Queue(0) payment_secrets: Dict[str, str] = dict() paid_invoices: Set[str] = set() @@ -43,17 +42,18 @@ class FakeWallet(Wallet): ).hex() async def status(self) -> StatusResponse: - return StatusResponse(error_message=None, balance_msat=1337) + return StatusResponse(error_message=None, balance=1337) async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, expiry: Optional[int] = None, payment_secret: Optional[bytes] = None, ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) tags = Tags() if description_hash: @@ -83,7 +83,7 @@ async def create_invoice( bolt11 = Bolt11( currency="bc", - amount_msat=MilliSatoshi(amount * 1000), + amount_msat=MilliSatoshi(amount.to(Unit.msat, round="up").amount), date=int(datetime.now().timestamp()), tags=tags, ) @@ -94,19 +94,19 @@ async def create_invoice( ok=True, checking_id=payment_hash, payment_request=payment_request ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: - invoice = decode(bolt11) + async def pay_invoice(self, quote: MeltQuote, fee_limit: int) -> PaymentResponse: + invoice = decode(quote.request) - if DELAY_PAYMENT: + if settings.fakewallet_delay_payment: await asyncio.sleep(5) - if invoice.payment_hash in self.payment_secrets or BRR: + if invoice.payment_hash in self.payment_secrets or settings.fakewallet_brr: await self.queue.put(invoice) self.paid_invoices.add(invoice.payment_hash) return PaymentResponse( ok=True, checking_id=invoice.payment_hash, - fee_msat=0, + fee=Amount(unit=Unit.msat, amount=0), preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64, ) else: @@ -115,10 +115,10 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - if STOCHASTIC_INVOICE: + if settings.fakewallet_stochastic_invoice: paid = random.random() > 0.7 return PaymentStatus(paid=paid) - paid = checking_id in self.paid_invoices or BRR + paid = checking_id in self.paid_invoices or settings.fakewallet_brr return PaymentStatus(paid=paid or None) async def get_payment_status(self, _: str) -> PaymentStatus: @@ -128,3 +128,20 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: value: Bolt11 = await self.queue.get() yield value.payment_hash + + # async def get_invoice_quote(self, bolt11: str) -> InvoiceQuoteResponse: + # invoice_obj = decode(bolt11) + # assert invoice_obj.amount_msat, "invoice has no amount." + # amount = invoice_obj.amount_msat + # return InvoiceQuoteResponse(checking_id="", amount=amount) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + ) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 0f12b0b9..f47558b8 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -2,20 +2,28 @@ from typing import Optional import httpx +from bolt11 import ( + decode, +) +from ..core.base import Amount, MeltQuote, Unit +from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, PaymentResponse, PaymentStatus, StatusResponse, - Wallet, ) -class LNbitsWallet(Wallet): +class LNbitsWallet(LightningBackend): """https://github.com/lnbits/lnbits""" + units = set([Unit.sat]) + def __init__(self): self.endpoint = settings.mint_lnbits_endpoint self.client = httpx.AsyncClient( @@ -30,7 +38,7 @@ async def status(self) -> StatusResponse: except Exception as exc: return StatusResponse( error_message=f"Failed to connect to {self.endpoint} due to: {exc}", - balance_msat=0, + balance=0, ) try: @@ -40,23 +48,25 @@ async def status(self) -> StatusResponse: error_message=( f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'" ), - balance_msat=0, + balance=0, ) if "detail" in data: return StatusResponse( - error_message=f"LNbits error: {data['detail']}", balance_msat=0 + error_message=f"LNbits error: {data['detail']}", balance=0 ) - return StatusResponse(error_message=None, balance_msat=data["balance"]) + return StatusResponse(error_message=None, balance=data["balance"]) async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: - data = {"out": False, "amount": amount} + self.assert_unit_supported(amount.unit) + + data = {"out": False, "amount": amount.to(Unit.sat).amount} if description_hash: data["description_hash"] = description_hash.hex() if unhashed_description: @@ -83,11 +93,13 @@ async def create_invoice( payment_request=payment_request, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: try: r = await self.client.post( url=f"{self.endpoint}/api/v1/payments", - json={"out": True, "bolt11": bolt11}, + json={"out": True, "bolt11": quote.request}, timeout=None, ) r.raise_for_status() @@ -107,7 +119,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=True, checking_id=checking_id, - fee_msat=payment.fee_msat, + fee=payment.fee, preimage=payment.preimage, ) @@ -138,6 +150,17 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: return PaymentStatus( paid=data["paid"], - fee_msat=data["details"]["fee"], + fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), preimage=data["preimage"], ) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + ) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index f3f6c8bf..3aa61a0a 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -5,22 +5,30 @@ from typing import AsyncGenerator, Dict, Optional import httpx +from bolt11 import ( + decode, +) from loguru import logger +from ..core.base import Amount, MeltQuote, Unit +from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, PaymentResponse, PaymentStatus, StatusResponse, - Wallet, ) from .macaroon import load_macaroon -class LndRestWallet(Wallet): +class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" + units = set([Unit.sat, Unit.msat]) + def __init__(self): endpoint = settings.mint_lnd_rest_endpoint cert = settings.mint_lnd_rest_cert @@ -67,7 +75,7 @@ async def status(self) -> StatusResponse: except (httpx.ConnectError, httpx.RequestError) as exc: return StatusResponse( error_message=f"Unable to connect to {self.endpoint}. {exc}", - balance_msat=0, + balance=0, ) try: @@ -75,21 +83,24 @@ async def status(self) -> StatusResponse: if r.is_error: raise Exception except Exception: - return StatusResponse(error_message=r.text[:200], balance_msat=0) + return StatusResponse(error_message=r.text[:200], balance=0) - return StatusResponse( - error_message=None, balance_msat=int(data["balance"]) * 1000 - ) + return StatusResponse(error_message=None, balance=int(data["balance"]) * 1000) async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, **kwargs, ) -> InvoiceResponse: - data: Dict = {"value": amount, "private": True, "memo": memo or ""} + self.assert_unit_supported(amount.unit) + data: Dict = { + "value": amount.to(Unit.sat).amount, + "private": True, + "memo": memo or "", + } if kwargs.get("expiry"): data["expiry"] = kwargs["expiry"] if description_hash: @@ -101,7 +112,10 @@ async def create_invoice( hashlib.sha256(unhashed_description).digest() ).decode("ascii") - r = await self.client.post(url="/v1/invoices", json=data) + try: + r = await self.client.post(url="/v1/invoices", json=data) + except Exception as e: + raise Exception(f"failed to create invoice: {e}") if r.is_error: error_message = r.text @@ -128,14 +142,16 @@ async def create_invoice( error_message=None, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: # set the fee limit for the payment lnrpcFeeLimit = dict() lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}" r = await self.client.post( url="/v1/channels/transactions", - json={"payment_request": bolt11, "fee_limit": lnrpcFeeLimit}, + json={"payment_request": quote.request, "fee_limit": lnrpcFeeLimit}, timeout=None, ) @@ -144,7 +160,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=error_message, ) @@ -156,7 +172,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=True, checking_id=checking_id, - fee_msat=fee_msat, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, error_message=None, ) @@ -209,7 +225,11 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: if payment is not None and payment.get("status"): return PaymentStatus( paid=statuses[payment["status"]], - fee_msat=payment.get("fee_msat"), + fee=( + Amount(unit=Unit.msat, amount=payment.get("fee_msat")) + if payment.get("fee_msat") + else None + ), preimage=payment.get("payment_preimage"), ) else: @@ -240,3 +260,14 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: " seconds" ) await asyncio.sleep(5) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + ) diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py new file mode 100644 index 00000000..c755ad17 --- /dev/null +++ b/cashu/lightning/strike.py @@ -0,0 +1,219 @@ +# type: ignore +import secrets +from typing import Dict, Optional + +import httpx + +from ..core.base import Amount, MeltQuote, Unit +from ..core.settings import settings +from .base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, +) + + +class StrikeUSDWallet(LightningBackend): + """https://github.com/lnbits/lnbits""" + + units = [Unit.usd] + + def __init__(self): + self.endpoint = "https://api.strike.me" + + # bearer auth with settings.mint_strike_key + bearer_auth = { + "Authorization": f"Bearer {settings.mint_strike_key}", + } + self.client = httpx.AsyncClient( + verify=not settings.debug, + headers=bearer_auth, + ) + + async def status(self) -> StatusResponse: + try: + r = await self.client.get(url=f"{self.endpoint}/v1/balances", timeout=15) + r.raise_for_status() + except Exception as exc: + return StatusResponse( + error_message=f"Failed to connect to {self.endpoint} due to: {exc}", + balance=0, + ) + + try: + data = r.json() + except Exception: + return StatusResponse( + error_message=( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'" + ), + balance=0, + ) + + for balance in data: + if balance["currency"] == "USD": + return StatusResponse(error_message=None, balance=balance["total"]) + + async def create_invoice( + self, + amount: Amount, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) + + data: Dict = {"out": False, "amount": amount} + if description_hash: + data["description_hash"] = description_hash.hex() + if unhashed_description: + data["unhashed_description"] = unhashed_description.hex() + + data["memo"] = memo or "" + payload = { + "correlationId": secrets.token_hex(16), + "description": "Invoice for order 123", + "amount": {"amount": str(amount.amount / 100), "currency": "USD"}, + } + try: + r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload) + r.raise_for_status() + except Exception: + return InvoiceResponse( + paid=False, + checking_id=None, + payment_request=None, + error_message=r.json()["detail"], + ) + + quote = r.json() + invoice_id = quote.get("invoiceId") + + try: + payload = {"descriptionHash": secrets.token_hex(32)} + r2 = await self.client.post( + f"{self.endpoint}/v1/invoices/{invoice_id}/quote", json=payload + ) + except Exception: + return InvoiceResponse( + paid=False, + checking_id=None, + payment_request=None, + error_message=r.json()["detail"], + ) + + data2 = r2.json() + payment_request = data2.get("lnInvoice") + assert payment_request, "Did not receive an invoice" + checking_id = invoice_id + return InvoiceResponse( + ok=True, + checking_id=checking_id, + payment_request=payment_request, + error_message=None, + ) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + try: + r = await self.client.post( + url=f"{self.endpoint}/v1/payment-quotes/lightning", + json={"sourceCurrency": "USD", "lnInvoice": bolt11}, + timeout=None, + ) + r.raise_for_status() + except Exception: + error_message = r.json()["data"]["message"] + raise Exception(error_message) + data = r.json() + + amount_cent = int(float(data.get("amount").get("amount")) * 100) + quote = PaymentQuoteResponse( + amount=Amount(Unit.usd, amount=amount_cent), + checking_id=data.get("paymentQuoteId"), + fee=Amount(Unit.usd, 0), + ) + return quote + + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: + # we need to get the checking_id of this quote + try: + r = await self.client.patch( + url=f"{self.endpoint}/v1/payment-quotes/{quote.checking_id}/execute", + timeout=None, + ) + r.raise_for_status() + except Exception: + error_message = r.json()["data"]["message"] + return PaymentResponse( + ok=None, + checking_id=None, + fee=None, + preimage=None, + error_message=error_message, + ) + + data = r.json() + states = {"PENDING": None, "COMPLETED": True, "FAILED": False} + if states[data.get("state")]: + return PaymentResponse( + ok=True, checking_id=None, fee=None, preimage=None, error_message=None + ) + else: + return PaymentResponse( + ok=False, checking_id=None, fee=None, preimage=None, error_message=None + ) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}") + r.raise_for_status() + except Exception: + return PaymentStatus(paid=None) + data = r.json() + states = {"PENDING": None, "UNPAID": None, "PAID": True, "CANCELLED": False} + return PaymentStatus(paid=states[data["state"]]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}") + r.raise_for_status() + except Exception: + return PaymentStatus(paid=None) + data = r.json() + if "paid" not in data and "details" not in data: + return PaymentStatus(paid=None) + + return PaymentStatus( + paid=data["paid"], + fee_msat=data["details"]["fee"], + preimage=data["preimage"], + ) + + # async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + # url = f"{self.endpoint}/api/v1/payments/sse" + + # while True: + # try: + # async with requests.stream("GET", url) as r: + # async for line in r.aiter_lines(): + # if line.startswith("data:"): + # try: + # data = json.loads(line[5:]) + # except json.decoder.JSONDecodeError: + # continue + + # if type(data) is not dict: + # continue + + # yield data["payment_hash"] # payment_hash + + # except: + # pass + + # print("lost connection to lnbits /payments/sse, retrying in 5 seconds") + # await asyncio.sleep(5) diff --git a/cashu/mint/app.py b/cashu/mint/app.py index 0de2d096..50058f21 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -1,4 +1,3 @@ -import logging import sys from traceback import print_exception @@ -14,8 +13,10 @@ from starlette.requests import Request from ..core.errors import CashuError +from ..core.logging import configure_logger from ..core.settings import settings from .router import router +from .router_deprecated import router_deprecated from .startup import start_mint_init if settings.debug_profiling: @@ -37,48 +38,6 @@ def create_app(config_object="core.settings") -> FastAPI: - def configure_logger() -> None: - class Formatter: - def __init__(self): - self.padding = 0 - self.minimal_fmt = ( - "{time:YYYY-MM-DD HH:mm:ss.SS} |" - " {level} | {message}\n" - ) - if settings.debug: - self.fmt = ( - "{time:YYYY-MM-DD HH:mm:ss.SS} | {level:" - " <4} |" - " {name}:{function}:{line}" - " | {message}\n" - ) - else: - self.fmt = self.minimal_fmt - - def format(self, record): - function = "{function}".format(**record) - if function == "emit": # uvicorn logs - return self.minimal_fmt - return self.fmt - - class InterceptHandler(logging.Handler): - def emit(self, record): - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - logger.log(level, record.getMessage()) - - logger.remove() - log_level = settings.log_level - if settings.debug and log_level == "INFO": - log_level = "DEBUG" - formatter = Formatter() - logger.add(sys.stderr, level=log_level, format=formatter.format) - - logging.getLogger("uvicorn").handlers = [InterceptHandler()] - logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] - configure_logger() # middleware = [ @@ -99,8 +58,8 @@ def emit(self, record): ] app = FastAPI( - title="Cashu Python Mint", - description="Ecash wallet and mint for Bitcoin", + title="Nutshell Cashu Mint", + description="Ecash wallet and mint based on the Cashu protocol.", version=settings.version, license_info={ "name": "MIT License", @@ -176,5 +135,10 @@ async def startup_mint(): await start_mint_init() -app.include_router(router=router) +if settings.debug_mint_only_deprecated: + app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True) +else: + app.include_router(router=router, tags=["Mint"]) + app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True) + app.add_exception_handler(RequestValidationError, request_validation_exception_handler) diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 4967a07f..d48c06ef 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -255,9 +255,9 @@ def _verify_output_p2pk_spending_conditions( # check if all secrets are P2PK # NOTE: This is redundant, because P2PKSecret.from_secret() already checks for the kind # Leaving it in for explicitness - if not all( - [SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets] - ): + if not all([ + SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets + ]): # not all secrets are P2PK return True diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 8797dc20..2d9660a3 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,10 +1,18 @@ +import time +from abc import ABC, abstractmethod from typing import Any, List, Optional -from ..core.base import BlindedSignature, Invoice, MintKeyset, Proof +from ..core.base import ( + BlindedSignature, + MeltQuote, + MintKeyset, + MintQuote, + Proof, +) from ..core.db import Connection, Database, table_with_schema -class LedgerCrud: +class LedgerCrud(ABC): """ Database interface for Cashu mint. @@ -12,118 +20,80 @@ class LedgerCrud: to use their own database. """ + @abstractmethod async def get_keyset( self, + *, db: Database, id: str = "", derivation_path: str = "", conn: Optional[Connection] = None, - ): - return await get_keyset( - db=db, - id=id, - derivation_path=derivation_path, - conn=conn, - ) - - async def get_lightning_invoice( - self, - db: Database, - id: str, - conn: Optional[Connection] = None, - ) -> Optional[Invoice]: - return await get_lightning_invoice( - db=db, - id=id, - conn=conn, - ) + ) -> List[MintKeyset]: ... - async def get_secrets_used( + @abstractmethod + async def get_spent_proofs( self, + *, db: Database, conn: Optional[Connection] = None, - ) -> List[str]: - return await get_secrets_used( - db=db, - conn=conn, - ) + ) -> List[Proof]: ... async def get_proof_used( self, + *, db: Database, - proof: Proof, + secret: str, conn: Optional[Connection] = None, - ) -> Optional[Proof]: - return await get_proof_used( - db=db, - proof=proof, - conn=conn, - ) + ) -> Optional[Proof]: ... + @abstractmethod async def invalidate_proof( self, + *, db: Database, proof: Proof, conn: Optional[Connection] = None, - ): - return await invalidate_proof( - db=db, - proof=proof, - conn=conn, - ) + ) -> None: ... + @abstractmethod async def get_proofs_pending( self, + *, db: Database, conn: Optional[Connection] = None, - ): - return await get_proofs_pending(db=db, conn=conn) + ) -> List[Proof]: ... + @abstractmethod async def set_proof_pending( self, + *, db: Database, proof: Proof, conn: Optional[Connection] = None, - ): - return await set_proof_pending( - db=db, - proof=proof, - conn=conn, - ) + ) -> None: ... + @abstractmethod async def unset_proof_pending( - self, proof: Proof, db: Database, conn: Optional[Connection] = None - ): - return await unset_proof_pending( - proof=proof, - db=db, - conn=conn, - ) + self, *, proof: Proof, db: Database, conn: Optional[Connection] = None + ) -> None: ... + @abstractmethod async def store_keyset( self, + *, db: Database, keyset: MintKeyset, conn: Optional[Connection] = None, - ): - return await store_keyset( - db=db, - keyset=keyset, - conn=conn, - ) + ) -> None: ... - async def store_lightning_invoice( + @abstractmethod + async def get_balance( self, db: Database, - invoice: Invoice, conn: Optional[Connection] = None, - ): - return await store_lightning_invoice( - db=db, - invoice=invoice, - conn=conn, - ) + ) -> int: ... + @abstractmethod async def store_promise( self, *, @@ -135,42 +105,427 @@ async def store_promise( e: str = "", s: str = "", conn: Optional[Connection] = None, - ): - return await store_promise( - db=db, - amount=amount, - B_=B_, - C_=C_, - id=id, - e=e, - s=s, - conn=conn, + ) -> None: ... + + @abstractmethod + async def get_promise( + self, + *, + db: Database, + B_: str, + conn: Optional[Connection] = None, + ) -> Optional[BlindedSignature]: ... + + @abstractmethod + async def store_mint_quote( + self, + *, + quote: MintQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: ... + + @abstractmethod + async def get_mint_quote( + self, + *, + quote_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[MintQuote]: ... + + @abstractmethod + async def get_mint_quote_by_checking_id( + self, + *, + checking_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[MintQuote]: ... + + @abstractmethod + async def update_mint_quote( + self, + *, + quote: MintQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: ... + + # @abstractmethod + # async def update_mint_quote_paid( + # self, + # *, + # quote_id: str, + # paid: bool, + # db: Database, + # conn: Optional[Connection] = None, + # ) -> None: ... + + @abstractmethod + async def store_melt_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: ... + + @abstractmethod + async def get_melt_quote( + self, + *, + quote_id: str, + db: Database, + checking_id: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> Optional[MeltQuote]: ... + + @abstractmethod + async def update_melt_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: ... + + +class LedgerCrudSqlite(LedgerCrud): + """Implementation of LedgerCrud for sqlite. + + Args: + LedgerCrud (ABC): Abstract base class for LedgerCrud. + """ + + async def store_promise( + self, + *, + db: Database, + amount: int, + B_: str, + C_: str, + id: str, + e: str = "", + s: str = "", + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'promises')} + (amount, B_b, C_b, e, s, id, created) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + amount, + B_, + C_, + e, + s, + id, + int(time.time()), + ), ) async def get_promise( self, + *, db: Database, B_: str, conn: Optional[Connection] = None, - ): - return await get_promise( - db=db, - B_=B_, - conn=conn, + ) -> Optional[BlindedSignature]: + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'promises')} + WHERE B_b = ? + """, + (str(B_),), ) + return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None - async def update_lightning_invoice( + async def get_spent_proofs( self, + *, db: Database, - id: str, - issued: bool, - conn: Optional[Connection] = None, - ): - return await update_lightning_invoice( - db=db, - id=id, - issued=issued, - conn=conn, + conn: Optional[Connection] = None, + ) -> List[Proof]: + rows = await (conn or db).fetchall(f""" + SELECT * from {table_with_schema(db, 'proofs_used')} + """) + return [Proof(**r) for r in rows] if rows else [] + + async def invalidate_proof( + self, + *, + db: Database, + proof: Proof, + conn: Optional[Connection] = None, + ) -> None: + # we add the proof and secret to the used list + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'proofs_used')} + (amount, C, secret, id, witness, created) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + proof.amount, + proof.C, + proof.secret, + proof.id, + proof.witness, + int(time.time()), + ), + ) + + async def get_proofs_pending( + self, + *, + db: Database, + conn: Optional[Connection] = None, + ) -> List[Proof]: + rows = await (conn or db).fetchall(f""" + SELECT * from {table_with_schema(db, 'proofs_pending')} + """) + return [Proof(**r) for r in rows] + + async def set_proof_pending( + self, + *, + db: Database, + proof: Proof, + conn: Optional[Connection] = None, + ) -> None: + # we add the proof and secret to the used list + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'proofs_pending')} + (amount, C, secret, created) + VALUES (?, ?, ?, ?) + """, + ( + proof.amount, + str(proof.C), + str(proof.secret), + int(time.time()), + ), + ) + + async def unset_proof_pending( + self, + *, + proof: Proof, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f""" + DELETE FROM {table_with_schema(db, 'proofs_pending')} + WHERE secret = ? + """, + (proof.secret,), + ) + + async def store_mint_quote( + self, + *, + quote: MintQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'mint_quotes')} + (quote, method, request, checking_id, unit, amount, issued, paid, created_time, paid_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + quote.quote, + quote.method, + quote.request, + quote.checking_id, + quote.unit, + quote.amount, + quote.issued, + quote.paid, + quote.created_time, + quote.paid_time, + ), + ) + + async def get_mint_quote( + self, + *, + quote_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[MintQuote]: + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'mint_quotes')} + WHERE quote = ? + """, + (quote_id,), + ) + return MintQuote(**dict(row)) if row else None + + async def get_mint_quote_by_checking_id( + self, + *, + checking_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[MintQuote]: + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'mint_quotes')} + WHERE checking_id = ? + """, + (checking_id,), + ) + return MintQuote(**dict(row)) if row else None + + async def update_mint_quote( + self, + *, + quote: MintQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f"UPDATE {table_with_schema(db, 'mint_quotes')} SET issued = ?, paid = ?," + " paid_time = ? WHERE quote = ?", + ( + quote.issued, + quote.paid, + quote.paid_time, + quote.quote, + ), + ) + + # async def update_mint_quote_paid( + # self, + # *, + # quote_id: str, + # paid: bool, + # db: Database, + # conn: Optional[Connection] = None, + # ) -> None: + # await (conn or db).execute( + # f"UPDATE {table_with_schema(db, 'mint_quotes')} SET paid = ? WHERE" + # " quote = ?", + # ( + # paid, + # quote_id, + # ), + # ) + + async def store_melt_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'melt_quotes')} + (quote, method, request, checking_id, unit, amount, fee_reserve, paid, created_time, paid_time, fee_paid, proof) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + quote.quote, + quote.method, + quote.request, + quote.checking_id, + quote.unit, + quote.amount, + quote.fee_reserve or 0, + quote.paid, + quote.created_time, + quote.paid_time, + quote.fee_paid, + quote.proof, + ), + ) + + async def get_melt_quote( + self, + *, + quote_id: str, + db: Database, + checking_id: Optional[str] = None, + request: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> Optional[MeltQuote]: + clauses = [] + values: List[Any] = [] + if quote_id: + clauses.append("quote = ?") + values.append(quote_id) + if checking_id: + clauses.append("checking_id = ?") + values.append(checking_id) + if request: + clauses.append("request = ?") + values.append(request) + where = "" + if clauses: + where = f"WHERE {' AND '.join(clauses)}" + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'melt_quotes')} + {where} + """, + tuple(values), + ) + if row is None: + return None + return MeltQuote(**dict(row)) if row else None + + async def update_melt_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, fee_paid = ?," + " paid_time = ?, proof = ? WHERE quote = ?", + ( + quote.paid, + quote.fee_paid, + quote.paid_time, + quote.proof, + quote.quote, + ), + ) + + async def store_keyset( + self, + *, + db: Database, + keyset: MintKeyset, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( # type: ignore + f""" + INSERT INTO {table_with_schema(db, 'keysets')} + (id, seed, derivation_path, valid_from, valid_to, first_seen, active, version, unit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + keyset.id, + keyset.seed, + keyset.derivation_path, + keyset.valid_from or int(time.time()), + keyset.valid_to or int(time.time()), + keyset.first_seen or int(time.time()), + True, + keyset.version, + keyset.unit.name, + ), ) async def get_balance( @@ -178,270 +533,60 @@ async def get_balance( db: Database, conn: Optional[Connection] = None, ) -> int: - return await get_balance( - db=db, - conn=conn, - ) + row = await (conn or db).fetchone(f""" + SELECT * from {table_with_schema(db, 'balance')} + """) + assert row, "Balance not found" + return int(row[0]) + async def get_keyset( + self, + *, + db: Database, + id: Optional[str] = None, + derivation_path: Optional[str] = None, + unit: Optional[str] = None, + active: Optional[bool] = None, + conn: Optional[Connection] = None, + ) -> List[MintKeyset]: + clauses = [] + values: List[Any] = [] + if active is not None: + clauses.append("active = ?") + values.append(active) + if id is not None: + clauses.append("id = ?") + values.append(id) + if derivation_path is not None: + clauses.append("derivation_path = ?") + values.append(derivation_path) + if unit is not None: + clauses.append("unit = ?") + values.append(unit) + where = "" + if clauses: + where = f"WHERE {' AND '.join(clauses)}" + + rows = await (conn or db).fetchall( # type: ignore + f""" + SELECT * from {table_with_schema(db, 'keysets')} + {where} + """, + tuple(values), + ) + return [MintKeyset(**row) for row in rows] -async def store_promise( - *, - db: Database, - amount: int, - B_: str, - C_: str, - id: str, - e: str = "", - s: str = "", - conn: Optional[Connection] = None, -): - await (conn or db).execute( - f""" - INSERT INTO {table_with_schema(db, 'promises')} - (amount, B_b, C_b, e, s, id) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - amount, - B_, - C_, - e, - s, - id, - ), - ) - - -async def get_promise( - db: Database, - B_: str, - conn: Optional[Connection] = None, -): - row = await (conn or db).fetchone( - f""" - SELECT * from {table_with_schema(db, 'promises')} - WHERE B_b = ? - """, - (str(B_),), - ) - return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None - - -async def get_secrets_used( - db: Database, - conn: Optional[Connection] = None, -) -> List[str]: - rows = await (conn or db).fetchall(f""" - SELECT secret from {table_with_schema(db, 'proofs_used')} - """) - return [row[0] for row in rows] - - -async def invalidate_proof( - db: Database, - proof: Proof, - conn: Optional[Connection] = None, -): - # we add the proof and secret to the used list - await (conn or db).execute( - f""" - INSERT INTO {table_with_schema(db, 'proofs_used')} - (amount, C, secret, id) - VALUES (?, ?, ?, ?) - """, - ( - proof.amount, - str(proof.C), - str(proof.secret), - str(proof.id), - ), - ) - - -async def get_proofs_pending( - db: Database, - conn: Optional[Connection] = None, -): - rows = await (conn or db).fetchall(f""" - SELECT * from {table_with_schema(db, 'proofs_pending')} - """) - return [Proof(**r) for r in rows] - - -async def get_proof_used( - db: Database, - proof: Proof, - conn: Optional[Connection] = None, -) -> Optional[Proof]: - row = await (conn or db).fetchone( - f""" - SELECT 1 from {table_with_schema(db, 'proofs_used')} - WHERE secret = ? - """, - (str(proof.secret),), - ) - return Proof(**row) if row else None - - -async def set_proof_pending( - db: Database, - proof: Proof, - conn: Optional[Connection] = None, -): - # we add the proof and secret to the used list - await (conn or db).execute( - f""" - INSERT INTO {table_with_schema(db, 'proofs_pending')} - (amount, C, secret) - VALUES (?, ?, ?) - """, - ( - proof.amount, - str(proof.C), - str(proof.secret), - ), - ) - - -async def unset_proof_pending( - proof: Proof, - db: Database, - conn: Optional[Connection] = None, -): - await (conn or db).execute( - f""" - DELETE FROM {table_with_schema(db, 'proofs_pending')} - WHERE secret = ? - """, - (str(proof["secret"]),), - ) - - -async def store_lightning_invoice( - db: Database, - invoice: Invoice, - conn: Optional[Connection] = None, -): - await (conn or db).execute( - f""" - INSERT INTO {table_with_schema(db, 'invoices')} - (amount, bolt11, id, issued, payment_hash, out) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - invoice.amount, - invoice.bolt11, - invoice.id, - invoice.issued, - invoice.payment_hash, - invoice.out, - ), - ) - - -async def get_lightning_invoice( - db: Database, - *, - id: Optional[str] = None, - payment_hash: Optional[str] = None, - conn: Optional[Connection] = None, -): - clauses = [] - values: List[Any] = [] - if id: - clauses.append("id = ?") - values.append(id) - if payment_hash: - clauses.append("payment_hash = ?") - values.append(payment_hash) - where = "" - if clauses: - where = f"WHERE {' AND '.join(clauses)}" - row = await (conn or db).fetchone( - f""" - SELECT * from {table_with_schema(db, 'invoices')} - {where} - """, - tuple(values), - ) - row_dict = dict(row) - return Invoice(**row_dict) if row_dict else None - - -async def update_lightning_invoice( - db: Database, - id: str, - issued: bool, - conn: Optional[Connection] = None, -): - await (conn or db).execute( - f"UPDATE {table_with_schema(db, 'invoices')} SET issued = ? WHERE id = ?", - ( - issued, - id, - ), - ) - - -async def store_keyset( - db: Database, - keyset: MintKeyset, - conn: Optional[Connection] = None, -): - await (conn or db).execute( # type: ignore - f""" - INSERT INTO {table_with_schema(db, 'keysets')} - (id, derivation_path, valid_from, valid_to, first_seen, active, version) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - keyset.id, - keyset.derivation_path, - keyset.valid_from or db.timestamp_now, - keyset.valid_to or db.timestamp_now, - keyset.first_seen or db.timestamp_now, - True, - keyset.version, - ), - ) - - -async def get_keyset( - db: Database, - id: str = "", - derivation_path: str = "", - conn: Optional[Connection] = None, -): - clauses = [] - values: List[Any] = [] - clauses.append("active = ?") - values.append(True) - if id: - clauses.append("id = ?") - values.append(id) - if derivation_path: - clauses.append("derivation_path = ?") - values.append(derivation_path) - where = "" - if clauses: - where = f"WHERE {' AND '.join(clauses)}" - - rows = await (conn or db).fetchall( # type: ignore - f""" - SELECT * from {table_with_schema(db, 'keysets')} - {where} - """, - tuple(values), - ) - return [MintKeyset(**row) for row in rows] - - -async def get_balance( - db: Database, - conn: Optional[Connection] = None, -) -> int: - row = await (conn or db).fetchone(f""" - SELECT * from {table_with_schema(db, 'balance')} - """) - assert row, "Balance not found" - return int(row[0]) + async def get_proof_used( + self, + db: Database, + secret: str, + conn: Optional[Connection] = None, + ) -> Optional[Proof]: + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'proofs_used')} + WHERE secret = ? + """, + (secret,), + ) + return Proof(**row) if row else None diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 27afde9d..78360984 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,22 +1,37 @@ import asyncio +import copy import math -from typing import Dict, List, Optional, Tuple +import time +from typing import Dict, List, Mapping, Optional, Tuple import bolt11 from loguru import logger from ..core.base import ( DLEQ, + Amount, BlindedMessage, BlindedSignature, - Invoice, + MeltQuote, + Method, MintKeyset, - MintKeysets, + MintQuote, + PostMeltQuoteRequest, + PostMeltQuoteResponse, + PostMintQuoteRequest, Proof, + ProofState, + SpentState, + Unit, ) from ..core.crypto import b_dhke -from ..core.crypto.keys import derive_pubkey, random_hash -from ..core.crypto.secp import PublicKey +from ..core.crypto.keys import ( + derive_keyset_id, + derive_keyset_id_deprecated, + derive_pubkey, + random_hash, +) +from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database, get_db_connection from ..core.errors import ( KeysetError, @@ -25,42 +40,48 @@ NotAllowedError, TransactionError, ) -from ..core.helpers import fee_reserve, sum_proofs +from ..core.helpers import sum_proofs from ..core.settings import settings from ..core.split import amount_split -from ..lightning.base import PaymentResponse, Wallet -from ..mint.crud import LedgerCrud +from ..lightning.base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentStatus, +) +from ..mint.crud import LedgerCrudSqlite from .conditions import LedgerSpendingConditions -from .lightning import LedgerLightning from .verification import LedgerVerification -class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerLightning): +class Ledger(LedgerVerification, LedgerSpendingConditions): + backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks proofs_pending_lock: asyncio.Lock = ( asyncio.Lock() ) # holds locks for proofs_pending database + keysets: Dict[str, MintKeyset] = {} def __init__( self, db: Database, seed: str, - lightning: Wallet, - crud: LedgerCrud, + backends: Mapping[Method, Mapping[Unit, LightningBackend]], derivation_path="", + crud=LedgerCrudSqlite(), ): self.master_key = seed self.derivation_path = derivation_path self.db = db self.crud = crud - self.lightning = lightning + self.backends = backends self.pubkey = derive_pubkey(self.master_key) - self.keysets = MintKeysets([]) + self.spent_proofs: Dict[str, Proof] = {} # ------- KEYS ------- - async def load_keyset(self, derivation_path, autosave=True) -> MintKeyset: + async def activate_keyset(self, derivation_path, autosave=True) -> MintKeyset: """Load the keyset for a derivation path if it already exists. If not generate new one and store in the db. Args: @@ -70,11 +91,7 @@ async def load_keyset(self, derivation_path, autosave=True) -> MintKeyset: Returns: MintKeyset: Keyset """ - keyset = MintKeyset( - seed=self.master_key, - derivation_path=derivation_path, - version=settings.version, - ) + logger.debug(f"Activating keyset for derivation path {derivation_path}") # load the keyset from db logger.trace(f"crud: loading keyset for {derivation_path}") tmp_keyset_local: List[MintKeyset] = await self.crud.get_keyset( @@ -82,66 +99,104 @@ async def load_keyset(self, derivation_path, autosave=True) -> MintKeyset: ) logger.trace(f"crud: loaded {len(tmp_keyset_local)} keysets") if tmp_keyset_local: - # we have a keyset for this derivation path + # we have a keyset with this derivation path in the database keyset = tmp_keyset_local[0] - # we need to initialize it - keyset.generate_keys(self.master_key) + # we keys are not stored in the database but only their derivation path + # so we might need to generate the keys for keysets loaded from the database + if not len(keyset.private_keys): + keyset.generate_keys() else: + logger.trace(f"crud: no keyset for {derivation_path}") # no keyset for this derivation path yet - # we generate a new keyset - logger.debug(f"Generating new keyset {keyset.id}.") + # we create a new keyset (keys will be generated at instantiation) keyset = MintKeyset( seed=self.master_key, derivation_path=derivation_path, version=settings.version, ) + logger.debug(f"Generated new keyset {keyset.id}.") if autosave: logger.debug(f"crud: storing new keyset {keyset.id}.") await self.crud.store_keyset(keyset=keyset, db=self.db) logger.trace(f"crud: stored new keyset {keyset.id}.") - # store the new keyset in the current keysets - self.keysets.keysets[keyset.id] = keyset - logger.debug(f"Loaded keyset {keyset.id}.") + # activate this keyset + keyset.active = True + # load the new keyset in self.keysets + self.keysets[keyset.id] = keyset + + # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 + # set the deprecated id + assert keyset.public_keys + keyset.duplicate_keyset_id = derive_keyset_id_deprecated(keyset.public_keys) + # END BACKWARDS COMPATIBILITY < 0.15.0 + + logger.debug(f"Loaded keyset {keyset.id}") return keyset async def init_keysets(self, autosave=True) -> None: - """Initializes all keysets of the mint from the db. Loads all past keysets and generate their keys. Then load the current keyset. + """Initializes all keysets of the mint from the db. Loads all past keysets from db + and generate their keys. Then load the current keyset. Args: autosave (bool, optional): Whether the current keyset should be saved if it is - not in the database yet. Will be passed to `self.load_keyset` where it is + not in the database yet. Will be passed to `self.activate_keyset` where it is generated from `self.derivation_path`. Defaults to True. """ # load all past keysets from db - logger.trace("crud: loading keysets") tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db) - logger.trace(f"crud: loaded {len(tmp_keysets)} keysets") + logger.debug( + f"Loaded {len(tmp_keysets)} keysets from database. Generating keys..." + ) # add keysets from db to current keysets for k in tmp_keysets: - if k.id and k.id not in self.keysets.keysets: - self.keysets.keysets[k.id] = k + self.keysets[k.id] = k # generate keys for all keysets in the database - for _, v in self.keysets.keysets.items(): - # we already generated the keys for this keyset + for _, v in self.keysets.items(): + # if we already generated the keys for this keyset, skip if v.id and v.public_keys and len(v.public_keys): continue - logger.debug(f"Generating keys for keyset {v.id}") - v.generate_keys(self.master_key) + logger.trace(f"Generating keys for keyset {v.id}") + v.seed = self.master_key + v.generate_keys() - logger.debug( - f"Initialized {len(self.keysets.keysets)} keysets from the database." + logger.info(f"Initialized {len(self.keysets)} keysets from the database.") + + # activate the current keyset set by self.derivation_path + self.keyset = await self.activate_keyset(self.derivation_path, autosave) + logger.info( + "Activated keysets from database:" + f" {[f'{k} ({v.unit.name})' for k, v in self.keysets.items()]}" ) - # load the current keyset - self.keyset = await self.load_keyset(self.derivation_path, autosave) + logger.info(f"Current keyset: {self.keyset.id}") + + # check that we have a least one active keyset + assert any([k.active for k in self.keysets.values()]), "No active keyset found." + + # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 + # we duplicate new keysets and compute their old keyset id, and + # we duplicate old keysets and compute their new keyset id + for _, keyset in copy.copy(self.keysets).items(): + keyset_copy = copy.copy(keyset) + assert keyset_copy.public_keys + if keyset.version_tuple >= (0, 15): + keyset_copy.id = derive_keyset_id_deprecated(keyset_copy.public_keys) + else: + keyset_copy.id = derive_keyset_id(keyset_copy.public_keys) + keyset_copy.duplicate_keyset_id = keyset.id + self.keysets[keyset_copy.id] = keyset_copy + # remember which keyset this keyset was duplicated from + logger.debug(f"Duplicated keyset id {keyset.id} -> {keyset_copy.id}") + + # END BACKWARDS COMPATIBILITY < 0.15.0 def get_keyset(self, keyset_id: Optional[str] = None) -> Dict[int, str]: """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" - if keyset_id and keyset_id not in self.keysets.keysets: + if keyset_id and keyset_id not in self.keysets: raise KeysetNotFoundError() - keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset + keyset = self.keysets[keyset_id] if keyset_id else self.keyset assert keyset.public_keys, KeysetError("no public keys for this keyset") return {a: p.serialize().hex() for a, p in keyset.public_keys.items()} @@ -154,14 +209,13 @@ async def get_balance(self) -> int: async def _invalidate_proofs( self, proofs: List[Proof], conn: Optional[Connection] = None ) -> None: - """Adds secrets of proofs to the list of known secrets and stores them in the db. + """Adds proofs to the set of spent proofs and stores them in the db. Args: proofs (List[Proof]): Proofs to add to known secret table. conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. """ - secrets = set([p.secret for p in proofs]) - self.secrets_used |= secrets + self.spent_proofs.update({p.secret: p for p in proofs}) async with get_db_connection(self.db, conn) as conn: # store in db for p in proofs: @@ -169,9 +223,9 @@ async def _invalidate_proofs( async def _generate_change_promises( self, - total_provided: int, - invoice_amount: int, - ln_fee_msat: int, + input_amount: int, + output_amount: int, + output_fee_paid: int, outputs: Optional[List[BlindedMessage]], keyset: Optional[MintKeyset] = None, ) -> List[BlindedSignature]: @@ -185,9 +239,9 @@ async def _generate_change_promises( Otherwise, a smaller amount will be returned. Args: - total_provided (int): Amount of the proofs provided by the wallet. - invoice_amount (int): Amount of the invoice to be paid. - ln_fee_msat (int): Actually paid Lightning network fees. + input_amount (int): Amount of the proofs provided by the client. + output_amount (int): Amount of the melt request to be paid. + output_fee_paid (int): Actually paid melt network fees. outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. Raises: @@ -197,18 +251,15 @@ async def _generate_change_promises( List[BlindedSignature]: Signatures on the outputs. """ # we make sure that the fee is positive - ln_fee_msat = abs(ln_fee_msat) - - ln_fee_sat = math.ceil(ln_fee_msat / 1000) - user_paid_fee_sat = total_provided - invoice_amount - overpaid_fee_sat = user_paid_fee_sat - ln_fee_sat + user_fee_paid = input_amount - output_amount + overpaid_fee = user_fee_paid - output_fee_paid logger.debug( - f"Lightning fee was: {ln_fee_sat}. User paid: {user_paid_fee_sat}. " - f"Returning difference: {overpaid_fee_sat}." + f"Lightning fee was: {output_fee_paid}. User paid: {user_fee_paid}. " + f"Returning difference: {overpaid_fee}." ) - if overpaid_fee_sat > 0 and outputs is not None: - return_amounts = amount_split(overpaid_fee_sat) + if overpaid_fee > 0 and outputs is not None: + return_amounts = amount_split(overpaid_fee) # We return at most as many outputs as were provided or as many as are # required to pay back the overpaid fee. @@ -231,225 +282,425 @@ async def _generate_change_promises( # ------- TRANSACTIONS ------- - async def request_mint(self, amount: int) -> Tuple[str, str]: - """Returns Lightning invoice and stores it in the db. + async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: + """Creates a mint quote and stores it in the database. Args: - amount (int): Amount of the mint request in Satoshis. + quote_request (PostMintQuoteRequest): Mint quote request. Raises: - Exception: Invoice creation failed. + Exception: Quote creation failed. Returns: - Tuple[str, str]: Bolt11 invoice and a id (for looking it up later) + MintQuote: Mint quote object. """ logger.trace("called request_mint") - if settings.mint_max_peg_in and amount > settings.mint_max_peg_in: + if settings.mint_max_peg_in and quote_request.amount > settings.mint_max_peg_in: raise NotAllowedError( f"Maximum mint amount is {settings.mint_max_peg_in} sat." ) if settings.mint_peg_out_only: raise NotAllowedError("Mint does not allow minting new tokens.") + unit = Unit[quote_request.unit] + method = Method.bolt11 if settings.mint_max_balance: balance = await self.get_balance() - if balance + amount > settings.mint_max_balance: + if balance + quote_request.amount > settings.mint_max_balance: raise NotAllowedError("Mint has reached maximum balance.") - logger.trace(f"requesting invoice for {amount} satoshis") - invoice_response = await self._request_lightning_invoice(amount) + logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") + invoice_response: InvoiceResponse = await self.backends[method][ + unit + ].create_invoice(Amount(unit=unit, amount=quote_request.amount)) logger.trace( - f"got invoice {invoice_response.payment_request} with check id" + f"got invoice {invoice_response.payment_request} with checking id" f" {invoice_response.checking_id}" ) + assert ( invoice_response.payment_request and invoice_response.checking_id - ), LightningError("could not fetch invoice from Lightning backend") - - invoice = Invoice( - amount=amount, - id=random_hash(), - bolt11=invoice_response.payment_request, - payment_hash=invoice_response.checking_id, # what we got from the backend + ), LightningError("could not fetch bolt11 payment request from backend") + + # get invoice expiry time + invoice_obj = bolt11.decode(invoice_response.payment_request) + + quote = MintQuote( + quote=random_hash(), + method=method.name, + request=invoice_response.payment_request, + checking_id=invoice_response.checking_id, + unit=quote_request.unit, + amount=quote_request.amount, issued=False, + paid=False, + created_time=int(time.time()), + expiry=invoice_obj.expiry or 0, ) - logger.trace(f"crud: storing invoice {invoice.id} in db") - await self.crud.store_lightning_invoice(invoice=invoice, db=self.db) - logger.trace(f"crud: stored invoice {invoice.id} in db") - return invoice_response.payment_request, invoice.id + await self.crud.store_mint_quote( + quote=quote, + db=self.db, + ) + return quote + + async def get_mint_quote(self, quote_id: str) -> MintQuote: + """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. + + Args: + quote_id (str): ID of the mint quote. + + Raises: + Exception: Quote not found. + + Returns: + MintQuote: Mint quote object. + """ + quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) + assert quote, "quote not found" + assert quote.method == Method.bolt11.name, "only bolt11 supported" + unit = Unit[quote.unit] + method = Method[quote.method] + + if not quote.paid: + logger.trace(f"Lightning: checking invoice {quote.checking_id}") + status: PaymentStatus = await self.backends[method][ + unit + ].get_invoice_status(quote.checking_id) + if status.paid: + logger.trace(f"Setting quote {quote_id} as paid") + quote.paid = True + await self.crud.update_mint_quote(quote=quote, db=self.db) + + return quote async def mint( self, - B_s: List[BlindedMessage], - id: Optional[str] = None, - keyset: Optional[MintKeyset] = None, + *, + outputs: List[BlindedMessage], + quote_id: str, ) -> List[BlindedSignature]: - """Mints a promise for coins for B_. + """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. Args: - B_s (List[BlindedMessage]): Outputs (blinded messages) to sign. - id (Optional[str], optional): Id of (paid) Lightning invoice. Defaults to None. + outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. + quote_id (str): Mint quote id. keyset (Optional[MintKeyset], optional): Keyset to use. If not provided, uses active keyset. Defaults to None. Raises: - Exception: Lightning invoice is not paid. - Exception: Lightning is turned on but no id is provided. - Exception: Something went wrong with the invoice check. - Exception: Amount too large. + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Quote expired. + Exception: Amount to mint does not match quote amount. Returns: List[BlindedSignature]: Signatures on the outputs. """ logger.trace("called mint") - amount_outputs = sum([b.amount for b in B_s]) - - await self._verify_outputs(B_s) - - if settings.lightning: - if not id: - raise NotAllowedError("no id provided.") - self.locks[id] = ( - self.locks.get(id) or asyncio.Lock() - ) # create a new lock if it doesn't exist - async with self.locks[id]: - # will raise an exception if the invoice is not paid or tokens are - # already issued or the requested amount is too high - await self._check_lightning_invoice(amount=amount_outputs, id=id) - - logger.trace(f"crud: setting invoice {id} as issued") - await self.crud.update_lightning_invoice(id=id, issued=True, db=self.db) - del self.locks[id] - - promises = await self._generate_promises(B_s, keyset) - logger.trace("generated promises") + await self._verify_outputs(outputs) + sum_amount_outputs = sum([b.amount for b in outputs]) + + self.locks[quote_id] = ( + self.locks.get(quote_id) or asyncio.Lock() + ) # create a new lock if it doesn't exist + async with self.locks[quote_id]: + quote = await self.get_mint_quote(quote_id=quote_id) + assert quote.paid, "quote not paid" + assert not quote.issued, "quote already issued" + assert ( + quote.amount == sum_amount_outputs + ), "amount to mint does not match quote amount" + if quote.expiry: + assert quote.expiry > int(time.time()), "quote expired" + + promises = await self._generate_promises(outputs) + logger.trace("generated promises") + + logger.trace(f"crud: setting quote {quote_id} as issued") + quote.issued = True + await self.crud.update_mint_quote(quote=quote, db=self.db) + del self.locks[quote_id] return promises + async def melt_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PostMeltQuoteResponse: + """Creates a melt quote and stores it in the database. + + Args: + melt_quote (PostMeltQuoteRequest): Melt quote request. + + Raises: + Exception: Quote invalid. + Exception: Quote already paid. + Exception: Quote already issued. + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + unit = Unit[melt_quote.unit] + method = Method.bolt11 + invoice_obj = bolt11.decode(melt_quote.request) + assert invoice_obj.amount_msat, "invoice has no amount." + + # check if there is a mint quote with the same payment request + # so that we can handle the transaction internally without lightning + # and respond with zero fees + mint_quote = await self.crud.get_mint_quote_by_checking_id( + checking_id=invoice_obj.payment_hash, db=self.db + ) + if mint_quote: + # internal transaction, validate and return amount from + # associated mint quote and demand zero fees + assert ( + Amount(unit, mint_quote.amount).to(Unit.msat).amount + == invoice_obj.amount_msat + ), "amounts do not match" + assert ( + melt_quote.request == mint_quote.request + ), "bolt11 requests do not match" + assert mint_quote.unit == melt_quote.unit, "units do not match" + assert mint_quote.method == method.name, "methods do not match" + assert not mint_quote.paid, "mint quote already paid" + assert not mint_quote.issued, "mint quote already issued" + payment_quote = PaymentQuoteResponse( + checking_id=mint_quote.checking_id, + amount=Amount(unit, mint_quote.amount), + fee=Amount(unit=Unit.msat, amount=0), + ) + logger.info( + f"Issuing internal melt quote: {melt_quote.request} ->" + f" {mint_quote.quote} ({mint_quote.amount} {mint_quote.unit})" + ) + else: + # not internal, get quote by backend + payment_quote = await self.backends[method][unit].get_payment_quote( + melt_quote.request + ) + + quote = MeltQuote( + quote=random_hash(), + method=method.name, + request=melt_quote.request, + checking_id=payment_quote.checking_id, + unit=melt_quote.unit, + amount=payment_quote.amount.to(unit).amount, + paid=False, + fee_reserve=payment_quote.fee.to(unit).amount, + created_time=int(time.time()), + ) + await self.crud.store_melt_quote(quote=quote, db=self.db) + return PostMeltQuoteResponse( + quote=quote.quote, + amount=quote.amount, + fee_reserve=quote.fee_reserve, + paid=quote.paid, + ) + + async def get_melt_quote(self, quote_id: str) -> MeltQuote: + """Returns a melt quote. + + If melt quote is not paid yet, checks with the backend for the state of the payment request. + + If the quote has been paid, updates the melt quote in the database. + + Args: + quote_id (str): ID of the melt quote. + + Raises: + Exception: Quote not found. + + Returns: + MeltQuote: Melt quote object. + """ + melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) + assert melt_quote, "quote not found" + assert melt_quote.method == Method.bolt11.name, "only bolt11 supported" + unit = Unit[melt_quote.unit] + method = Method[melt_quote.method] + + # we only check the state with the backend if there is no associated internal + # mint quote for this melt quote + mint_quote = await self.crud.get_mint_quote_by_checking_id( + checking_id=melt_quote.checking_id, db=self.db + ) + + if not melt_quote.paid and not mint_quote: + logger.trace( + "Lightning: checking outgoing Lightning payment" + f" {melt_quote.checking_id}" + ) + status: PaymentStatus = await self.backends[method][ + unit + ].get_payment_status(melt_quote.checking_id) + if status.paid: + logger.trace(f"Setting quote {quote_id} as paid") + melt_quote.paid = True + if status.fee: + melt_quote.fee_paid = status.fee.to(unit).amount + if status.preimage: + melt_quote.proof = status.preimage + melt_quote.paid_time = int(time.time()) + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + + return melt_quote + + async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: + """Settles a melt quote internally if there is a mint quote with the same payment request. + + Args: + melt_quote (MeltQuote): Melt quote to settle. + + Raises: + Exception: Melt quote already paid. + Exception: Melt quote already issued. + + Returns: + MeltQuote: Settled melt quote. + """ + # first we check if there is a mint quote with the same payment request + # so that we can handle the transaction internally without the backend + mint_quote = await self.crud.get_mint_quote_by_checking_id( + checking_id=melt_quote.checking_id, db=self.db + ) + if not mint_quote: + return melt_quote + # we settle the transaction internally + assert not melt_quote.paid, "melt quote already paid" + + # verify amounts from bolt11 invoice + bolt11_request = melt_quote.request + invoice_obj = bolt11.decode(bolt11_request) + assert invoice_obj.amount_msat, "invoice has no amount." + invoice_amount_sat = math.ceil(invoice_obj.amount_msat / 1000) + assert ( + Amount(Unit[melt_quote.unit], mint_quote.amount).to(Unit.sat).amount + == invoice_amount_sat + ), "amounts do not match" + assert bolt11_request == mint_quote.request, "bolt11 requests do not match" + assert mint_quote.unit == melt_quote.unit, "units do not match" + assert mint_quote.method == melt_quote.method, "methods do not match" + assert not mint_quote.paid, "mint quote already paid" + assert not mint_quote.issued, "mint quote already issued" + logger.info( + f"Settling bolt11 payment internally: {melt_quote.quote} ->" + f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" + ) + + # we handle this transaction internally + melt_quote.fee_paid = 0 + melt_quote.paid = True + melt_quote.paid_time = int(time.time()) + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + + mint_quote.paid = True + await self.crud.update_mint_quote(quote=mint_quote, db=self.db) + + return melt_quote + async def melt( self, + *, proofs: List[Proof], - invoice: str, - outputs: Optional[List[BlindedMessage]], - keyset: Optional[MintKeyset] = None, - ) -> Tuple[bool, str, List[BlindedSignature]]: + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> Tuple[str, List[BlindedSignature]]: """Invalidates proofs and pays a Lightning invoice. Args: proofs (List[Proof]): Proofs provided for paying the Lightning invoice - invoice (str): bolt11 Lightning invoice. + quote (str): ID of the melt quote. outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. Raises: e: Lightning payment unsuccessful Returns: - List[BlindedMessage]: Signed outputs for returning overpaid fees to wallet. + Tuple[str, List[BlindedMessage]]: Proof of payment and signed outputs for returning overpaid fees to wallet. """ + # get melt quote and settle transaction internally if possible + melt_quote = await self.get_melt_quote(quote_id=quote) + method = Method[melt_quote.method] + unit = Unit[melt_quote.unit] + assert not melt_quote.paid, "melt quote already paid" + + # make sure that the outputs (for fee return) are in the same unit as the quote + if outputs: + await self._verify_outputs(outputs) + assert outputs[0].id, "output id not set" + outputs_unit = self.keysets[outputs[0].id].unit + assert melt_quote.unit == outputs_unit.name, ( + f"output unit {outputs_unit.name} does not match quote unit" + f" {melt_quote.unit}" + ) - logger.trace("melt called") + # verify that the amount of the input proofs is equal to the amount of the quote + total_provided = sum_proofs(proofs) + total_needed = melt_quote.amount + (melt_quote.fee_reserve or 0) + assert total_provided >= total_needed, ( + f"not enough inputs provided for melt. Provided: {total_provided}, needed:" + f" {total_needed}" + ) + + # verify that the amount of the proofs is not larger than the maximum allowed + if settings.mint_max_peg_out and total_provided > settings.mint_max_peg_out: + raise NotAllowedError( + f"Maximum melt amount is {settings.mint_max_peg_out} sat." + ) + + # verify inputs and their spending conditions + await self.verify_inputs_and_outputs(proofs=proofs) # set proofs to pending to avoid race conditions await self._set_proofs_pending(proofs) - try: - # verify amounts - total_provided = sum_proofs(proofs) - invoice_obj = bolt11.decode(invoice) - assert invoice_obj.amount_msat, "invoice has no amount." - invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) - if settings.mint_max_peg_out and invoice_amount > settings.mint_max_peg_out: - raise NotAllowedError( - f"Maximum melt amount is {settings.mint_max_peg_out} sat." - ) - reserve_fees_sat = await self.get_melt_fees(invoice) - # verify overspending attempt - assert ( - total_provided >= invoice_amount + reserve_fees_sat - ), TransactionError( - "provided proofs not enough for Lightning payment. Provided:" - f" {total_provided}, needed: {invoice_amount + reserve_fees_sat}" - ) + melt_quote = await self.melt_mint_settle_internally(melt_quote) - if outputs: - # verify the outputs. note: we don't verify inputs - # and outputs simultaneously with verify_inputs_and_outputs() as we do - # in split() because we do not expect the amounts to be equal here. - await self._verify_outputs(outputs) - - # verify spending inputs and their spending conditions - await self.verify_inputs_and_outputs(proofs) - - if settings.lightning: - logger.trace(f"paying lightning invoice {invoice}") - payment = await self._pay_lightning_invoice( - invoice, reserve_fees_sat * 1000 + # quote not paid yet (not internal), pay it with the backend + if not melt_quote.paid: + logger.debug(f"Lightning: pay invoice {melt_quote.request}") + payment = await self.backends[method][unit].pay_invoice( + melt_quote, melt_quote.fee_reserve * 1000 ) - logger.trace("paid lightning invoice") - else: - payment = PaymentResponse(ok=True, preimage="preimage", fee_msat=0) - - logger.debug( - f"Melt status: {payment.ok}: preimage: {payment.preimage}, fee_msat:" - f" {payment.fee_msat}" - ) - - if not payment.ok: - raise LightningError("Lightning payment unsuccessful.") + logger.debug( + f"Melt status: {payment.ok}: preimage: {payment.preimage}," + f" fee: {payment.fee.str() if payment.fee else 0}" + ) + if not payment.ok: + raise LightningError("Lightning payment unsuccessful.") + if payment.fee: + melt_quote.fee_paid = payment.fee.to( + to_unit=unit, round="up" + ).amount + if payment.preimage: + melt_quote.proof = payment.preimage + # set quote as paid + melt_quote.paid = True + melt_quote.paid_time = int(time.time()) + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) # melt successful, invalidate proofs await self._invalidate_proofs(proofs) # prepare change to compensate wallet for overpaid fees return_promises: List[BlindedSignature] = [] - if outputs and payment.fee_msat is not None: + if outputs: + assert outputs[0].id, "output id not set" return_promises = await self._generate_change_promises( - total_provided=total_provided, - invoice_amount=invoice_amount, - ln_fee_msat=payment.fee_msat, + input_amount=total_provided, + output_amount=melt_quote.amount, + output_fee_paid=melt_quote.fee_paid, outputs=outputs, - keyset=keyset, + keyset=self.keysets[outputs[0].id], ) except Exception as e: - logger.trace(f"exception: {e}") + logger.trace(f"Melt exception: {e}") raise e finally: # delete proofs from pending list await self._unset_proofs_pending(proofs) - return payment.ok, payment.preimage or "", return_promises - - async def get_melt_fees(self, pr: str) -> int: - """Returns the fee reserve (in sat) that a wallet must add to its proofs - in order to pay a Lightning invoice. - - Args: - pr (str): Bolt11 encoded payment request. Lightning invoice. - - Returns: - int: Fee in Satoshis. - """ - # hack: check if it's internal, if it exists, it will return paid = False, - # if id does not exist (not internal), it returns paid = None - amount_msat = 0 - if settings.lightning: - decoded_invoice = bolt11.decode(pr) - assert decoded_invoice.amount_msat, "invoice has no amount." - amount_msat = int(decoded_invoice.amount_msat) - logger.trace( - "get_melt_fees: checking lightning invoice:" - f" {decoded_invoice.payment_hash}" - ) - payment = await self.lightning.get_invoice_status( - decoded_invoice.payment_hash - ) - logger.trace(f"get_melt_fees: paid: {payment.paid}") - internal = payment.paid is False - else: - amount_msat = 0 - internal = True - - fees_msat = fee_reserve(amount_msat, internal) - fee_sat = math.ceil(fees_msat / 1000) - return fee_sat + return melt_quote.proof or "", return_promises async def split( self, @@ -482,7 +733,7 @@ async def split( # verify_inputs_and_outputs, we check it here. self._verify_equation_balanced(proofs, outputs) # verify spending inputs, outputs, and spending conditions - await self.verify_inputs_and_outputs(proofs, outputs) + await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) # Mark proofs as used and prepare new promises async with get_db_connection(self.db) as conn: @@ -515,7 +766,7 @@ async def restore( # BEGIN backwards compatibility mints pre `m007_proofs_and_promises_store_id` # add keyset id to promise if not present only if the current keyset # is the only one ever used - if not promise.id and len(self.keysets.keysets) == 1: + if not promise.id and len(self.keysets) == 1: promise.id = self.keyset.id # END backwards compatibility promises.append(promise) @@ -527,7 +778,7 @@ async def restore( async def _generate_promises( self, - B_s: List[BlindedMessage], + outputs: List[BlindedMessage], keyset: Optional[MintKeyset] = None, conn: Optional[Connection] = None, ) -> list[BlindedSignature]: @@ -540,29 +791,42 @@ async def _generate_promises( Args: B_s (List[BlindedMessage]): Blinded secret (point on curve) - keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. Defaults to None. + keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. + If not given will use the keyset of the first output. Defaults to None. conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. Returns: list[BlindedSignature]: Generated BlindedSignatures. """ - keyset = keyset if keyset else self.keyset - promises = [] - for b in B_s: - amount = b.amount - B_ = PublicKey(bytes.fromhex(b.B_), raw=True) - logger.trace(f"Generating promise with keyset {keyset.id}.") - private_key_amount = keyset.private_keys[amount] + promises: List[ + Tuple[str, PublicKey, int, PublicKey, PrivateKey, PrivateKey] + ] = [] + for output in outputs: + B_ = PublicKey(bytes.fromhex(output.B_), raw=True) + assert output.id, "output id not set" + keyset = keyset or self.keysets[output.id] + + assert output.id in self.keysets, f"keyset {output.id} not found" + assert output.id in [ + keyset.id, + keyset.duplicate_keyset_id, + ], "keyset id does not match output id" + assert keyset.active, "keyset is not active" + keyset_id = output.id + logger.trace(f"Generating promise with keyset {keyset_id}.") + private_key_amount = keyset.private_keys[output.amount] C_, e, s = b_dhke.step2_bob(B_, private_key_amount) - promises.append((B_, amount, C_, e, s)) + promises.append((keyset_id, B_, output.amount, C_, e, s)) + + keyset = keyset or self.keyset signatures = [] async with get_db_connection(self.db, conn) as conn: for promise in promises: - B_, amount, C_, e, s = promise + keyset_id, B_, amount, C_, e, s = promise logger.trace(f"crud: _generate_promise storing promise for {amount}") await self.crud.store_promise( amount=amount, - id=keyset.id, + id=keyset_id, B_=B_.serialize().hex(), C_=C_.serialize().hex(), e=e.serialize(), @@ -572,7 +836,7 @@ async def _generate_promises( ) logger.trace(f"crud: _generate_promise stored promise for {amount}") signature = BlindedSignature( - id=keyset.id, + id=keyset_id, amount=amount, C_=C_.serialize().hex(), dleq=DLEQ(e=e.serialize(), s=s.serialize()), @@ -584,23 +848,13 @@ async def _generate_promises( async def load_used_proofs(self) -> None: """Load all used proofs from database.""" + assert settings.mint_cache_secrets, "MINT_CACHE_SECRETS must be set to TRUE" logger.debug("Loading used proofs into memory") - secrets_used = await self.crud.get_secrets_used(db=self.db) - logger.debug(f"Loaded {len(secrets_used)} used proofs") - self.secrets_used = set(secrets_used) - - async def _check_pending(self, proofs: List[Proof]) -> List[bool]: - """Checks whether the proof is still pending.""" - proofs_pending = await self.crud.get_proofs_pending(db=self.db) - pending_secrets = [pp.secret for pp in proofs_pending] - pending_states = [ - True if p.secret in pending_secrets else False for p in proofs - ] - return pending_states - - async def check_proof_state( - self, proofs: List[Proof] - ) -> Tuple[List[bool], List[bool]]: + spent_proofs_list = await self.crud.get_spent_proofs(db=self.db) or [] + logger.debug(f"Loaded {len(spent_proofs_list)} used proofs") + self.spent_proofs = {p.secret: p for p in spent_proofs_list} + + async def check_proofs_state(self, secrets: List[str]) -> List[ProofState]: """Checks if provided proofs are spend or are pending. Used by wallets to check if their proofs have been redeemed by a receiver or they are still in-flight in a transaction. @@ -615,10 +869,23 @@ async def check_proof_state( List[bool]: List of which proof is still spendable (True if still spendable, else False) List[bool]: List of which proof are pending (True if pending, else False) """ - - spendable = await self._check_proofs_spendable(proofs) - pending = await self._check_pending(proofs) - return spendable, pending + states: List[ProofState] = [] + proofs_spent = await self._get_proofs_spent(secrets) + proofs_pending = await self._get_proofs_pending(secrets) + for secret in secrets: + if secret not in proofs_spent and secret not in proofs_pending: + states.append(ProofState(secret=secret, state=SpentState.unspent)) + elif secret not in proofs_spent and secret in proofs_pending: + states.append(ProofState(secret=secret, state=SpentState.pending)) + else: + states.append( + ProofState( + secret=secret, + state=SpentState.spent, + witness=proofs_spent[secret].witness, + ) + ) + return states async def _set_proofs_pending(self, proofs: List[Proof]) -> None: """If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to diff --git a/cashu/mint/lightning.py b/cashu/mint/lightning.py deleted file mode 100644 index 076c714c..00000000 --- a/cashu/mint/lightning.py +++ /dev/null @@ -1,137 +0,0 @@ -from typing import Optional, Union - -from loguru import logger - -from ..core.base import ( - Invoice, -) -from ..core.db import Connection, Database -from ..core.errors import ( - InvoiceNotPaidError, - LightningError, -) -from ..lightning.base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet -from ..mint.crud import LedgerCrud -from .protocols import SupportLightning, SupportsDb - - -class LedgerLightning(SupportLightning, SupportsDb): - """Lightning functions for the ledger.""" - - lightning: Wallet - crud: LedgerCrud - db: Database - - async def _request_lightning_invoice(self, amount: int) -> InvoiceResponse: - """Generate a Lightning invoice using the funding source backend. - - Args: - amount (int): Amount of invoice (in Satoshis) - - Raises: - Exception: Error with funding source. - - Returns: - Tuple[str, str]: Bolt11 invoice and payment id (for lookup) - """ - logger.trace( - "_request_lightning_invoice: Requesting Lightning invoice for" - f" {amount} satoshis." - ) - status = await self.lightning.status() - logger.trace( - "_request_lightning_invoice: Lightning wallet balance:" - f" {status.balance_msat}" - ) - if status.error_message: - raise LightningError( - f"Lightning wallet not responding: {status.error_message}" - ) - payment = await self.lightning.create_invoice(amount, "Cashu deposit") - logger.trace( - f"_request_lightning_invoice: Lightning invoice: {payment.payment_request}" - ) - - if not payment.ok: - raise LightningError(f"Lightning wallet error: {payment.error_message}") - assert payment.payment_request and payment.checking_id, LightningError( - "could not fetch invoice from Lightning backend" - ) - return payment - - async def _check_lightning_invoice( - self, *, amount: int, id: str, conn: Optional[Connection] = None - ) -> PaymentStatus: - """Checks with the Lightning backend whether an invoice with `id` was paid. - - Args: - amount (int): Amount of the outputs the wallet wants in return (in Satoshis). - id (str): Id to look up Lightning invoice by. - - Raises: - Exception: Invoice not found. - Exception: Tokens for invoice already issued. - Exception: Amount larger than invoice amount. - Exception: Invoice not paid yet - e: Update database and pass through error. - - Returns: - bool: True if invoice has been paid, else False - """ - invoice: Union[Invoice, None] = await self.crud.get_lightning_invoice( - id=id, db=self.db, conn=conn - ) - if invoice is None: - raise LightningError("invoice not found.") - if invoice.issued: - raise LightningError("tokens already issued for this invoice.") - if amount > invoice.amount: - raise LightningError( - f"requested amount too high: {amount}. Invoice amount: {invoice.amount}" - ) - assert invoice.payment_hash, "invoice has no payment hash." - # set this invoice as issued - await self.crud.update_lightning_invoice( - id=id, issued=True, db=self.db, conn=conn - ) - - try: - status = await self.lightning.get_invoice_status(invoice.payment_hash) - if status.paid: - return status - else: - raise InvoiceNotPaidError() - except Exception as e: - # unset issued - await self.crud.update_lightning_invoice( - id=id, issued=False, db=self.db, conn=conn - ) - raise e - - async def _pay_lightning_invoice( - self, invoice: str, fee_limit_msat: int - ) -> PaymentResponse: - """Pays a Lightning invoice via the funding source backend. - - Args: - invoice (str): Bolt11 Lightning invoice - fee_limit_msat (int): Maximum fee reserve for payment (in Millisatoshi) - - Raises: - Exception: Funding source error. - - Returns: - Tuple[bool, string, int]: Returns payment status, preimage of invoice, paid fees (in Millisatoshi) - """ - status = await self.lightning.status() - if status.error_message: - raise LightningError( - f"Lightning wallet not responding: {status.error_message}" - ) - payment = await self.lightning.pay_invoice( - invoice, fee_limit_msat=fee_limit_msat - ) - logger.trace(f"_pay_lightning_invoice: Lightning payment status: {payment.ok}") - # make sure that fee is positive and not None - payment.fee_msat = abs(payment.fee_msat) if payment.fee_msat else 0 - return payment diff --git a/cashu/mint/main.py b/cashu/mint/main.py index 999863d4..651727a8 100644 --- a/cashu/mint/main.py +++ b/cashu/mint/main.py @@ -32,7 +32,6 @@ def main( for a in ctx.args: item = a.split("=") if len(item) > 1: # argument like --key=value - print(a, item) d[item[0].strip("--").replace("-", "_")] = ( int(item[1]) # need to convert to int if it's a number if item[1].isdigit() @@ -49,5 +48,6 @@ def main( ssl_certfile=ssl_certfile, **d, # type: ignore ) + server = uvicorn.Server(config) server.run() diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 54a109b9..72160405 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -1,4 +1,7 @@ +import time + from ..core.db import Connection, Database, table_with_schema +from ..core.settings import settings async def m000_create_migrations_table(conn: Connection): @@ -218,3 +221,86 @@ async def m010_add_index_to_proofs_used(db: Database): " proofs_used_secret_idx ON" f" {table_with_schema(db, 'proofs_used')} (secret)" ) + + +async def m011_add_quote_tables(db: Database): + async with db.connect() as conn: + # add column "created" to tables invoices, promises, proofs_used, proofs_pending + tables = ["invoices", "promises", "proofs_used", "proofs_pending"] + for table in tables: + await conn.execute( + f"ALTER TABLE {table_with_schema(db, table)} ADD COLUMN created" + " TIMESTAMP" + ) + await conn.execute( + f"UPDATE {table_with_schema(db, table)} SET created =" + f" '{int(time.time())}'" + ) + + # add column "witness" to table proofs_used + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'proofs_used')} ADD COLUMN witness" + " TEXT" + ) + + # add columns "seed" and "unit" to table keysets + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'keysets')} ADD COLUMN seed TEXT" + ) + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'keysets')} ADD COLUMN unit TEXT" + ) + + # fill columns "seed" and "unit" in table keysets + await conn.execute( + f"UPDATE {table_with_schema(db, 'keysets')} SET seed =" + f" '{settings.mint_private_key}', unit = 'sat'" + ) + + await conn.execute(f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_quotes')} ( + quote TEXT NOT NULL, + method TEXT NOT NULL, + request TEXT NOT NULL, + checking_id TEXT NOT NULL, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + paid BOOL NOT NULL, + issued BOOL NOT NULL, + created_time TIMESTAMP, + paid_time TIMESTAMP, + + UNIQUE (quote) + + ); + """) + + await conn.execute(f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'melt_quotes')} ( + quote TEXT NOT NULL, + method TEXT NOT NULL, + request TEXT NOT NULL, + checking_id TEXT NOT NULL, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + fee_reserve INTEGER, + paid BOOL NOT NULL, + created_time TIMESTAMP, + paid_time TIMESTAMP, + fee_paid INTEGER, + proof TEXT, + + UNIQUE (quote) + + ); + """) + + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'mint_quotes')} (quote, method," + " request, checking_id, unit, amount, paid, issued, created_time," + " paid_time) SELECT id, 'bolt11', bolt11, payment_hash, 'sat', amount," + f" False, issued, created, 0 FROM {table_with_schema(db, 'invoices')} " + ) + + # drop table invoices + await conn.execute(f"DROP TABLE {table_with_schema(db, 'invoices')}") diff --git a/cashu/mint/protocols.py b/cashu/mint/protocols.py index bef454a4..47bf618e 100644 --- a/cashu/mint/protocols.py +++ b/cashu/mint/protocols.py @@ -1,18 +1,18 @@ -from typing import Protocol +from typing import Dict, Protocol -from ..core.base import MintKeyset, MintKeysets +from ..core.base import MintKeyset, Unit from ..core.db import Database -from ..lightning.base import Wallet +from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud class SupportsKeysets(Protocol): keyset: MintKeyset - keysets: MintKeysets + keysets: Dict[str, MintKeyset] class SupportLightning(Protocol): - lightning: Wallet + lightning: Dict[Unit, LightningBackend] class SupportsDb(Protocol): diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 90fc6483..5cf303b6 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -1,26 +1,27 @@ -from typing import List, Optional, Union +from typing import Any, Dict, List -from fastapi import APIRouter +from fastapi import APIRouter, Request from loguru import logger from ..core.base import ( - BlindedSignature, - CheckFeesRequest, - CheckFeesResponse, - CheckSpendableRequest, - CheckSpendableResponse, GetInfoResponse, - GetMeltResponse, - GetMintResponse, KeysetsResponse, + KeysetsResponseKeyset, KeysResponse, + KeysResponseKeyset, + PostCheckStateRequest, + PostCheckStateResponse, + PostMeltQuoteRequest, + PostMeltQuoteResponse, PostMeltRequest, + PostMeltResponse, + PostMintQuoteRequest, + PostMintQuoteResponse, PostMintRequest, PostMintResponse, PostRestoreResponse, PostSplitRequest, PostSplitResponse, - PostSplitResponse_Deprecated, ) from ..core.errors import CashuError from ..core.settings import settings @@ -30,14 +31,38 @@ @router.get( - "/info", + "/v1/info", name="Mint information", summary="Mint information, operator contact information, and other info.", response_model=GetInfoResponse, response_model_exclude_none=True, ) async def info() -> GetInfoResponse: - logger.trace("> GET /info") + logger.trace("> GET /v1/info") + + # determine all method-unit pairs + method_unit_pairs: List[List[str]] = [] + for method, unit_dict in ledger.backends.items(): + for unit in unit_dict.keys(): + method_unit_pairs.append([method.name, unit.name]) + supported_dict = dict(supported=True) + + mint_features: Dict[int, Dict[str, Any]] = { + 4: dict( + methods=method_unit_pairs, + ), + 5: dict( + methods=method_unit_pairs, + disabled=False, + ), + 7: supported_dict, + 8: supported_dict, + 9: supported_dict, + 10: supported_dict, + 11: supported_dict, + 12: supported_dict, + } + return GetInfoResponse( name=settings.mint_info_name, pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None, @@ -45,18 +70,13 @@ async def info() -> GetInfoResponse: description=settings.mint_info_description, description_long=settings.mint_info_description_long, contact=settings.mint_info_contact, - nuts=settings.mint_info_nuts, + nuts=mint_features, motd=settings.mint_info_motd, - parameter={ - "max_peg_in": settings.mint_max_peg_in, - "max_peg_out": settings.mint_max_peg_out, - "peg_out_only": settings.mint_peg_out_only, - }, ) @router.get( - "/keys", + "/v1/keys", name="Mint public keys", summary="Get the public keys of the newest mint keyset", response_description=( @@ -67,14 +87,23 @@ async def info() -> GetInfoResponse: ) async def keys(): """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" - logger.trace("> GET /keys") - keyset = ledger.get_keyset() - keys = KeysResponse.parse_obj(keyset) - return keys.__root__ + logger.trace("> GET /v1/keys") + keyset = ledger.keyset + keyset_for_response = [] + for keyset in ledger.keysets.values(): + if keyset.active: + keyset_for_response.append( + KeysResponseKeyset( + id=keyset.id, + unit=keyset.unit.name, + keys={k: v for k, v in keyset.public_keys_hex.items()}, + ) + ) + return KeysResponse(keysets=keyset_for_response) @router.get( - "/keys/{idBase64Urlsafe}", + "/v1/keys/{keyset_id}", name="Keyset public keys", summary="Public keys of a specific keyset", response_description=( @@ -83,21 +112,33 @@ async def keys(): ), response_model=KeysResponse, ) -async def keyset_keys(idBase64Urlsafe: str): +async def keyset_keys(keyset_id: str, request: Request) -> KeysResponse: """ Get the public keys of the mint from a specific keyset id. - The id is encoded in idBase64Urlsafe (by a wallet) and is converted back to - normal base64 before it can be processed (by the mint). """ - logger.trace(f"> GET /keys/{idBase64Urlsafe}") - id = idBase64Urlsafe.replace("-", "+").replace("_", "/") - keyset = ledger.get_keyset(keyset_id=id) - keys = KeysResponse.parse_obj(keyset) - return keys.__root__ + logger.trace(f"> GET /v1/keys/{keyset_id}") + # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 + # if keyset_id is not hex, we assume it is base64 and sanitize it + try: + int(keyset_id, 16) + except ValueError: + keyset_id = keyset_id.replace("-", "+").replace("_", "/") + # END BACKWARDS COMPATIBILITY < 0.15.0 + + keyset = ledger.keysets.get(keyset_id) + if keyset is None: + raise CashuError(code=0, detail="Keyset not found.") + + keyset_for_response = KeysResponseKeyset( + id=keyset.id, + unit=keyset.unit.name, + keys={k: v for k, v in keyset.public_keys_hex.items()}, + ) + return KeysResponse(keysets=[keyset_for_response]) @router.get( - "/keysets", + "/v1/keysets", name="Active keysets", summary="Get all active keyset id of the mind", response_model=KeysetsResponse, @@ -105,44 +146,75 @@ async def keyset_keys(idBase64Urlsafe: str): ) async def keysets() -> KeysetsResponse: """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" - logger.trace("> GET /keysets") - keysets = KeysetsResponse(keysets=ledger.keysets.get_ids()) - return keysets + logger.trace("> GET /v1/keysets") + keysets = [] + for id, keyset in ledger.keysets.items(): + keysets.append( + KeysetsResponseKeyset( + id=id, unit=keyset.unit.name, active=keyset.active or False + ) + ) + return KeysetsResponse(keysets=keysets) -@router.get( - "/mint", - name="Request mint", - summary="Request minting of new tokens", - response_model=GetMintResponse, - response_description=( - "A Lightning invoice to be paid and a hash to request minting of new tokens" - " after payment." - ), +@router.post( + "/v1/mint/quote/bolt11", + name="Request mint quote", + summary="Request a quote for minting of new tokens", + response_model=PostMintQuoteResponse, + response_description="A payment request to mint tokens of a denomination", ) -async def request_mint(amount: int = 0) -> GetMintResponse: +async def mint_quote(payload: PostMintQuoteRequest) -> PostMintQuoteResponse: """ Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow. - Call `POST /mint` after paying the invoice. + Call `POST /v1/mint/bolt11` after paying the invoice. """ - logger.trace(f"> GET /mint: amount={amount}") + logger.trace(f"> POST /v1/mint/quote/bolt11: payload={payload}") + amount = payload.amount if amount > 21_000_000 * 100_000_000 or amount <= 0: raise CashuError(code=0, detail="Amount must be a valid amount of sat.") if settings.mint_peg_out_only: raise CashuError(code=0, detail="Mint does not allow minting new tokens.") - payment_request, hash = await ledger.request_mint(amount) - resp = GetMintResponse(pr=payment_request, hash=hash) - logger.trace(f"< GET /mint: {resp}") + quote = await ledger.mint_quote(payload) + resp = PostMintQuoteResponse( + request=quote.request, + quote=quote.quote, + paid=quote.paid, + expiry=quote.expiry, + ) + logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}") + return resp + + +@router.get( + "/v1/mint/quote/{quote}", + summary="Get mint quote", + response_model=PostMintQuoteResponse, + response_description="Get an existing mint quote to check its status.", +) +async def get_mint_quote(quote: str) -> PostMintQuoteResponse: + """ + Get mint quote state. + """ + logger.trace(f"> POST /v1/mint/quote/{quote}") + mint_quote = await ledger.get_mint_quote(quote) + resp = PostMintQuoteResponse( + quote=mint_quote.quote, + request=mint_quote.request, + paid=mint_quote.paid, + expiry=mint_quote.expiry, + ) + logger.trace(f"< POST /v1/mint/quote/{quote}") return resp @router.post( - "/mint", + "/v1/mint/bolt11", name="Mint tokens", - summary="Mint tokens in exchange for a Bitcoin paymemt that the user has made", + summary="Mint tokens in exchange for a Bitcoin payment that the user has made", response_model=PostMintResponse, response_description=( "A list of blinded signatures that can be used to create proofs." @@ -150,144 +222,133 @@ async def request_mint(amount: int = 0) -> GetMintResponse: ) async def mint( payload: PostMintRequest, - hash: Optional[str] = None, - payment_hash: Optional[str] = None, ) -> PostMintResponse: """ Requests the minting of tokens belonging to a paid payment request. - Call this endpoint after `GET /mint`. + Call this endpoint after `POST /v1/mint/quote`. """ - logger.trace(f"> POST /mint: {payload}") + logger.trace(f"> POST /v1/mint/bolt11: {payload}") - # BEGIN: backwards compatibility < 0.12 where we used to lookup payments with payment_hash - # We use the payment_hash to lookup the hash from the database and pass that one along. - id = payment_hash or hash - # END: backwards compatibility < 0.12 - - promises = await ledger.mint(payload.outputs, id=id) - blinded_signatures = PostMintResponse(promises=promises) - logger.trace(f"< POST /mint: {blinded_signatures}") + promises = await ledger.mint(outputs=payload.outputs, quote_id=payload.quote) + blinded_signatures = PostMintResponse(signatures=promises) + logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}") return blinded_signatures @router.post( - "/melt", + "/v1/melt/quote/bolt11", + summary="Request a quote for melting tokens", + response_model=PostMeltQuoteResponse, + response_description="Melt tokens for a payment on a supported payment method.", +) +async def get_melt_quote(payload: PostMeltQuoteRequest) -> PostMeltQuoteResponse: + """ + Request a quote for melting tokens. + """ + logger.trace(f"> POST /v1/melt/quote/bolt11: {payload}") + quote = await ledger.melt_quote(payload) # TODO + logger.trace(f"< POST /v1/melt/quote/bolt11: {quote}") + return quote + + +@router.get( + "/v1/melt/quote/{quote}", + summary="Get melt quote", + response_model=PostMeltQuoteResponse, + response_description="Get an existing melt quote to check its status.", +) +async def melt_quote(quote: str) -> PostMeltQuoteResponse: + """ + Get melt quote state. + """ + logger.trace(f"> POST /v1/melt/quote/{quote}") + melt_quote = await ledger.get_melt_quote(quote) + resp = PostMeltQuoteResponse( + quote=melt_quote.quote, + amount=melt_quote.amount, + fee_reserve=melt_quote.fee_reserve, + paid=melt_quote.paid, + ) + logger.trace(f"< POST /v1/melt/quote/{quote}") + return resp + + +@router.post( + "/v1/melt/bolt11", name="Melt tokens", summary=( "Melt tokens for a Bitcoin payment that the mint will make for the user in" " exchange" ), - response_model=GetMeltResponse, + response_model=PostMeltResponse, response_description=( "The state of the payment, a preimage as proof of payment, and a list of" " promises for change." ), ) -async def melt(payload: PostMeltRequest) -> GetMeltResponse: +async def melt(payload: PostMeltRequest) -> PostMeltResponse: """ Requests tokens to be destroyed and sent out via Lightning. """ - logger.trace(f"> POST /melt: {payload}") - ok, preimage, change_promises = await ledger.melt( - payload.proofs, payload.pr, payload.outputs + logger.trace(f"> POST /v1/melt/bolt11: {payload}") + preimage, change_promises = await ledger.melt( + proofs=payload.inputs, quote=payload.quote, outputs=payload.outputs + ) + resp = PostMeltResponse( + paid=True, payment_preimage=preimage, change=change_promises ) - resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises) - logger.trace(f"< POST /melt: {resp}") + logger.trace(f"< POST /v1/melt/bolt11: {resp}") return resp @router.post( - "/check", - name="Check proof state", - summary="Check whether a proof is spent already or is pending in a transaction", - response_model=CheckSpendableResponse, + "/v1/swap", + name="Swap tokens", + summary="Swap inputs for outputs of the same value", + response_model=PostSplitResponse, response_description=( - "Two lists of booleans indicating whether the provided proofs " - "are spendable or pending in a transaction respectively." - ), -) -async def check_spendable( - payload: CheckSpendableRequest, -) -> CheckSpendableResponse: - """Check whether a secret has been spent already or not.""" - logger.trace(f"> POST /check: {payload}") - spendableList, pendingList = await ledger.check_proof_state(payload.proofs) - logger.trace(f"< POST /check : {spendableList}") - logger.trace(f"< POST /check : {pendingList}") - return CheckSpendableResponse(spendable=spendableList, pending=pendingList) - - -@router.post( - "/checkfees", - name="Check fees", - summary="Check fee reserve for a Lightning payment", - response_model=CheckFeesResponse, - response_description="The fees necessary to pay a Lightning invoice.", -) -async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: - """ - Responds with the fees necessary to pay a Lightning invoice. - Used by wallets for figuring out the fees they need to supply together with the payment amount. - This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu). - """ - logger.trace(f"> POST /checkfees: {payload}") - fees_sat = await ledger.get_melt_fees(payload.pr) - logger.trace(f"< POST /checkfees: {fees_sat}") - return CheckFeesResponse(fee=fees_sat) - - -@router.post( - "/split", - name="Split", - summary="Split proofs at a specified amount", - response_model=Union[PostSplitResponse, PostSplitResponse_Deprecated], - response_description=( - "A list of blinded signatures that can be used to create proofs." + "An array of blinded signatures that can be used to create proofs." ), ) async def split( payload: PostSplitRequest, -) -> Union[PostSplitResponse, PostSplitResponse_Deprecated]: +) -> PostSplitResponse: """ Requests a set of Proofs to be split into two a new set of BlindedSignatures. This endpoint is used by Alice to split a set of proofs before making a payment to Carol. It is then used by Carol (by setting split=total) to redeem the tokens. """ - logger.trace(f"> POST /split: {payload}") + logger.trace(f"> POST /v1/swap: {payload}") assert payload.outputs, Exception("no outputs provided.") - promises = await ledger.split(proofs=payload.proofs, outputs=payload.outputs) - - if payload.amount: - # BEGIN backwards compatibility < 0.13 - # old clients expect two lists of promises where the second one's amounts - # sum up to `amount`. The first one is the rest. - # The returned value `promises` has the form [keep1, keep2, ..., send1, send2, ...] - # The sum of the sendx is `amount`. We need to split this into two lists and keep the order of the elements. - frst_promises: List[BlindedSignature] = [] - scnd_promises: List[BlindedSignature] = [] - scnd_amount = 0 - for promise in promises[::-1]: # we iterate backwards - if scnd_amount < payload.amount: - scnd_promises.insert(0, promise) # and insert at the beginning - scnd_amount += promise.amount - else: - frst_promises.insert(0, promise) # and insert at the beginning - logger.trace( - f"Split into keep: {len(frst_promises)}:" - f" {sum([p.amount for p in frst_promises])} sat and send:" - f" {len(scnd_promises)}: {sum([p.amount for p in scnd_promises])} sat" - ) - return PostSplitResponse_Deprecated(fst=frst_promises, snd=scnd_promises) - # END backwards compatibility < 0.13 - else: - return PostSplitResponse(promises=promises) + signatures = await ledger.split(proofs=payload.inputs, outputs=payload.outputs) + + return PostSplitResponse(signatures=signatures) + + +@router.post( + "/v1/checkstate", + name="Check proof state", + summary="Check whether a proof is spent already or is pending in a transaction", + response_model=PostCheckStateResponse, + response_description=( + "Two lists of booleans indicating whether the provided proofs " + "are spendable or pending in a transaction respectively." + ), +) +async def check_state( + payload: PostCheckStateRequest, +) -> PostCheckStateResponse: + """Check whether a secret has been spent already or not.""" + logger.trace(f"> POST /v1/checkstate: {payload}") + proof_states = await ledger.check_proofs_state(payload.secrets) + return PostCheckStateResponse(states=proof_states) @router.post( - "/restore", + "/v1/restore", name="Restore", summary="Restores a blinded signature from a secret", response_model=PostRestoreResponse, diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py new file mode 100644 index 00000000..b1d4be64 --- /dev/null +++ b/cashu/mint/router_deprecated.py @@ -0,0 +1,363 @@ +from typing import List, Optional + +from fastapi import APIRouter +from loguru import logger + +from ..core.base import ( + BlindedSignature, + CheckFeesRequest_deprecated, + CheckFeesResponse_deprecated, + CheckSpendableRequest_deprecated, + CheckSpendableResponse_deprecated, + GetInfoResponse_deprecated, + GetMintResponse_deprecated, + KeysetsResponse_deprecated, + KeysResponse_deprecated, + PostMeltQuoteRequest, + PostMeltRequest_deprecated, + PostMeltResponse_deprecated, + PostMintQuoteRequest, + PostMintRequest_deprecated, + PostMintResponse_deprecated, + PostRestoreResponse, + PostSplitRequest_Deprecated, + PostSplitResponse_Deprecated, + PostSplitResponse_Very_Deprecated, + SpentState, +) +from ..core.errors import CashuError +from ..core.settings import settings +from .startup import ledger + +router_deprecated: APIRouter = APIRouter() + + +@router_deprecated.get( + "/info", + name="Mint information", + summary="Mint information, operator contact information, and other info.", + response_model=GetInfoResponse_deprecated, + response_model_exclude_none=True, + deprecated=True, +) +async def info() -> GetInfoResponse_deprecated: + logger.trace("> GET /info") + return GetInfoResponse_deprecated( + name=settings.mint_info_name, + pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None, + version=f"Nutshell/{settings.version}", + description=settings.mint_info_description, + description_long=settings.mint_info_description_long, + contact=settings.mint_info_contact, + nuts=settings.mint_info_nuts, + motd=settings.mint_info_motd, + parameter={ + "max_peg_in": settings.mint_max_peg_in, + "max_peg_out": settings.mint_max_peg_out, + "peg_out_only": settings.mint_peg_out_only, + }, + ) + + +@router_deprecated.get( + "/keys", + name="Mint public keys", + summary="Get the public keys of the newest mint keyset", + response_description=( + "A dictionary of all supported token values of the mint and their associated" + " public key of the current keyset." + ), + response_model=KeysResponse_deprecated, + deprecated=True, +) +async def keys_deprecated(): + """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" + logger.trace("> GET /keys") + keyset = ledger.get_keyset() + keys = KeysResponse_deprecated.parse_obj(keyset) + return keys.__root__ + + +@router_deprecated.get( + "/keys/{idBase64Urlsafe}", + name="Keyset public keys", + summary="Public keys of a specific keyset", + response_description=( + "A dictionary of all supported token values of the mint and their associated" + " public key for a specific keyset." + ), + response_model=KeysResponse_deprecated, + deprecated=True, +) +async def keyset_deprecated(idBase64Urlsafe: str): + """ + Get the public keys of the mint from a specific keyset id. + The id is encoded in idBase64Urlsafe (by a wallet) and is converted back to + normal base64 before it can be processed (by the mint). + """ + logger.trace(f"> GET /keys/{idBase64Urlsafe}") + id = idBase64Urlsafe.replace("-", "+").replace("_", "/") + keyset = ledger.get_keyset(keyset_id=id) + keys = KeysResponse_deprecated.parse_obj(keyset) + return keys.__root__ + + +@router_deprecated.get( + "/keysets", + name="Active keysets", + summary="Get all active keyset id of the mind", + response_model=KeysetsResponse_deprecated, + response_description="A list of all active keyset ids of the mint.", + deprecated=True, +) +async def keysets_deprecated() -> KeysetsResponse_deprecated: + """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" + logger.trace("> GET /keysets") + keysets = KeysetsResponse_deprecated(keysets=list(ledger.keysets.keys())) + return keysets + + +@router_deprecated.get( + "/mint", + name="Request mint", + summary="Request minting of new tokens", + response_model=GetMintResponse_deprecated, + response_description=( + "A Lightning invoice to be paid and a hash to request minting of new tokens" + " after payment." + ), + deprecated=True, +) +async def request_mint_deprecated(amount: int = 0) -> GetMintResponse_deprecated: + """ + Request minting of new tokens. The mint responds with a Lightning invoice. + This endpoint can be used for a Lightning invoice UX flow. + + Call `POST /mint` after paying the invoice. + """ + logger.trace(f"> GET /mint: amount={amount}") + if amount > 21_000_000 * 100_000_000 or amount <= 0: + raise CashuError(code=0, detail="Amount must be a valid amount of sat.") + if settings.mint_peg_out_only: + raise CashuError(code=0, detail="Mint does not allow minting new tokens.") + quote = await ledger.mint_quote(PostMintQuoteRequest(amount=amount, unit="sat")) + resp = GetMintResponse_deprecated(pr=quote.request, hash=quote.quote) + logger.trace(f"< GET /mint: {resp}") + return resp + + +@router_deprecated.post( + "/mint", + name="Mint tokens", + summary="Mint tokens in exchange for a Bitcoin payment that the user has made", + response_model=PostMintResponse_deprecated, + response_description=( + "A list of blinded signatures that can be used to create proofs." + ), + deprecated=True, +) +async def mint_deprecated( + payload: PostMintRequest_deprecated, + hash: Optional[str] = None, + payment_hash: Optional[str] = None, +) -> PostMintResponse_deprecated: + """ + Requests the minting of tokens belonging to a paid payment request. + + Call this endpoint after `GET /mint`. + """ + logger.trace(f"> POST /mint: {payload}") + + # BEGIN BACKWARDS COMPATIBILITY < 0.15 + # Mint expects "id" in outputs to know which keyset to use to sign them. + for output in payload.outputs: + if not output.id: + # use the deprecated version of the current keyset + output.id = ledger.keyset.duplicate_keyset_id + # END BACKWARDS COMPATIBILITY < 0.15 + + # BEGIN: backwards compatibility < 0.12 where we used to lookup payments with payment_hash + # We use the payment_hash to lookup the hash from the database and pass that one along. + hash = payment_hash or hash + assert hash, "hash must be set." + # END: backwards compatibility < 0.12 + + promises = await ledger.mint(outputs=payload.outputs, quote_id=hash) + blinded_signatures = PostMintResponse_deprecated(promises=promises) + + logger.trace(f"< POST /mint: {blinded_signatures}") + return blinded_signatures + + +@router_deprecated.post( + "/melt", + name="Melt tokens", + summary=( + "Melt tokens for a Bitcoin payment that the mint will make for the user in" + " exchange" + ), + response_model=PostMeltResponse_deprecated, + response_description=( + "The state of the payment, a preimage as proof of payment, and a list of" + " promises for change." + ), + deprecated=True, +) +async def melt_deprecated( + payload: PostMeltRequest_deprecated, +) -> PostMeltResponse_deprecated: + """ + Requests tokens to be destroyed and sent out via Lightning. + """ + logger.trace(f"> POST /melt: {payload}") + # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs + if payload.outputs: + for output in payload.outputs: + if not output.id: + output.id = ledger.keyset.id + # END BACKWARDS COMPATIBILITY < 0.14 + quote = await ledger.melt_quote( + PostMeltQuoteRequest(request=payload.pr, unit="sat") + ) + preimage, change_promises = await ledger.melt( + proofs=payload.proofs, quote=quote.quote, outputs=payload.outputs + ) + resp = PostMeltResponse_deprecated( + paid=True, preimage=preimage, change=change_promises + ) + logger.trace(f"< POST /melt: {resp}") + return resp + + +@router_deprecated.post( + "/checkfees", + name="Check fees", + summary="Check fee reserve for a Lightning payment", + response_model=CheckFeesResponse_deprecated, + response_description="The fees necessary to pay a Lightning invoice.", + deprecated=True, +) +async def check_fees( + payload: CheckFeesRequest_deprecated, +) -> CheckFeesResponse_deprecated: + """ + Responds with the fees necessary to pay a Lightning invoice. + Used by wallets for figuring out the fees they need to supply together with the payment amount. + This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu). + """ + logger.trace(f"> POST /checkfees: {payload}") + quote = await ledger.melt_quote( + PostMeltQuoteRequest(request=payload.pr, unit="sat") + ) + fees_sat = quote.fee_reserve + logger.trace(f"< POST /checkfees: {fees_sat}") + return CheckFeesResponse_deprecated(fee=fees_sat) + + +@router_deprecated.post( + "/split", + name="Split", + summary="Split proofs at a specified amount", + # response_model=Union[ + # PostSplitResponse_Very_Deprecated, PostSplitResponse_Deprecated + # ], + response_description=( + "A list of blinded signatures that can be used to create proofs." + ), + deprecated=True, +) +async def split_deprecated( + payload: PostSplitRequest_Deprecated, + # ) -> Union[PostSplitResponse_Very_Deprecated, PostSplitResponse_Deprecated]: +): + """ + Requests a set of Proofs to be split into two a new set of BlindedSignatures. + + This endpoint is used by Alice to split a set of proofs before making a payment to Carol. + It is then used by Carol (by setting split=total) to redeem the tokens. + """ + logger.trace(f"> POST /split: {payload}") + assert payload.outputs, Exception("no outputs provided.") + # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs + if payload.outputs: + for output in payload.outputs: + if not output.id: + output.id = ledger.keyset.id + # END BACKWARDS COMPATIBILITY < 0.14 + promises = await ledger.split(proofs=payload.proofs, outputs=payload.outputs) + + if payload.amount: + # BEGIN backwards compatibility < 0.13 + # old clients expect two lists of promises where the second one's amounts + # sum up to `amount`. The first one is the rest. + # The returned value `promises` has the form [keep1, keep2, ..., send1, send2, ...] + # The sum of the sendx is `amount`. We need to split this into two lists and keep the order of the elements. + frst_promises: List[BlindedSignature] = [] + scnd_promises: List[BlindedSignature] = [] + scnd_amount = 0 + for promise in promises[::-1]: # we iterate backwards + if scnd_amount < payload.amount: + scnd_promises.insert(0, promise) # and insert at the beginning + scnd_amount += promise.amount + else: + frst_promises.insert(0, promise) # and insert at the beginning + logger.trace( + f"Split into keep: {len(frst_promises)}:" + f" {sum([p.amount for p in frst_promises])} sat and send:" + f" {len(scnd_promises)}: {sum([p.amount for p in scnd_promises])} sat" + ) + return PostSplitResponse_Very_Deprecated(fst=frst_promises, snd=scnd_promises) + # END backwards compatibility < 0.13 + else: + return PostSplitResponse_Deprecated(promises=promises) + + +@router_deprecated.post( + "/check", + name="Check proof state", + summary="Check whether a proof is spent already or is pending in a transaction", + response_model=CheckSpendableResponse_deprecated, + response_description=( + "Two lists of booleans indicating whether the provided proofs " + "are spendable or pending in a transaction respectively." + ), + deprecated=True, +) +async def check_spendable( + payload: CheckSpendableRequest_deprecated, +) -> CheckSpendableResponse_deprecated: + """Check whether a secret has been spent already or not.""" + logger.trace(f"> POST /check: {payload}") + proofs_state = await ledger.check_proofs_state([p.secret for p in payload.proofs]) + spendableList: List[bool] = [] + pendingList: List[bool] = [] + for proof_state in proofs_state: + if proof_state.state == SpentState.unspent: + spendableList.append(True) + pendingList.append(False) + elif proof_state.state == SpentState.spent: + spendableList.append(False) + pendingList.append(False) + elif proof_state.state == SpentState.pending: + spendableList.append(True) + pendingList.append(True) + return CheckSpendableResponse_deprecated( + spendable=spendableList, pending=pendingList + ) + + +@router_deprecated.post( + "/restore", + name="Restore", + summary="Restores a blinded signature from a secret", + response_model=PostRestoreResponse, + response_description=( + "Two lists with the first being the list of the provided outputs that " + "have an associated blinded signature which is given in the second list." + ), + deprecated=True, +) +async def restore(payload: PostMintRequest_deprecated) -> PostRestoreResponse: + assert payload.outputs, Exception("no outputs provided.") + outputs, promises = await ledger.restore(payload.outputs) + return PostRestoreResponse(outputs=outputs, promises=promises) diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 833cc883..acc78559 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -6,11 +6,12 @@ from loguru import logger +from ..core.base import Method, Unit from ..core.db import Database from ..core.migrations import migrate_databases from ..core.settings import settings from ..mint import migrations -from ..mint.crud import LedgerCrud +from ..mint.crud import LedgerCrudSqlite from ..mint.ledger import Ledger logger.debug("Enviroment Settings:") @@ -22,16 +23,29 @@ assert settings.mint_private_key is not None, "No mint private key set." +# strike_backend = getattr(wallets_module, "StrikeUSDWallet")() +# backends = { +# Method.bolt11: {Unit.sat: lightning_backend, Unit.usd: strike_backend}, +# } +# backends = { +# Method.bolt11: {Unit.sat: lightning_backend, Unit.msat: lightning_backend}, +# } +# backends = { +# Method.bolt11: {Unit.sat: lightning_backend, Unit.msat: lightning_backend, +# } +backends = { + Method.bolt11: {Unit.sat: lightning_backend}, +} ledger = Ledger( db=Database("mint", settings.mint_database), seed=settings.mint_private_key, derivation_path=settings.mint_derivation_path, - lightning=lightning_backend, - crud=LedgerCrud(), + backends=backends, + crud=LedgerCrudSqlite(), ) -async def rotate_keys(n_seconds=10): +async def rotate_keys(n_seconds=60): """Rotate keyset epoch every n_seconds. Note: This is just a helper function for testing purposes. """ @@ -39,8 +53,10 @@ async def rotate_keys(n_seconds=10): while True: i += 1 logger.info("Rotating keys.") - ledger.derivation_path = f"0/0/0/{i}" - await ledger.init_keysets() + incremented_derivation_path = ( + "/".join(ledger.derivation_path.split("/")[:-1]) + f"/{i}" + ) + await ledger.activate_keyset(incremented_derivation_path) logger.info(f"Current keyset: {ledger.keyset.id}") await asyncio.sleep(n_seconds) @@ -51,16 +67,24 @@ async def start_mint_init(): await ledger.load_used_proofs() await ledger.init_keysets() - if settings.lightning: - logger.info(f"Using backend: {settings.mint_lightning_backend}") - status = await ledger.lightning.status() - if status.error_message: - logger.warning( - f"The backend for {ledger.lightning.__class__.__name__} isn't" - f" working properly: '{status.error_message}'", - RuntimeWarning, + for derivation_path in settings.mint_derivation_path_list: + await ledger.activate_keyset(derivation_path) + + for method in ledger.backends: + for unit in ledger.backends[method]: + logger.info( + f"Using {ledger.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" ) - logger.info(f"Lightning balance: {status.balance_msat} msat") + status = await ledger.backends[method][unit].status() + if status.error_message: + logger.warning( + "The backend for" + f" {ledger.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'", + RuntimeWarning, + ) + logger.info(f"Backend balance: {status.balance} {unit.name}") logger.info(f"Data dir: {settings.cashu_dir}") logger.info("Mint started.") diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 531207b1..06eb8b72 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Set, Union +from typing import Dict, List, Literal, Optional, Union from loguru import logger @@ -6,7 +6,6 @@ BlindedMessage, BlindedSignature, MintKeyset, - MintKeysets, Proof, ) from ..core.crypto import b_dhke @@ -29,20 +28,20 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb): """Verification functions for the ledger.""" keyset: MintKeyset - keysets: MintKeysets - secrets_used: Set[str] = set() + keysets: Dict[str, MintKeyset] + spent_proofs: Dict[str, Proof] crud: LedgerCrud db: Database async def verify_inputs_and_outputs( - self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None + self, *, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None ): """Checks all proofs and outputs for validity. Args: proofs (List[Proof]): List of proofs to check. outputs (Optional[List[BlindedMessage]], optional): List of outputs to check. - Must be provided for /split but not for /melt. Defaults to None. + Must be provided for a swap but not for a melt. Defaults to None. Raises: Exception: Scripts did not validate. @@ -52,8 +51,8 @@ async def verify_inputs_and_outputs( """ # Verify inputs # Verify proofs are spendable - spendable = await self._check_proofs_spendable(proofs) - if not all(spendable): + spent_proofs = await self._get_proofs_spent([p.secret for p in proofs]) + if not len(spent_proofs) == 0: raise TokenAlreadySpentError() # Verify amounts of inputs if not all([self._verify_amount(p.amount) for p in proofs]): @@ -83,12 +82,31 @@ async def verify_inputs_and_outputs( # Verify inputs and outputs together if not self._verify_input_output_amounts(proofs, outputs): raise TransactionError("input amounts less than output.") + # Verify that input keyset units are the same as output keyset unit + # We have previously verified that all outputs have the same keyset id in `_verify_outputs` + assert outputs[0].id, "output id not set" + if not all([ + self.keysets[p.id].unit == self.keysets[outputs[0].id].unit + for p in proofs + if p.id + ]): + raise TransactionError("input and output keysets have different units.") + # Verify output spending conditions if outputs and not self._verify_output_spending_conditions(proofs, outputs): raise TransactionError("validation of output spending conditions failed.") async def _verify_outputs(self, outputs: List[BlindedMessage]): """Verify that the outputs are valid.""" + logger.trace(f"Verifying {len(outputs)} outputs.") + # Verify all outputs have the same keyset id + if not all([o.id == outputs[0].id for o in outputs]): + raise TransactionError("outputs have different keyset ids.") + # Verify that the keyset id is known and active + if outputs[0].id not in self.keysets: + raise TransactionError("keyset id unknown.") + if not self.keysets[outputs[0].id].active: + raise TransactionError("keyset id inactive.") # Verify amounts of outputs if not all([self._verify_amount(o.amount) for o in outputs]): raise TransactionError("invalid amount.") @@ -98,6 +116,7 @@ async def _verify_outputs(self, outputs: List[BlindedMessage]): # verify that outputs have not been signed previously if any(await self._check_outputs_issued_before(outputs)): raise TransactionError("outputs have already been signed before.") + logger.trace(f"Verified {len(outputs)} outputs.") async def _check_outputs_issued_before(self, outputs: List[BlindedMessage]): """Checks whether the provided outputs have previously been signed by the mint @@ -118,24 +137,32 @@ async def _check_outputs_issued_before(self, outputs: List[BlindedMessage]): result.append(False if promise is None else True) return result - async def _check_proofs_spendable(self, proofs: List[Proof]) -> List[bool]: - """Checks whether the proof was already spent.""" - spendable_states = [] + async def _get_proofs_pending(self, secrets: List[str]) -> Dict[str, Proof]: + """Returns only those proofs that are pending.""" + all_proofs_pending = await self.crud.get_proofs_pending(db=self.db) + proofs_pending = list(filter(lambda p: p.secret in secrets, all_proofs_pending)) + proofs_pending_dict = {p.secret: p for p in proofs_pending} + return proofs_pending_dict + + async def _get_proofs_spent(self, secrets: List[str]) -> Dict[str, Proof]: + """Returns all proofs that are spent.""" + proofs_spent: List[Proof] = [] if settings.mint_cache_secrets: # check used secrets in memory - for p in proofs: - spendable_state = p.secret not in self.secrets_used - spendable_states.append(spendable_state) + for secret in secrets: + if secret in self.spent_proofs: + proofs_spent.append(self.spent_proofs[secret]) else: # check used secrets in database async with self.db.connect() as conn: - for p in proofs: - spendable_state = ( - await self.crud.get_proof_used(db=self.db, proof=p, conn=conn) - is None + for secret in secrets: + spent_proof = await self.crud.get_proof_used( + db=self.db, secret=secret, conn=conn ) - spendable_states.append(spendable_state) - return spendable_states + if spent_proof: + proofs_spent.append(spent_proof) + proofs_spent_dict = {p.secret: p for p in proofs_spent} + return proofs_spent_dict def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: """Verifies that a secret is present and is not too long (DOS prevention).""" @@ -145,23 +172,27 @@ def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: raise SecretTooLongError() return True - def _verify_proof_bdhke(self, proof: Proof): + def _verify_proof_bdhke(self, proof: Proof) -> bool: """Verifies that the proof of promise was issued by this ledger.""" # if no keyset id is given in proof, assume the current one if not proof.id: private_key_amount = self.keyset.private_keys[proof.amount] else: - assert proof.id in self.keysets.keysets, f"keyset {proof.id} unknown" + assert proof.id in self.keysets, f"keyset {proof.id} unknown" logger.trace( - f"Validating proof with keyset {self.keysets.keysets[proof.id].id}." + f"Validating proof {proof.secret} with keyset" + f" {self.keysets[proof.id].id}." ) # use the appropriate active keyset for this proof.id - private_key_amount = self.keysets.keysets[proof.id].private_keys[ - proof.amount - ] + private_key_amount = self.keysets[proof.id].private_keys[proof.amount] C = PublicKey(bytes.fromhex(proof.C), raw=True) - return b_dhke.verify(private_key_amount, C, proof.secret) + valid = b_dhke.verify(private_key_amount, C, proof.secret) + if valid: + logger.trace("Proof verified.") + else: + logger.trace(f"Proof verification failed for {proof.secret} – {proof.C}.") + return valid def _verify_input_output_amounts( self, inputs: List[Proof], outputs: List[BlindedMessage] diff --git a/cashu/nostr/client/client.py b/cashu/nostr/client/client.py index 3af7303b..3cb77662 100644 --- a/cashu/nostr/client/client.py +++ b/cashu/nostr/client/client.py @@ -45,9 +45,9 @@ def __init__(self, private_key: str = "", relays: List[str] = [], connect=True): def connect(self): for relay in self.relays: self.relay_manager.add_relay(relay) - self.relay_manager.open_connections( - {"cert_reqs": ssl.CERT_NONE} - ) # NOTE: This disables ssl certificate verification + self.relay_manager.open_connections({ + "cert_reqs": ssl.CERT_NONE + }) # NOTE: This disables ssl certificate verification def close(self): self.relay_manager.close_connections() @@ -105,15 +105,13 @@ def dm(self, message: str, to_pubkey: PublicKey): self.relay_manager.publish_event(dm) def get_dm(self, sender_publickey: PublicKey, callback_func=None, filter_kwargs={}): - filters = Filters( - [ - Filter( - kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], - pubkey_refs=[sender_publickey.hex()], - **filter_kwargs, - ) - ] - ) + filters = Filters([ + Filter( + kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], + pubkey_refs=[sender_publickey.hex()], + **filter_kwargs, + ) + ]) subscription_id = os.urandom(4).hex() self.relay_manager.add_subscription(subscription_id, filters) diff --git a/cashu/nostr/event.py b/cashu/nostr/event.py index 0ee7ad0b..6271e7ef 100644 --- a/cashu/nostr/event.py +++ b/cashu/nostr/event.py @@ -77,20 +77,18 @@ def verify(self) -> bool: ) def to_message(self) -> str: - return json.dumps( - [ - ClientMessageType.EVENT, - { - "id": self.id, - "pubkey": self.public_key, - "created_at": self.created_at, - "kind": self.kind, - "tags": self.tags, - "content": self.content, - "sig": self.signature, - }, - ] - ) + return json.dumps([ + ClientMessageType.EVENT, + { + "id": self.id, + "pubkey": self.public_key, + "created_at": self.created_at, + "kind": self.kind, + "tags": self.tags, + "content": self.content, + "sig": self.signature, + }, + ]) @dataclass diff --git a/cashu/wallet/api/api_helpers.py b/cashu/wallet/api/api_helpers.py index 1c96b9c9..a40e1dfd 100644 --- a/cashu/wallet/api/api_helpers.py +++ b/cashu/wallet/api/api_helpers.py @@ -1,5 +1,5 @@ from ...core.base import TokenV3 -from ...wallet.crud import get_keyset +from ...wallet.crud import get_keysets async def verify_mints(wallet, tokenObj: TokenV3): @@ -9,5 +9,5 @@ async def verify_mints(wallet, tokenObj: TokenV3): raise Exception("Token has missing mint information.") for mint in mints: assert mint - mint_keysets = await get_keyset(mint_url=mint, db=wallet.db) - assert mint_keysets, "We don't know this mint." + mint_keysets = await get_keysets(mint_url=mint, db=wallet.db) + assert len(mint_keysets), "We don't know this mint." diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 22d46ab9..611bfbfa 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -92,7 +92,9 @@ async def pay( if mint: wallet = await mint_wallet(mint) payment_response = await wallet.pay_invoice(bolt11) - return payment_response + ret = PaymentResponse(**payment_response.dict()) + ret.fee = None # TODO: we can't return an Amount object, overwriting + return ret @router.get( @@ -162,10 +164,8 @@ async def lightning_balance() -> StatusResponse: try: await wallet.load_proofs(reload=True) except Exception as exc: - return StatusResponse(error_message=str(exc), balance_msat=0) - return StatusResponse( - error_message=None, balance_msat=wallet.available_balance * 1000 - ) + return StatusResponse(error_message=str(exc), balance=0) + return StatusResponse(error_message=None, balance=wallet.available_balance * 1000) @router.post( @@ -179,8 +179,6 @@ async def swap( outgoing_mint: str = Query(default=..., description="URL of outgoing mint"), incoming_mint: str = Query(default=..., description="URL of incoming mint"), ): - if not settings.lightning: - raise Exception("lightning not supported") incoming_wallet = await mint_wallet(incoming_mint) outgoing_wallet = await mint_wallet(outgoing_mint) if incoming_wallet.url == outgoing_wallet.url: @@ -191,17 +189,17 @@ async def swap( # pay invoice from outgoing mint await outgoing_wallet.load_proofs(reload=True) - total_amount, fee_reserve_sat = await outgoing_wallet.get_pay_amount_with_fees( - invoice.bolt11 - ) - assert total_amount > 0, "amount must be positive" + quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11) + total_amount = quote.amount + quote.fee_reserve if outgoing_wallet.available_balance < total_amount: raise Exception("balance too low") _, send_proofs = await outgoing_wallet.split_to_send( outgoing_wallet.proofs, total_amount, set_reserved=True ) - await outgoing_wallet.pay_lightning(send_proofs, invoice.bolt11, fee_reserve_sat) + await outgoing_wallet.pay_lightning( + send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote + ) # mint token in incoming mint await incoming_wallet.mint(amount, id=invoice.id) @@ -325,9 +323,9 @@ async def burn( proofs = tokenObj.get_proofs() if delete: - await wallet.invalidate(proofs, check_spendable=False) - else: await wallet.invalidate(proofs) + else: + await wallet.invalidate(proofs, check_spendable=True) return BurnResponse(balance=wallet.available_balance) @@ -361,17 +359,15 @@ async def pending( reserved_date = datetime.utcfromtimestamp( int(grouped_proofs[0].time_reserved) # type: ignore ).strftime("%Y-%m-%d %H:%M:%S") - result.update( - { - f"{i}": { - "amount": sum_proofs(grouped_proofs), - "time": reserved_date, - "ID": key, - "token": token, - "mint": mint, - } + result.update({ + f"{i}": { + "amount": sum_proofs(grouped_proofs), + "time": reserved_date, + "ID": key, + "token": token, + "mint": mint, } - ) + }) return PendingResponse(pending_token=result) @@ -416,22 +412,20 @@ async def wallets(): if w == wallet.name: active_wallet = True if active_wallet: - result.update( - { - f"{w}": { - "balance": sum_proofs(wallet.proofs), - "available": sum_proofs( - [p for p in wallet.proofs if not p.reserved] - ), - } + result.update({ + f"{w}": { + "balance": sum_proofs(wallet.proofs), + "available": sum_proofs([ + p for p in wallet.proofs if not p.reserved + ]), } - ) + }) except Exception: pass return WalletsResponse(wallets=result) -@router.post("/restore", name="Restore wallet", response_model=RestoreResponse) +@router.post("/v1/restore", name="Restore wallet", response_model=RestoreResponse) async def restore( to: int = Query(default=..., description="Counter to which restore the wallet"), ): @@ -439,8 +433,7 @@ async def restore( raise Exception("Counter must be positive") await wallet.load_mint() await wallet.restore_promises_from_to(0, to) - await wallet.invalidate(wallet.proofs) - wallet.status() + await wallet.invalidate(wallet.proofs, check_spendable=True) return RestoreResponse(balance=wallet.available_balance) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index eceb4fe5..2d1d177c 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -14,7 +14,9 @@ from click import Context from loguru import logger -from ...core.base import TokenV3 +from cashu.core.logging import configure_logger + +from ...core.base import TokenV3, Unit from ...core.helpers import sum_proofs from ...core.settings import settings from ...nostr.client.client import NostrClient @@ -26,7 +28,13 @@ ) from ...wallet.wallet import Wallet as Wallet from ..api.api_server import start_api_server -from ..cli.cli_helpers import get_mint_wallet, print_mint_balances, verify_mint +from ..cli.cli_helpers import ( + get_mint_wallet, + get_unit_wallet, + print_balance, + print_mint_balances, + verify_mint, +) from ..helpers import ( deserialize_token_from_string, init_wallet, @@ -74,6 +82,13 @@ def wrapper(*args, **kwargs): default=settings.wallet_name, help=f"Wallet name (default: {settings.wallet_name}).", ) +@click.option( + "--unit", + "-u", + "unit", + default=None, + help=f"Wallet unit (default: {settings.wallet_unit}).", +) @click.option( "--daemon", "-d", @@ -92,7 +107,9 @@ def wrapper(*args, **kwargs): ) @click.pass_context @coro -async def cli(ctx: Context, host: str, walletname: str, tests: bool): +async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): + if settings.debug: + configure_logger() if settings.tor and not TorProxy().check_platform(): error_str = ( "Your settings say TOR=true but the built-in Tor bundle is not supported on" @@ -120,6 +137,7 @@ async def cli(ctx: Context, host: str, walletname: str, tests: bool): ctx.ensure_object(dict) ctx.obj["HOST"] = host or settings.mint_url + ctx.obj["UNIT"] = unit ctx.obj["WALLET_NAME"] = walletname settings.wallet_name = walletname @@ -128,13 +146,13 @@ async def cli(ctx: Context, host: str, walletname: str, tests: bool): # otherwise it will create a mnemonic and store it in the database if ctx.invoked_subcommand == "restore": wallet = await Wallet.with_db( - ctx.obj["HOST"], db_path, name=walletname, skip_private_key=True + ctx.obj["HOST"], db_path, name=walletname, skip_db_read=True ) else: # # we need to run the migrations before we load the wallet for the first time # # otherwise the wallet will not be able to generate a new private key and store it wallet = await Wallet.with_db( - ctx.obj["HOST"], db_path, name=walletname, skip_private_key=True + ctx.obj["HOST"], db_path, name=walletname, skip_db_read=True ) # now with the migrations done, we can load the wallet and generate a new mnemonic if needed wallet = await Wallet.with_db(ctx.obj["HOST"], db_path, name=walletname) @@ -143,11 +161,13 @@ async def cli(ctx: Context, host: str, walletname: str, tests: bool): ctx.obj["WALLET"] = wallet # await init_wallet(ctx.obj["WALLET"], load_proofs=False) - # ------ MUTLIMINT ------- : Select a wallet # only if a command is one of a subset that needs to specify a mint host # if a mint host is already specified as an argument `host`, use it if ctx.invoked_subcommand not in ["send", "invoice", "pay"] or host: return + # ------ MULTIUNIT ------- : Select a unit + ctx.obj["WALLET"] = await get_unit_wallet(ctx) + # ------ MUTLIMINT ------- : Select a wallet # else: we ask the user to select one ctx.obj["WALLET"] = await get_mint_wallet( ctx @@ -165,13 +185,17 @@ async def cli(ctx: Context, host: str, walletname: str, tests: bool): async def pay(ctx: Context, invoice: str, yes: bool): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() - wallet.status() - total_amount, fee_reserve_sat = await wallet.get_pay_amount_with_fees(invoice) + print_balance(ctx) + quote = await wallet.get_pay_amount_with_fees(invoice) + logger.debug(f"Quote: {quote}") + total_amount = quote.amount + quote.fee_reserve if not yes: potential = ( - f" ({total_amount} sat with potential fees)" if fee_reserve_sat else "" + f" ({wallet.unit.str(total_amount)} with potential fees)" + if quote.fee_reserve + else "" ) - message = f"Pay {total_amount - fee_reserve_sat} sat{potential}?" + message = f"Pay {wallet.unit.str(quote.amount)}{potential}?" click.confirm( message, abort=True, @@ -181,27 +205,26 @@ async def pay(ctx: Context, invoice: str, yes: bool): print("Paying Lightning invoice ...", end="", flush=True) assert total_amount > 0, "amount is not positive" if wallet.available_balance < total_amount: - print("Error: Balance too low.") + print(" Error: Balance too low.") return _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) try: melt_response = await wallet.pay_lightning( - send_proofs, invoice, fee_reserve_sat + send_proofs, invoice, quote.fee_reserve, quote.quote ) - except Exception as e: - print(f"\nError paying invoice: {str(e)}") + print(f" Error paying invoice: {str(e)}") return print(" Invoice paid", end="", flush=True) - if melt_response.preimage and melt_response.preimage != "0" * 64: - print(f" (Proof: {melt_response.preimage}).") + if melt_response.payment_preimage and melt_response.payment_preimage != "0" * 64: + print(f" (Preimage: {melt_response.payment_preimage}).") else: print(".") - wallet.status() + print_balance(ctx) @cli.command("invoice", help="Create Lighting invoice.") -@click.argument("amount", type=int) +@click.argument("amount", type=float) @click.option("--id", default="", help="Id of the paid invoice.", type=str) @click.option( "--split", @@ -223,7 +246,8 @@ async def pay(ctx: Context, invoice: str, yes: bool): async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() - wallet.status() + print_balance(ctx) + amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) # in case the user wants a specific split, we create a list of amounts optional_split = None if split: @@ -231,15 +255,16 @@ async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool assert amount >= split, "split must smaller or equal amount" n_splits = amount // split optional_split = [split] * n_splits - logger.debug(f"Requesting split with {n_splits} * {split} sat tokens.") + logger.debug( + f"Requesting split with {n_splits} * {wallet.unit.str(split)} tokens." + ) - if not settings.lightning: - await wallet.mint(amount, split=optional_split) # user requests an invoice - elif amount and not id: + if amount and not id: invoice = await wallet.request_mint(amount) if invoice.bolt11: - print(f"Pay invoice to mint {amount} sat:") + print("") + print(f"Pay invoice to mint {wallet.unit.str(amount)}:") print("") print(f"Invoice: {invoice.bolt11}") print("") @@ -280,7 +305,8 @@ async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool # user paid invoice and want to check it elif amount and id: await wallet.mint(amount, split=optional_split, id=id) - wallet.status() + print("") + print_balance(ctx) return @@ -288,8 +314,6 @@ async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool @click.pass_context @coro async def swap(ctx: Context): - if not settings.lightning: - raise Exception("lightning not supported.") print("Select the mint to swap from:") outgoing_wallet = await get_mint_wallet(ctx, force_select=True) @@ -302,22 +326,23 @@ async def swap(ctx: Context): if incoming_wallet.url == outgoing_wallet.url: raise Exception("mints for swap have to be different") - amount = int(input("Enter amount to swap in sat: ")) + amount = int(input(f"Enter amount to swap in {incoming_wallet.unit.name}: ")) assert amount > 0, "amount is not positive" # request invoice from incoming mint invoice = await incoming_wallet.request_mint(amount) # pay invoice from outgoing mint - total_amount, fee_reserve_sat = await outgoing_wallet.get_pay_amount_with_fees( - invoice.bolt11 - ) + quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11) + total_amount = quote.amount + quote.fee_reserve if outgoing_wallet.available_balance < total_amount: raise Exception("balance too low") _, send_proofs = await outgoing_wallet.split_to_send( outgoing_wallet.proofs, total_amount, set_reserved=True ) - await outgoing_wallet.pay_lightning(send_proofs, invoice.bolt11, fee_reserve_sat) + await outgoing_wallet.pay_lightning( + send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote + ) # mint token in incoming mint await incoming_wallet.mint(amount, id=invoice.id) @@ -339,34 +364,44 @@ async def swap(ctx: Context): @coro async def balance(ctx: Context, verbose): wallet: Wallet = ctx.obj["WALLET"] - await wallet.load_proofs() + await wallet.load_proofs(unit=False) + unit_balances = wallet.balance_per_unit() + if len(unit_balances) > 1 and not ctx.obj["UNIT"]: + print(f"You have balances in {len(unit_balances)} units:") + print("") + for i, (k, v) in enumerate(unit_balances.items()): + unit = k + print(f"Unit {i+1} ({unit}) – Balance: {unit.str(int(v['available']))}") + print("") if verbose: # show balances per keyset keyset_balances = wallet.balance_per_keyset() - if len(keyset_balances) > 1: + if len(keyset_balances): print(f"You have balances in {len(keyset_balances)} keysets:") print("") - for k, v in keyset_balances.items(): + for k, v in keyset_balances.items(): # type: ignore + unit = Unit[str(v["unit"])] print( - f"Keyset: {k} - Balance: {v['available']} sat (pending:" - f" {v['balance']-v['available']} sat)" + f"Keyset: {k} - Balance: {unit.str(int(v['available']))} (pending:" + f" {unit.str(int(v['balance'])-int(v['available']))})" ) print("") await print_mint_balances(wallet) + await wallet.load_proofs(reload=True) if verbose: print( - f"Balance: {wallet.available_balance} sat (pending:" - f" {wallet.balance-wallet.available_balance} sat) in" + f"Balance: {wallet.unit.str(wallet.available_balance)} (pending:" + f" {wallet.unit.str(wallet.balance-wallet.available_balance)}) in" f" {len([p for p in wallet.proofs if not p.reserved])} tokens" ) else: - print(f"Balance: {wallet.available_balance} sat") + print(f"Balance: {wallet.unit.str(wallet.available_balance)}") @cli.command("send", help="Send tokens.") -@click.argument("amount", type=int) +@click.argument("amount", type=float) @click.argument("nostr", type=str, required=False) @click.option( "--nostr", @@ -426,6 +461,7 @@ async def send_command( nosplit: bool, ): wallet: Wallet = ctx.obj["WALLET"] + amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) if not nostr and not nopt: await send( wallet, @@ -439,6 +475,7 @@ async def send_command( await send_nostr( wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes ) + print_balance(ctx) @cli.command("receive", help="Receive tokens.") @@ -493,6 +530,8 @@ async def receive_cli( await receive(wallet, tokenObj) else: print("Error: enter token or use either flag --nostr or --all.") + return + print_balance(ctx) @cli.command("burn", help="Burn spent tokens.") @@ -536,10 +575,10 @@ async def burn(ctx: Context, token: str, all: bool, force: bool, delete: str): proofs = tokenObj.get_proofs() if delete: - await wallet.invalidate(proofs, check_spendable=False) - else: await wallet.invalidate(proofs) - wallet.status() + else: + await wallet.invalidate(proofs, check_spendable=True) + print_balance(ctx) @cli.command("pending", help="Show pending tokens.") @@ -591,7 +630,8 @@ async def pending(ctx: Context, legacy, number: int, offset: int): int(grouped_proofs[0].time_reserved) ).strftime("%Y-%m-%d %H:%M:%S") print( - f"#{i} Amount: {sum_proofs(grouped_proofs)} sat Time:" + f"#{i} Amount:" + f" {wallet.unit.str(sum_proofs(grouped_proofs))} Time:" f" {reserved_date} ID: {key} Mint: {mint}\n" ) print(f"{token}\n") @@ -697,9 +737,10 @@ async def wallets(ctx): if w == ctx.obj["WALLET_NAME"]: active_wallet = True print( - f"Wallet: {w}\tBalance: {sum_proofs(wallet.proofs)} sat" + f"Wallet: {w}\tBalance:" + f" {wallet.unit.str(sum_proofs(wallet.proofs))}" " (available: " - f"{sum_proofs([p for p in wallet.proofs if not p.reserved])} sat){' *' if active_wallet else ''}" + f"{wallet.unit.str(sum_proofs([p for p in wallet.proofs if not p.reserved]))}){' *' if active_wallet else ''}" ) except Exception: pass @@ -737,25 +778,32 @@ async def info(ctx: Context, mint: bool, mnemonic: bool): if mint: for mint_url in mint_list: wallet.url = mint_url - mint_info: dict = (await wallet._load_mint_info()).dict() - print("") - print("Mint information:") - print("") - print(f"Mint URL: {mint_url}") - if mint_info: - print(f"Mint name: {mint_info['name']}") - if mint_info["description"]: - print(f"Description: {mint_info['description']}") - if mint_info["description_long"]: - print(f"Long description: {mint_info['description_long']}") - if mint_info["contact"]: - print(f"Contact: {mint_info['contact']}") - if mint_info["version"]: - print(f"Version: {mint_info['version']}") - if mint_info["motd"]: - print(f"Message of the day: {mint_info['motd']}") - if mint_info["parameter"]: - print(f"Parameter: {mint_info['parameter']}") + try: + mint_info: dict = (await wallet._load_mint_info()).dict() + print("") + print("---- Mint information ----") + print("") + print(f"Mint URL: {mint_url}") + if mint_info: + print(f"Mint name: {mint_info['name']}") + if mint_info.get("description"): + print(f"Description: {mint_info['description']}") + if mint_info.get("description_long"): + print(f"Long description: {mint_info['description_long']}") + if mint_info.get("contact"): + print(f"Contact: {mint_info['contact']}") + if mint_info.get("version"): + print(f"Version: {mint_info['version']}") + if mint_info.get("motd"): + print(f"Message of the day: {mint_info['motd']}") + if mint_info.get("nuts"): + print( + "Supported NUTS:" + f" {', '.join(['NUT-'+str(k) for k in mint_info['nuts'].keys()])}" + ) + except Exception as e: + print("") + print(f"Error fetching mint information for {mint_url}: {e}") if mnemonic: assert wallet.mnemonic @@ -807,7 +855,7 @@ async def restore(ctx: Context, to: int, batch: int): await wallet.restore_wallet_from_mnemonic(mnemonic, to=to, batch=batch) await wallet.load_proofs() - wallet.status() + print_balance(ctx) @cli.command("selfpay", help="Refresh tokens.") @@ -820,7 +868,7 @@ async def selfpay(ctx: Context, all: bool = False): # get balance on this mint mint_balance_dict = await wallet.balance_per_minturl() - mint_balance = mint_balance_dict[wallet.url]["available"] + mint_balance = int(mint_balance_dict[wallet.url]["available"]) # send balance once to mark as reserved await wallet.split_to_send(wallet.proofs, mint_balance, None, set_reserved=True) # load all reserved proofs (including the one we just sent) diff --git a/cashu/wallet/cli/cli_helpers.py b/cashu/wallet/cli/cli_helpers.py index d2b58c7e..8de0a1c7 100644 --- a/cashu/wallet/cli/cli_helpers.py +++ b/cashu/wallet/cli/cli_helpers.py @@ -4,11 +4,62 @@ from click import Context from loguru import logger +from ...core.base import Unit from ...core.settings import settings -from ...wallet.crud import get_keyset +from ...wallet.crud import get_keysets from ...wallet.wallet import Wallet as Wallet +def print_balance(ctx: Context): + wallet: Wallet = ctx.obj["WALLET"] + print(f"Balance: {wallet.unit.str(wallet.available_balance)}") + + +async def get_unit_wallet(ctx: Context, force_select: bool = False): + """Helper function that asks the user for an input to select which unit they want to load. + + Args: + ctx (Context): Context + force_select (bool, optional): Force the user to select a unit. Defaults to False. + """ + wallet: Wallet = ctx.obj["WALLET"] + await wallet.load_proofs(reload=True, unit=False) + # show balances per unit + unit_balances = wallet.balance_per_unit() + if ctx.obj["UNIT"] in [u.name for u in unit_balances] and not force_select: + wallet.unit = Unit[ctx.obj["UNIT"]] + elif len(unit_balances) > 1 and not ctx.obj["UNIT"]: + print(f"You have balances in {len(unit_balances)} units:") + print("") + for i, (k, v) in enumerate(unit_balances.items()): + unit = k + print(f"Unit {i+1} ({unit}) – Balance: {unit.str(int(v['available']))}") + print("") + unit_nr_str = input( + f"Select unit [1-{len(unit_balances)}] or " + f"press enter for your default '{Unit[settings.wallet_unit]}': " + ) + if not unit_nr_str: # default unit + unit = Unit[settings.wallet_unit] + elif unit_nr_str.isdigit() and int(unit_nr_str) <= len( + unit_balances + ): # specific unit + unit = list(unit_balances.keys())[int(unit_nr_str) - 1] + else: + raise Exception("invalid input.") + + print(f"Selected unit: {unit}") + print("") + # load this unit into a wallet + wallet.unit = unit + elif len(unit_balances) == 1 and not ctx.obj["UNIT"]: + wallet.unit = list(unit_balances.keys())[0] + elif ctx.obj["UNIT"]: + wallet.unit = Unit[ctx.obj["UNIT"]] + settings.wallet_unit = wallet.unit.name + return wallet + + async def get_mint_wallet(ctx: Context, force_select: bool = False): """ Helper function that asks the user for an input to select which mint they want to load. @@ -56,19 +107,19 @@ async def get_mint_wallet(ctx: Context, force_select: bool = False): return mint_wallet -async def print_mint_balances(wallet, show_mints=False): +async def print_mint_balances(wallet: Wallet, show_mints: bool = False): """ Helper function that prints the balances for each mint URL that we have tokens from. """ # get balances per mint - mint_balances = await wallet.balance_per_minturl() - + mint_balances = await wallet.balance_per_minturl(unit=wallet.unit) # if we have a balance on a non-default mint, we show its URL keysets = [k for k, v in wallet.balance_per_keyset().items()] for k in keysets: - ks = await get_keyset(id=str(k), db=wallet.db) - if ks and ks.mint_url != wallet.url: - show_mints = True + keysets_local = await get_keysets(id=str(k), db=wallet.db) + for kl in keysets_local: + if kl and kl.mint_url != wallet.url: + show_mints = True # or we have a balance on more than one mint # show balances per mint @@ -76,9 +127,10 @@ async def print_mint_balances(wallet, show_mints=False): print(f"You have balances in {len(mint_balances)} mints:") print("") for i, (k, v) in enumerate(mint_balances.items()): + unit = Unit[str(v["unit"])] print( - f"Mint {i+1}: Balance: {v['available']} sat (pending:" - f" {v['balance']-v['available']} sat) URL: {k}" + f"Mint {i+1}: Balance: {unit.str(int(v['available']))} (pending:" + f" {unit.str(int(v['balance'])-int(v['available']))}) URL: {k}" ) print("") @@ -93,7 +145,7 @@ async def verify_mint(mint_wallet: Wallet, url: str): # dummy Wallet to check the database later # mint_wallet = Wallet(url, os.path.join(settings.cashu_dir, ctx.obj["WALLET_NAME"])) # we check the db whether we know this mint already and ask the user if not - mint_keysets = await get_keyset(mint_url=url, db=mint_wallet.db) + mint_keysets = await get_keysets(mint_url=url, db=mint_wallet.db) if mint_keysets is None: # we encountered a new mint and ask for a user confirmation print("") @@ -107,4 +159,4 @@ async def verify_mint(mint_wallet: Wallet, url: str): default=True, ) else: - logger.debug(f"We know keyset {mint_keysets.id} already") + logger.debug(f"We know mint {url} already") diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index 844bf63e..0e9b9b45 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -167,8 +167,8 @@ async def store_keyset( await (conn or db).execute( # type: ignore """ INSERT INTO keysets - (id, mint_url, valid_from, valid_to, first_seen, active, public_keys) - VALUES (?, ?, ?, ?, ?, ?, ?) + (id, mint_url, valid_from, valid_to, first_seen, active, public_keys, unit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( keyset.id, @@ -176,18 +176,19 @@ async def store_keyset( keyset.valid_from or int(time.time()), keyset.valid_to or int(time.time()), keyset.first_seen or int(time.time()), - True, + keyset.active, keyset.serialize(), + keyset.unit.name, ), ) -async def get_keyset( +async def get_keysets( id: str = "", mint_url: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -) -> Optional[WalletKeyset]: +) -> List[WalletKeyset]: clauses = [] values: List[Any] = [] clauses.append("active = ?") @@ -202,14 +203,18 @@ async def get_keyset( if clauses: where = f"WHERE {' AND '.join(clauses)}" - row = await (conn or db).fetchone( # type: ignore + row = await (conn or db).fetchall( # type: ignore f""" SELECT * from keysets {where} """, tuple(values), ) - return WalletKeyset.from_row(row) if row is not None else None + ret = [] + for r in row: + keyset = WalletKeyset.from_row(r) + ret.append(keyset) + return ret async def store_lightning_invoice( diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index 50425dc6..26f0d1c6 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -10,7 +10,7 @@ from ..core.migrations import migrate_databases from ..core.settings import settings from ..wallet import migrations -from ..wallet.crud import get_keyset +from ..wallet.crud import get_keysets from ..wallet.wallet import Wallet @@ -47,15 +47,16 @@ async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3): mint_wallet = await Wallet.with_db( t.mint, os.path.join(settings.cashu_dir, wallet.name) ) - keysets = mint_wallet._get_proofs_keysets(t.proofs) - logger.debug(f"Keysets in tokens: {keysets}") + keyset_ids = mint_wallet._get_proofs_keysets(t.proofs) + logger.trace(f"Keysets in tokens: {keyset_ids}") # loop over all keysets - for keyset in set(keysets): - await mint_wallet.load_mint() + for keyset_id in set(keyset_ids): + await mint_wallet.load_mint(keyset_id) + mint_wallet.unit = mint_wallet.keysets[keyset_id].unit # redeem proofs of this keyset - redeem_proofs = [p for p in t.proofs if p.id == keyset] + redeem_proofs = [p for p in t.proofs if p.id == keyset_id] _, _ = await mint_wallet.redeem(redeem_proofs) - print(f"Received {sum_proofs(redeem_proofs)} sats") + print(f"Received {mint_wallet.unit.str(sum_proofs(redeem_proofs))}") def serialize_TokenV2_to_TokenV3(tokenv2: TokenV2): @@ -138,21 +139,21 @@ async def receive( keyset_in_token = proofs[0].id assert keyset_in_token # we get the keyset from the db - mint_keysets = await get_keyset(id=keyset_in_token, db=wallet.db) - assert mint_keysets, Exception("we don't know this keyset") - assert mint_keysets.mint_url, Exception("we don't know this mint's URL") + mint_keysets = await get_keysets(id=keyset_in_token, db=wallet.db) + assert mint_keysets, Exception(f"we don't know this keyset: {keyset_in_token}") + mint_keyset = mint_keysets[0] + assert mint_keyset.mint_url, Exception("we don't know this mint's URL") # now we have the URL mint_wallet = await Wallet.with_db( - mint_keysets.mint_url, + mint_keyset.mint_url, os.path.join(settings.cashu_dir, wallet.name), ) await mint_wallet.load_mint(keyset_in_token) _, _ = await mint_wallet.redeem(proofs) - print(f"Received {sum_proofs(proofs)} sats") + print(f"Received {mint_wallet.unit.str(sum_proofs(proofs))}") # reload main wallet so the balance updates await wallet.load_proofs(reload=True) - wallet.status() return wallet.available_balance @@ -224,5 +225,4 @@ async def send( ) print(token) - wallet.status() return wallet.available_balance, token diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index 36b9c8f2..c4b17d0b 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -1,5 +1,6 @@ import bolt11 +from ...core.base import Amount, SpentState, Unit from ...core.helpers import sum_promises from ...core.settings import settings from ...lightning.base import ( @@ -54,25 +55,28 @@ async def pay_invoice(self, pr: str) -> PaymentResponse: Returns: bool: True if successful """ - total_amount, fee_reserve_sat = await self.get_pay_amount_with_fees(pr) + quote = await self.get_pay_amount_with_fees(pr) + total_amount = quote.amount + quote.fee_reserve assert total_amount > 0, "amount is not positive" if self.available_balance < total_amount: print("Error: Balance too low.") return PaymentResponse(ok=False) _, send_proofs = await self.split_to_send(self.proofs, total_amount) try: - resp = await self.pay_lightning(send_proofs, pr, fee_reserve_sat) + resp = await self.pay_lightning( + send_proofs, pr, quote.fee_reserve, quote.quote + ) if resp.change: - fees_paid_sat = fee_reserve_sat - sum_promises(resp.change) + fees_paid_sat = quote.fee_reserve - sum_promises(resp.change) else: - fees_paid_sat = fee_reserve_sat + fees_paid_sat = quote.fee_reserve invoice_obj = bolt11.decode(pr) return PaymentResponse( ok=True, checking_id=invoice_obj.payment_hash, - preimage=resp.preimage, - fee_msat=fees_paid_sat * 1000, + preimage=resp.payment_preimage, + fee=Amount(Unit.msat, fees_paid_sat), ) except Exception as e: print("Exception:", e) @@ -126,19 +130,15 @@ async def get_payment_status(self, payment_hash: str) -> PaymentStatus: if not proofs: return PaymentStatus(paid=False) # "proofs not fount (in db)" proofs_states = await self.check_proof_state(proofs) - if ( - not proofs_states - or not proofs_states.spendable - or not proofs_states.pending - ): + if not proofs_states: return PaymentStatus(paid=False) # "states not fount" - if all(proofs_states.spendable) and all(proofs_states.pending): + if all([p.state == SpentState.pending for p in proofs_states.states]): return PaymentStatus(paid=None) # "pending (with check)" - if not any(proofs_states.spendable) and not any(proofs_states.pending): + if any([p.state == SpentState.spent for p in proofs_states.states]): # NOTE: consider adding this check in wallet.py and mark the invoice as paid if all proofs are spent return PaymentStatus(paid=True) # "paid (with check)" - if all(proofs_states.spendable) and not any(proofs_states.pending): + if all([p.state == SpentState.unspent for p in proofs_states.states]): return PaymentStatus(paid=False) # "failed (with check)" return PaymentStatus(paid=None) # "undefined state" @@ -148,6 +148,4 @@ async def get_balance(self) -> StatusResponse: Returns: int: balance in satoshis """ - return StatusResponse( - error_message=None, balance_msat=self.available_balance * 1000 - ) + return StatusResponse(error_message=None, balance=self.available_balance * 1000) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 5cd6c19e..813258e8 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -196,9 +196,11 @@ async def m010_add_ids_to_proofs_and_out_to_invoices(db: Database): Columns that store mint and melt id for proofs and invoices. """ async with db.connect() as conn: + print("Running wallet migrations") await conn.execute("ALTER TABLE proofs ADD COLUMN mint_id TEXT") - await conn.execute("ALTER TABLE proofs_used ADD COLUMN mint_id TEXT") await conn.execute("ALTER TABLE proofs ADD COLUMN melt_id TEXT") + + await conn.execute("ALTER TABLE proofs_used ADD COLUMN mint_id TEXT") await conn.execute("ALTER TABLE proofs_used ADD COLUMN melt_id TEXT") # column in invoices for marking whether the invoice is incoming (out=False) or outgoing (out=True) @@ -209,3 +211,10 @@ async def m010_add_ids_to_proofs_and_out_to_invoices(db: Database): await conn.execute("ALTER TABLE invoices RENAME COLUMN hash TO id") # add column payment_hash await conn.execute("ALTER TABLE invoices ADD COLUMN payment_hash TEXT") + + +async def m011_keysets_add_unit(db: Database): + async with db.connect() as conn: + # add column for storing the unit of a keyset + await conn.execute("ALTER TABLE keysets ADD COLUMN unit TEXT") + await conn.execute("UPDATE keysets SET unit = 'sat'") diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index 315d9dbd..a2d824c7 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -133,12 +133,9 @@ async def add_witnesses_to_outputs( return outputs # if any of the proofs provided require SIG_ALL, we must provide it - if any( - [ - P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL - for p in proofs - ] - ): + if any([ + P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs + ]): outputs = await self.add_p2pk_witnesses_to_outputs(outputs) return outputs @@ -184,9 +181,9 @@ async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: return proofs logger.debug("Spending conditions detected.") # P2PK signatures - if all( - [Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs] - ): + if all([ + Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs + ]): logger.debug("P2PK redemption detected.") proofs = await self.add_p2pk_witnesses_to_proofs(proofs) diff --git a/cashu/wallet/protocols.py b/cashu/wallet/protocols.py index effb430b..0dca62a2 100644 --- a/cashu/wallet/protocols.py +++ b/cashu/wallet/protocols.py @@ -1,5 +1,7 @@ from typing import Protocol +import httpx + from ..core.crypto.secp import PrivateKey from ..core.db import Database @@ -14,3 +16,11 @@ class SupportsDb(Protocol): class SupportsKeysets(Protocol): keyset_id: str + + +class SupportsHttpxClient(Protocol): + httpx: httpx.AsyncClient + + +class SupportsMintURL(Protocol): + url: str diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index f22d3ea5..1ef1eff5 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -1,5 +1,6 @@ import base64 import hashlib +import os from typing import List, Optional, Tuple from bip32 import BIP32 @@ -73,8 +74,8 @@ async def _init_private_key(self, from_mnemonic: Optional[str] = None) -> None: self.seed = mnemo.to_seed(mnemonic_str) self.mnemonic = mnemonic_str - logger.debug(f"Using seed: {self.seed.hex()}") - logger.debug(f"Using mnemonic: {mnemonic_str}") + # logger.debug(f"Using seed: {self.seed.hex()}") + # logger.debug(f"Using mnemonic: {mnemonic_str}") # if no mnemonic was in the database, store the new one if ret_db is None: @@ -92,20 +93,21 @@ async def _init_private_key(self, from_mnemonic: Optional[str] = None) -> None: except Exception as e: logger.error(e) - async def _generate_secret(self, randombits=128) -> str: + async def _generate_secret(self) -> str: """Returns base64 encoded deterministic random string. NOTE: This method should probably retire after `deterministic_secrets`. We are deriving secrets from a counter but don't store the respective blinding factor. We won't be able to restore any ecash generated with these secrets. """ - secret_counter = await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id - ) - logger.trace(f"secret_counter: {secret_counter}") - s, _, _ = await self.generate_determinstic_secret(secret_counter) - # return s.decode("utf-8") - return hashlib.sha256(s).hexdigest() + # secret_counter = await bump_secret_derivation(db=self.db, keyset_id=keyset_id) + # logger.trace(f"secret_counter: {secret_counter}") + # s, _, _ = await self.generate_determinstic_secret(secret_counter, keyset_id) + # # return s.decode("utf-8") + # return hashlib.sha256(s).hexdigest() + + # return random 32 byte hex string + return hashlib.sha256(os.urandom(32)).hexdigest() async def generate_determinstic_secret( self, counter: int @@ -116,11 +118,20 @@ async def generate_determinstic_secret( """ assert self.bip32, "BIP32 not initialized yet." # integer keyset id modulo max number of bip32 child keys - keyest_id = int.from_bytes(base64.b64decode(self.keyset_id), "big") % ( - 2**31 - 1 - ) - logger.trace(f"keyset id: {self.keyset_id} becomes {keyest_id}") - token_derivation_path = f"m/129372'/0'/{keyest_id}'/{counter}'" + try: + keyest_id_int = int.from_bytes(bytes.fromhex(self.keyset_id), "big") % ( + 2**31 - 1 + ) + except ValueError: + # BEGIN: BACKWARDS COMPATIBILITY < 0.15.0 keyset id is not hex + # calculate an integer keyset id from the base64 encoded keyset id + keyest_id_int = int.from_bytes(base64.b64decode(self.keyset_id), "big") % ( + 2**31 - 1 + ) + # END: BACKWARDS COMPATIBILITY < 0.15.0 keyset id is not hex + + logger.trace(f"keyset id: {self.keyset_id} becomes {keyest_id_int}") + token_derivation_path = f"m/129372'/0'/{keyest_id_int}'/{counter}'" # for secret secret_derivation_path = f"{token_derivation_path}/0" logger.trace(f"secret derivation path: {secret_derivation_path}") diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index bc146c4b..8ace2478 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1,6 +1,6 @@ import base64 +import copy import json -import math import time import uuid from itertools import groupby @@ -16,25 +16,34 @@ from ..core.base import ( BlindedMessage, BlindedSignature, - CheckFeesRequest, - CheckSpendableRequest, - CheckSpendableResponse, + CheckFeesResponse_deprecated, DLEQWallet, GetInfoResponse, - GetMeltResponse, - GetMintResponse, Invoice, KeysetsResponse, + KeysResponse, + PostCheckStateRequest, + PostCheckStateResponse, + PostMeltQuoteRequest, + PostMeltQuoteResponse, PostMeltRequest, + PostMeltResponse, + PostMeltResponse_deprecated, + PostMintQuoteRequest, + PostMintQuoteResponse, PostMintRequest, PostMintResponse, PostRestoreResponse, PostSplitRequest, + PostSplitResponse, Proof, + ProofState, + SpentState, TokenV2, TokenV2Mint, TokenV3, TokenV3Token, + Unit, WalletKeyset, ) from ..core.crypto import b_dhke @@ -48,7 +57,8 @@ from ..tor.tor import TorProxy from ..wallet.crud import ( bump_secret_derivation, - get_keyset, + get_keysets, + get_lightning_invoice, get_proofs, invalidate_proof, secret_used, @@ -63,6 +73,7 @@ from .htlc import WalletHTLC from .p2pk import WalletP2PK from .secrets import WalletSecrets +from .wallet_deprecated import LedgerAPIDeprecated def async_set_httpx_client(func): @@ -94,7 +105,7 @@ async def wrapper(self, *args, **kwargs): proxies=proxies_dict, # type: ignore headers=headers_dict, base_url=self.url, - timeout=5, + timeout=None if settings.debug else 60, ) return await func(self, *args, **kwargs) @@ -114,11 +125,11 @@ async def wrapper(self, *args, **kwargs): return wrapper -class LedgerAPI(object): +class LedgerAPI(LedgerAPIDeprecated, object): keyset_id: str # holds current keyset id keysets: Dict[str, WalletKeyset] # holds keysets mint_keyset_ids: List[str] # holds active keyset ids of the mint - + unit: Unit mint_info: GetInfoResponse # holds info about mint tor: TorProxy db: Database @@ -135,7 +146,9 @@ async def _init_s(self): return @staticmethod - def raise_on_error(resp: Response) -> None: + def raise_on_error_request( + resp: Response, + ) -> None: """Raises an exception if the response from the mint contains an error. Args: @@ -165,62 +178,70 @@ async def _load_mint_keys(self, keyset_id: Optional[str] = None) -> None: AssertionError: if mint URL is not set AssertionError: if no keys are received from the mint """ + logger.trace(f"Loading mint keys: {keyset_id}") assert len( self.url ), "Ledger not initialized correctly: mint URL not specified yet. " - keyset_local: Union[WalletKeyset, None] = None + keyset: WalletKeyset + + # if we want to load a specific keyset if keyset_id: - # check if current keyset is in db - logger.trace(f"Checking if keyset {keyset_id} is in database.") - keyset_local = await get_keyset(keyset_id, db=self.db) - if keyset_local: - logger.trace(f"Found keyset {keyset_id} in database.") + # check if this keyset is in db + logger.trace(f"Loading keyset {keyset_id} from database.") + keysets = await get_keysets(keyset_id, db=self.db) + if keysets: + logger.debug(f"Found keyset {keyset_id} in database.") + # select as current keyset + keyset = keysets[0] else: logger.trace( f"Could not find keyset {keyset_id} in database. Loading keyset" " from mint." ) - keyset = keyset_local - - if keyset_local is None and keyset_id: - # get requested keyset from mint - logger.trace(f"Getting keyset {keyset_id} from mint.") - keyset = await self._get_keys_of_keyset(self.url, keyset_id) + keyset = await self._get_keys_of_keyset(keyset_id) + if keyset.id == keyset_id: + # NOTE: Derived keyset *could* have a different id than the one + # requested because of the duplicate keysets for < 0.15.0 that's + # why we make an explicit check here to not overwrite an existing + # keyset with the incoming one. + logger.debug( + f"Storing new mint keyset: {keyset.id} ({keyset.unit.name})" + ) + await store_keyset(keyset=keyset, db=self.db) + keysets = [keyset] else: - # get current keyset - logger.trace("Getting current keyset from mint.") - keyset = await self._get_keys(self.url) + # else we load all active keysets of the mint and choose + # an appropriate one as the current keyset + keysets = await self._get_keys() + assert len(keysets), Exception("did not receive any keys") + # check if we have all keysets in db + for keyset in keysets: + keysets_in_db = await get_keysets(keyset.id, db=self.db) + if not keysets_in_db: + logger.debug( + "Storing new current mint keyset:" + f" {keyset.id} ({keyset.unit.name})" + ) + await store_keyset(keyset=keyset, db=self.db) - assert keyset - assert keyset.id - assert len(keyset.public_keys) > 0, "did not receive keys from mint." - - if keyset_id and keyset_id != keyset.id: - # NOTE: Because of the upcoming change of how to calculate keyset ids - # with version 0.15.0, we overwrite the calculated keyset id with the - # requested one. This is a temporary fix and should be removed once all - # ecash is transitioned to 0.15.0. - logger.debug( - f"Keyset ID mismatch: {keyset_id} != {keyset.id}. This can happen due" - " to a version upgrade." - ) - keyset.id = keyset_id or keyset.id + # select a keyset that matches the wallet unit + wallet_unit_keysets = [k for k in keysets if k.unit == self.unit] + assert len(wallet_unit_keysets) > 0, f"no keyset for unit {self.unit.name}." + keyset = [k for k in keysets if k.unit == self.unit][0] - # if the keyset is not in the database, store it - if keyset_local is None: - keyset_local_from_mint = await get_keyset(keyset.id, db=self.db) - if not keyset_local_from_mint: - logger.debug(f"Storing new mint keyset: {keyset.id}") - await store_keyset(keyset=keyset, db=self.db) + # load all keysets we have into memory + for k in keysets: + self.keysets[k.id] = k + # make sure we have selected a current keyset + assert keyset + assert keyset.id + assert len(keyset.public_keys) > 0, "no public keys in keyset" # set current keyset id self.keyset_id = keyset.id logger.debug(f"Current mint keyset: {self.keyset_id}") - # add keyset to keysets dict - self.keysets[keyset.id] = keyset - async def _load_mint_keysets(self) -> List[str]: """Loads the keyset IDs of the mint. @@ -230,9 +251,10 @@ async def _load_mint_keysets(self) -> List[str]: Raises: AssertionError: if no keysets are received from the mint """ + logger.trace("Loading mint keysets.") mint_keysets = [] try: - mint_keysets = await self._get_keyset_ids(self.url) + mint_keysets = await self._get_keyset_ids() except Exception: assert self.keysets[ self.keyset_id @@ -244,7 +266,7 @@ async def _load_mint_keysets(self) -> List[str]: async def _load_mint_info(self) -> GetInfoResponse: """Loads the mint info from the mint.""" - self.mint_info = await self._get_info(self.url) + self.mint_info = await self._get_info() logger.debug(f"Mint info: {self.mint_info}") return self.mint_info @@ -254,6 +276,7 @@ async def _load_mint(self, keyset_id: str = "") -> None: `keyset_id` or gets the keys of the active keyset from the mint. Gets the active keyset ids of the mint and stores in `self.mint_keyset_ids`. """ + logger.trace("Loading mint.") await self._load_mint_keys(keyset_id) await self._load_mint_keysets() try: @@ -280,7 +303,7 @@ async def _check_used_secrets(self, secrets): """ @async_set_httpx_client - async def _get_keys(self, url: str) -> WalletKeyset: + async def _get_keys(self) -> List[WalletKeyset]: """API that gets the current keys of the mint Args: @@ -293,25 +316,42 @@ async def _get_keys(self, url: str) -> WalletKeyset: Exception: If no keys are received from the mint """ resp = await self.httpx.get( - join(url, "keys"), + join(self.url, "/v1/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(public_keys=keyset_keys, mint_url=url) - return keyset + # 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_keys_of_keyset(self, url: str, keyset_id: str) -> WalletKeyset: + async def _get_keys_of_keyset(self, 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: @@ -322,25 +362,35 @@ async def _get_keys_of_keyset(self, url: str, keyset_id: str) -> WalletKeyset: """ keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_") resp = await self.httpx.get( - join(url, f"keys/{keyset_id_urlsafe}"), + join(self.url, f"/v1/keys/{keyset_id_urlsafe}"), ) - self.raise_on_error(resp) - keys = resp.json() - assert len(keys), Exception("did not receive any 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_of_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) keyset_keys = { int(amt): PublicKey(bytes.fromhex(val), raw=True) - for amt, val in keys.items() + for amt, val in keys.keysets[0].keys.items() } - keyset = WalletKeyset(id=keyset_id, public_keys=keyset_keys, mint_url=url) + keyset = WalletKeyset( + id=keyset_id, + unit=keys.keysets[0].unit, + public_keys=keyset_keys, + mint_url=self.url, + ) return keyset @async_set_httpx_client - async def _get_keyset_ids(self, url: str) -> List[str]: + async def _get_keyset_ids(self) -> List[str]: """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 @@ -348,21 +398,25 @@ async def _get_keyset_ids(self, url: str) -> List[str]: Exception: If no keysets are received from the mint """ resp = await self.httpx.get( - join(url, "keysets"), + join(self.url, "/v1/keysets"), ) - self.raise_on_error(resp) + # 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_ids_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) assert len(keysets.keysets), Exception("did not receive any keysets") - return keysets.keysets + return [k.id for k in keysets.keysets] @async_set_httpx_client - async def _get_info(self, url: str) -> GetInfoResponse: + async def _get_info(self) -> GetInfoResponse: """API that gets the mint info. - Args: - url (str): Mint URL - Returns: GetInfoResponse: Current mint info @@ -370,17 +424,23 @@ async def _get_info(self, url: str) -> GetInfoResponse: Exception: If the mint info request fails """ resp = await self.httpx.get( - join(url, "info"), + join(self.url, "/v1/info"), ) - self.raise_on_error(resp) + # 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 request_mint(self, amount) -> Invoice: - """Requests a mint from the server and returns Lightning invoice. + async def mint_quote(self, amount) -> Invoice: + """Requests a mint quote from the server and returns a payment request. Args: amount (int): Amount of tokens to mint @@ -391,17 +451,26 @@ async def request_mint(self, amount) -> Invoice: Raises: Exception: If the mint request fails """ - logger.trace("Requesting mint: GET /mint") - resp = await self.httpx.get(join(self.url, "mint"), params={"amount": amount}) - self.raise_on_error(resp) + logger.trace("Requesting mint: GET /v1/mint/bolt11") + payload = PostMintQuoteRequest(unit=self.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() - mint_response = GetMintResponse.parse_obj(return_dict) - decoded_invoice = bolt11.decode(mint_response.pr) + mint_response = PostMintQuoteResponse.parse_obj(return_dict) + decoded_invoice = bolt11.decode(mint_response.request) return Invoice( amount=amount, - bolt11=mint_response.pr, + bolt11=mint_response.request, payment_hash=decoded_invoice.payment_hash, - id=mint_response.hash, + id=mint_response.quote, out=False, time_created=int(time.time()), ) @@ -409,13 +478,13 @@ async def request_mint(self, amount) -> Invoice: @async_set_httpx_client @async_ensure_mint_loaded async def mint( - self, outputs: List[BlindedMessage], id: Optional[str] = None + 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 - id (str, optional): Id of the paid invoice. Defaults to None. + quote (str): Quote ID. Returns: list[Proof]: List of proofs. @@ -423,22 +492,110 @@ async def mint( Raises: Exception: If the minting fails """ - outputs_payload = PostMintRequest(outputs=outputs) - logger.trace("Checking Lightning invoice. POST /mint") + 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, "mint"), - json=outputs_payload.dict(), - params={ - "hash": id, - "payment_hash": id, # backwards compatibility pre 0.12.0 - }, + join(self.url, "/v1/mint/bolt11"), + json=payload, # type: ignore ) - self.raise_on_error(resp) + # 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 /mint") - promises = PostMintResponse.parse_obj(response_dict).promises + 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) -> PostMeltQuoteResponse: + """Checks whether the Lightning payment is internal.""" + invoice_obj = bolt11.decode(payment_request) + assert invoice_obj.amount_msat, "invoice must have amount" + payload = PostMeltQuoteRequest(unit=self.unit.name, request=payment_request) + 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=invoice_obj.amount_msat // 1000, + fee_reserve=ret.fee or 0, + paid=False, + ) + # 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]], + ) -> PostMeltResponse: + """ + 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.pay_lightning_deprecated( + proofs=proofs, outputs=outputs, invoice=invoice.bolt11 + ) + return PostMeltResponse( + paid=ret.paid, payment_preimage=ret.preimage, change=ret.change + ) + # END backwards compatibility < 0.15.0 + self.raise_on_error_request(resp) + return_dict = resp.json() + return PostMeltResponse.parse_obj(return_dict) + @async_set_httpx_client @async_ensure_mint_loaded async def split( @@ -447,12 +604,12 @@ async def split( outputs: List[BlindedMessage], ) -> List[BlindedSignature]: """Consume proofs and create new promises based on amount split.""" - logger.debug("Calling split. POST /split") - split_payload = PostSplitRequest(proofs=proofs, outputs=outputs) + 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 the /split""" + """strips away fields from the model that aren't necessary for /v1/swap""" proofs_include = { "id", "amount", @@ -462,17 +619,23 @@ def _splitrequest_include_fields(proofs: List[Proof]): } return { "outputs": ..., - "proofs": {i: proofs_include for i in range(len(proofs))}, + "inputs": {i: proofs_include for i in range(len(proofs))}, } resp = await self.httpx.post( - join(self.url, "split"), + join(self.url, "/v1/swap"), json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore ) - self.raise_on_error(resp) + # 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 = PostMintResponse.parse_obj(promises_dict) - promises = [BlindedSignature(**p.dict()) for p in mint_response.promises] + 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.") @@ -481,72 +644,33 @@ def _splitrequest_include_fields(proofs: List[Proof]): @async_set_httpx_client @async_ensure_mint_loaded - async def check_proof_state(self, proofs: List[Proof]): + 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 = CheckSpendableRequest(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))}, - } - + payload = PostCheckStateRequest(secrets=[p.secret for p in 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.parse_obj(return_dict) - return states - - @async_set_httpx_client - @async_ensure_mint_loaded - async def check_fees(self, payment_request: str): - """Checks whether the Lightning payment is internal.""" - payload = CheckFeesRequest(pr=payment_request) - resp = await self.httpx.post( - join(self.url, "checkfees"), + join(self.url, "/v1/checkstate"), json=payload.dict(), ) - self.raise_on_error(resp) - - return_dict = resp.json() - return return_dict - - @async_set_httpx_client - @async_ensure_mint_loaded - async def pay_lightning( - self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]] - ) -> GetMeltResponse: - """ - Accepts proofs and a lightning invoice to pay in exchange. - """ - - payload = PostMeltRequest(proofs=proofs, pr=invoice, outputs=outputs) - logger.debug("Calling melt. POST /melt") - - 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", "witness"} - return { - "proofs": {i: proofs_include for i in range(len(proofs))}, - "pr": ..., - "outputs": ..., - } - - resp = await self.httpx.post( - join(self.url, "melt"), - json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore - timeout=None, - ) - self.raise_on_error(resp) - return_dict = resp.json() - - return GetMeltResponse.parse_obj(return_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(secret=p.secret, state=SpentState.unspent)) + elif spendable and pending: + states.append(ProofState(secret=p.secret, state=SpentState.pending)) + else: + states.append(ProofState(secret=p.secret, state=SpentState.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 @@ -556,9 +680,15 @@ async def restore_promises( """ Asks the mint to restore promises corresponding to outputs. """ - payload = PostMintRequest(outputs=outputs) - resp = await self.httpx.post(join(self.url, "restore"), json=payload.dict()) - self.raise_on_error(resp) + 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) return returnObj.outputs, returnObj.promises @@ -589,9 +719,13 @@ def __init__( self.db = Database("wallet", db) self.proofs: List[Proof] = [] self.name = name + self.unit = Unit[settings.wallet_unit] super().__init__(url=url, db=self.db) - logger.debug(f"Wallet initialized with mint URL {url}") + logger.debug("Wallet initialized") + logger.debug(f"Mint URL: {url}") + logger.debug(f"Database: {db}") + logger.debug(f"Unit: {self.unit.name}") @classmethod async def with_db( @@ -599,7 +733,7 @@ async def with_db( url: str, db: str, name: str = "no_name", - skip_private_key: bool = False, + skip_db_read: bool = False, ): """Initializes a wallet with a database and initializes the private key. @@ -607,15 +741,22 @@ async def with_db( url (str): URL of the mint. db (str): Path to the database. name (str, optional): Name of the wallet. Defaults to "no_name". - skip_private_key (bool, optional): If true, the private key is not initialized. Defaults to False. + skip_db_read (bool, optional): If true, values from db like private key and + keysets are not loaded. Useful for running only migrations and returning. + Defaults to False. Returns: Wallet: Initialized wallet. """ + logger.trace(f"Initializing wallet with database: {db}") self = cls(url=url, db=db, name=name) await self._migrate_database() - if not skip_private_key: + if not skip_db_read: + logger.trace("Mint init: loading private key and keysets from db.") await self._init_private_key() + keysets_list = await get_keysets(mint_url=url, db=self.db) + self.keysets = {k.id: k for k in keysets_list} + return self async def _migrate_database(self): @@ -623,6 +764,7 @@ async def _migrate_database(self): await migrate_databases(self.db, migrations) except Exception as e: logger.error(f"Could not run migrations: {e}") + raise e # ---------- API ---------- @@ -636,13 +778,30 @@ async def load_mint(self, keyset_id: str = ""): """ await super()._load_mint(keyset_id) - async def load_proofs(self, reload: bool = False) -> None: + async def load_proofs( + self, reload: bool = False, unit: Union[Unit, bool] = True + ) -> None: """Load all proofs from the database.""" if self.proofs and not reload: logger.debug("Proofs already loaded.") return self.proofs = await get_proofs(db=self.db) + await self.load_keysets() + unit = self.unit if unit is True else unit + if unit: + self.unit = unit + self.proofs = [ + p + for p in self.proofs + if p.id in self.keysets and self.keysets[p.id].unit == unit + ] + + async def load_keysets(self) -> None: + """Load all keysets from the database.""" + keysets = await get_keysets(db=self.db) + for keyset in keysets: + self.keysets[keyset.id] = keyset async def request_mint(self, amount: int) -> Invoice: """Request a Lightning invoice for minting tokens. @@ -653,22 +812,22 @@ async def request_mint(self, amount: int) -> Invoice: Returns: Invoice: Lightning invoice """ - invoice = await super().request_mint(amount) + invoice = await super().mint_quote(amount) await store_lightning_invoice(db=self.db, invoice=invoice) return invoice async def mint( self, amount: int, + id: str, split: Optional[List[int]] = None, - id: Optional[str] = None, ) -> List[Proof]: """Mint tokens of a specific amount after an invoice has been paid. Args: amount (int): Total amount of tokens to be minted + id (str): Id for looking up the paid Lightning invoice. split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. - id (Optional[str], optional): Id for looking up the paid Lightning invoice. Defaults to None (for testing with LIGHTNING=False). Raises: Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. @@ -703,9 +862,9 @@ async def mint( # will raise exception if mint is unsuccessful promises = await super().mint(outputs, id) - # success, bump secret counter in database + promises_keyset_id = promises[0].id await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id, by=len(amounts) + db=self.db, keyset_id=promises_keyset_id, by=len(amounts) ) proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) @@ -758,6 +917,8 @@ async def split( assert len(proofs) > 0, "no proofs provided." assert sum_proofs(proofs) >= amount, "amount too large." assert amount > 0, "amount must be positive." + # make sure we're operating on an independent copy of proofs + proofs = copy.copy(proofs) # potentially add witnesses to unlock provided proofs (if they indicate one) proofs = await self.add_witnesses_to_proofs(proofs) @@ -779,9 +940,6 @@ async def split( # generate secrets for receiver secret_locks = [secret_lock.serialize() for i in range(len(scnd_outputs))] logger.debug(f"Creating proofs with custom secrets: {secret_locks}") - assert len(secret_locks) == len( - scnd_outputs - ), "number of secret_locks does not match number of outputs." # append predefined secrets (to send) to random secrets (to keep) # generate secrets to keep secrets = [ @@ -802,7 +960,7 @@ async def split( # potentially add witnesses to outputs based on what requirement the proofs indicate outputs = await self.add_witnesses_to_outputs(proofs, outputs) - # Call /split API + # Call swap API promises = await super().split(proofs, outputs) # Construct proofs from returned promises (i.e., unblind the signatures) @@ -817,8 +975,8 @@ async def split( return keep_proofs, send_proofs async def pay_lightning( - self, proofs: List[Proof], invoice: str, fee_reserve_sat: int - ) -> GetMeltResponse: + self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str + ) -> PostMeltResponse: """Pays a lightning invoice and returns the status of the payment. Args: @@ -827,6 +985,8 @@ async def pay_lightning( fee_reserve_sat (int): Amount of fees to be reserved for the payment. """ + # Make sure we're operating on an independent copy of proofs + proofs = copy.copy(proofs) # Generate a number of blank outputs for any overpaid fees. As described in # NUT-08, the mint will imprint these outputs with a value depending on the @@ -839,15 +999,13 @@ async def pay_lightning( n_change_outputs * [1], change_secrets, change_rs ) - # we store the invoice object in the database to later be able to check the invoice state - # generate a random ID for this transaction - melt_id = await self._generate_secret() - # store the melt_id in proofs async with self.db.connect() as conn: for p in proofs: - p.melt_id = melt_id - await update_proof(p, melt_id=melt_id, conn=conn) + p.melt_id = quote_id + await update_proof(p, melt_id=quote_id, conn=conn) + + # we store the invoice object in the database to later be able to check the invoice state decoded_invoice = bolt11.decode(invoice) invoice_obj = Invoice( @@ -857,13 +1015,13 @@ async def pay_lightning( # preimage=status.preimage, paid=False, time_paid=int(time.time()), - id=melt_id, # store the same ID in the invoice + id=quote_id, # store the same ID in the invoice out=True, # outgoing invoice ) # store invoice in db as not paid yet await store_lightning_invoice(db=self.db, invoice=invoice_obj) - status = await super().pay_lightning(proofs, invoice, change_outputs) + status = await super().melt(quote_id, proofs, change_outputs) # if payment fails if not status.paid: @@ -874,17 +1032,17 @@ async def pay_lightning( raise Exception("could not pay invoice.") # invoice was paid successfully - # we don't have to recheck the spendable sate of these tokens when invalidating - await self.invalidate(proofs, check_spendable=False) + + await self.invalidate(proofs) # update paid status in db - logger.trace(f"Settings invoice {melt_id} to paid.") + logger.trace(f"Settings invoice {quote_id} to paid.") await update_lightning_invoice( db=self.db, - id=melt_id, + id=quote_id, paid=True, time_paid=int(time.time()), - preimage=status.preimage, + preimage=status.payment_preimage, ) # handle change and produce proofs @@ -895,10 +1053,10 @@ async def pay_lightning( change_rs[: len(status.change)], change_derivation_paths[: len(status.change)], ) - logger.debug(f"Received change: {sum_proofs(change_proofs)} sat") + logger.debug(f"Received change: {self.unit.str(sum_proofs(change_proofs))}") return status - async def check_proof_state(self, proofs): + async def check_proof_state(self, proofs) -> PostCheckStateResponse: return await super().check_proof_state(proofs) # ---------- TOKEN MECHANICS ---------- @@ -954,10 +1112,10 @@ async def _construct_proofs( proofs: List[Proof] = [] for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): if promise.id not in self.keysets: + logger.debug(f"Keyset {promise.id} not found in db. Loading from mint.") # we don't have the keyset for this promise, so we load it await self._load_mint_keys(promise.id) assert promise.id in self.keysets, "Could not load keyset." - C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) C = b_dhke.step3_alice( C_, r, self.keysets[promise.id].public_keys[promise.amount] @@ -990,15 +1148,17 @@ async def _construct_proofs( logger.trace(f"Constructed {len(proofs)} proofs.") # add new proofs to wallet - self.proofs += proofs + self.proofs += copy.copy(proofs) # store new proofs in database await self._store_proofs(proofs) return proofs - @staticmethod def _construct_outputs( - amounts: List[int], secrets: List[str], rs: List[PrivateKey] = [] + self, + amounts: List[int], + secrets: List[str], + rs: List[PrivateKey] = [], ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: """Takes a list of amounts and secrets and returns outputs. Outputs are blinded messages `outputs` and blinding factors `rs` @@ -1025,7 +1185,9 @@ def _construct_outputs( for secret, amount, r in zip(secrets, amounts, rs_): B_, r = b_dhke.step1_alice(secret, r or None) rs_return.append(r) - output = BlindedMessage(amount=amount, B_=B_.serialize().hex()) + output = BlindedMessage( + amount=amount, B_=B_.serialize().hex(), id=self.keyset_id + ) outputs.append(output) logger.trace(f"Constructing output: {output}, r: {r.serialize()}") @@ -1043,18 +1205,23 @@ async def _store_proofs(self, proofs): @staticmethod def _get_proofs_per_keyset(proofs: List[Proof]): - return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)} # type: ignore + return { + key: list(group) for key, group in groupby(proofs, lambda p: p.id) if key + } async def _get_proofs_per_minturl( - self, proofs: List[Proof] + self, proofs: List[Proof], unit: Optional[Unit] = None ) -> Dict[str, List[Proof]]: ret: Dict[str, List[Proof]] = {} - for id in set([p.id for p in proofs]): + keyset_ids = set([p.id for p in proofs]) + for id in keyset_ids: if id is None: continue - keyset_crud = await get_keyset(id=id, db=self.db) - assert keyset_crud is not None, f"keyset {id} not found" - keyset: WalletKeyset = keyset_crud + keysets_crud = await get_keysets(id=id, db=self.db) + assert keysets_crud, f"keyset {id} not found" + keyset: WalletKeyset = keysets_crud[0] + if unit and keyset.unit != unit: + continue assert keyset.mint_url if keyset.mint_url not in ret: ret[keyset.mint_url] = [p for p in proofs if p.id == id] @@ -1062,6 +1229,19 @@ async def _get_proofs_per_minturl( ret[keyset.mint_url].extend([p for p in proofs if p.id == id]) return ret + def _get_proofs_per_unit(self, proofs: List[Proof]) -> Dict[Unit, List[Proof]]: + ret: Dict[Unit, List[Proof]] = {} + for proof in proofs: + if proof.id not in self.keysets: + logger.error(f"Keyset {proof.id} not found in wallet.") + continue + unit = self.keysets[proof.id].unit + if unit not in ret: + ret[unit] = [proof] + else: + ret[unit].append(proof) + return ret + def _get_proofs_keysets(self, proofs: List[Proof]) -> List[str]: """Extracts all keyset ids from a list of proofs. @@ -1080,7 +1260,8 @@ async def _get_keyset_urls(self, keysets: List[str]) -> Dict[str, List[str]]: """ mint_urls: Dict[str, List[str]] = {} for ks in set(keysets): - keyset_db = await get_keyset(id=ks, db=self.db) + keysets_db = await get_keysets(id=ks, db=self.db) + keyset_db = keysets_db[0] if keysets_db else None if keyset_db and keyset_db.mint_url: mint_urls[keyset_db.mint_url] = ( mint_urls[keyset_db.mint_url] + [ks] @@ -1167,7 +1348,8 @@ async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> Token # iterate through unique keyset ids for id in set(keysets): # load the keyset from the db - keyset_db = await get_keyset(id=id, db=self.db) + keysets_db = await get_keysets(id=id, db=self.db) + keyset_db = keysets_db[0] if keysets_db else None if keyset_db and keyset_db.mint_url and keyset_db.id: # we group all mints according to URL if keyset_db.mint_url not in mints: @@ -1186,6 +1368,12 @@ async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> Token async def _serialize_token_base64_tokenv2(self, token: TokenV2) -> str: """ Takes a TokenV2 and serializes it in urlsafe_base64. + + Args: + token (TokenV2): TokenV2 object to be serialized + + Returns: + str: Serialized token """ # encode the token as a base64 string token_base64 = base64.urlsafe_b64encode( @@ -1207,6 +1395,16 @@ async def _select_proofs_to_send( 2) Proofs that have a keyset id that is in self.mint_keyset_ids (all active keysets of mint) 3) Include all proofs that have an older keyset than the current keyset of the mint (to get rid of old epochs). 4) If the target amount is not reached, add proofs of the current keyset until it is. + + Args: + proofs (List[Proof]): List of proofs to select from + amount_to_send (int): Amount to select proofs for + + Returns: + List[Proof]: List of proofs to send + + Raises: + Exception: If the balance is too low to send the amount """ send_proofs: List[Proof] = [] @@ -1255,7 +1453,7 @@ async def set_reserved(self, proofs: List[Proof], reserved: bool) -> None: await update_proof(proof, reserved=reserved, send_id=uuid_str, db=self.db) async def invalidate( - self, proofs: List[Proof], check_spendable=True + self, proofs: List[Proof], check_spendable=False ) -> List[Proof]: """Invalidates all unspendable tokens supplied in proofs. @@ -1269,8 +1467,8 @@ async def invalidate( invalidated_proofs: List[Proof] = [] if check_spendable: proof_states = await self.check_proof_state(proofs) - for i, spendable in enumerate(proof_states.spendable): - if not spendable: + for i, state in enumerate(proof_states.states): + if state.state == SpentState.spent: invalidated_proofs.append(proofs[i]) else: invalidated_proofs = proofs @@ -1278,7 +1476,7 @@ async def invalidate( if invalidated_proofs: logger.trace( f"Invalidating {len(invalidated_proofs)} proofs worth" - f" {sum_proofs(invalidated_proofs)} sat." + f" {self.unit.str(sum_proofs(invalidated_proofs))}." ) async with self.db.connect() as conn: @@ -1298,13 +1496,11 @@ async def get_pay_amount_with_fees(self, invoice: str): Decodes the amount from a Lightning invoice and returns the total amount (amount+fees) to be paid. """ - decoded_invoice = bolt11.decode(invoice) - assert decoded_invoice.amount_msat, "invoices has no amount." - # check if it's an internal payment - fees = int((await self.check_fees(invoice))["fee"]) - logger.debug(f"Mint wants {fees} sat as fee reserve.") - amount = math.ceil((decoded_invoice.amount_msat + fees * 1000) / 1000) # 1% fee - return amount, fees + melt_quote = await self.melt_quote(invoice) + logger.debug( + f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve." + ) + return melt_quote async def split_to_send( self, @@ -1353,33 +1549,50 @@ def proof_amounts(self): """Returns a sorted list of amounts of all proofs""" return [p.amount for p in sorted(self.proofs, key=lambda p: p.amount)] - def status(self): - print(f"Balance: {self.available_balance} sat") - - def balance_per_keyset(self): - return { + def balance_per_keyset(self) -> Dict[str, Dict[str, Union[int, str]]]: + ret: Dict[str, Dict[str, Union[int, str]]] = { key: { "balance": sum_proofs(proofs), "available": sum_proofs([p for p in proofs if not p.reserved]), } for key, proofs in self._get_proofs_per_keyset(self.proofs).items() } + for key in ret.keys(): + if key in self.keysets: + ret[key]["unit"] = self.keysets[key].unit.name + return ret - async def balance_per_minturl(self): - balances = await self._get_proofs_per_minturl(self.proofs) - balances_return = { + def balance_per_unit(self) -> Dict[Unit, Dict[str, Union[int, str]]]: + ret: Dict[Unit, Dict[str, Union[int, str]]] = { + unit: { + "balance": sum_proofs(proofs), + "available": sum_proofs([p for p in proofs if not p.reserved]), + } + for unit, proofs in self._get_proofs_per_unit(self.proofs).items() + } + return ret + + async def balance_per_minturl( + self, unit: Optional[Unit] = None + ) -> Dict[str, Dict[str, Union[int, str]]]: + balances = await self._get_proofs_per_minturl(self.proofs, unit=unit) + balances_return: Dict[str, Dict[str, Union[int, str]]] = { key: { "balance": sum_proofs(proofs), "available": sum_proofs([p for p in proofs if not p.reserved]), } for key, proofs in balances.items() } + for key in balances_return.keys(): + if unit: + balances_return[key]["unit"] = unit.name return dict(sorted(balances_return.items(), key=lambda item: item[0])) # type: ignore async def restore_wallet_from_mnemonic( self, mnemonic: Optional[str], to: int = 2, batch: int = 25 ) -> None: - """Restores the wallet from a mnemonic + """ + Restores the wallet from a mnemonic. Args: mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. @@ -1404,7 +1617,9 @@ async def restore_wallet_from_mnemonic( restored_proofs = await self.restore_promises_from_to(i, i + batch - 1) if len(restored_proofs) == 0: stop_counter += 1 - spendable_proofs = await self.invalidate(restored_proofs) + spendable_proofs = await self.invalidate( + restored_proofs, check_spendable=True + ) if len(spendable_proofs): n_last_restored_proofs = len(spendable_proofs) print(f"Restored {sum_proofs(restored_proofs)} sat") diff --git a/cashu/wallet/wallet_deprecated.py b/cashu/wallet/wallet_deprecated.py new file mode 100644 index 00000000..cca86fb3 --- /dev/null +++ b/cashu/wallet/wallet_deprecated.py @@ -0,0 +1,425 @@ +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, + CheckFeesRequest_deprecated, + CheckFeesResponse_deprecated, + CheckSpendableRequest_deprecated, + CheckSpendableResponse_deprecated, + GetInfoResponse, + GetInfoResponse_deprecated, + GetMintResponse_deprecated, + Invoice, + KeysetsResponse_deprecated, + PostMeltRequest_deprecated, + PostMeltResponse_deprecated, + PostMintRequest_deprecated, + PostMintResponse_deprecated, + PostRestoreResponse, + PostSplitRequest_Deprecated, + PostSplitResponse_Deprecated, + Proof, + WalletKeyset, +) +from ..core.crypto.secp import PublicKey +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 + """ + resp_dict = resp.json() + 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"}) + ) + 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( + 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, use_deprecated_id=True + ) + return keyset + + @async_set_httpx_client + async def _get_keys_of_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( + url + f"/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, + use_deprecated_id=True, + ) + return keyset + + @async_set_httpx_client + @async_ensure_mint_loaded_deprecated + async def _get_keyset_ids_deprecated(self, url: str) -> List[str]: + """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( + 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") + return keysets.keysets + + @async_set_httpx_client + @async_ensure_mint_loaded_deprecated + async def request_mint_deprecated(self, amount) -> Invoice: + """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(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 Invoice( + amount=amount, + bolt11=mint_response.pr, + id=mint_response.hash, + payment_hash=decoded_invoice.payment_hash, + out=False, + ) + + @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_payload = PostMintRequest_deprecated(outputs=outputs) + + 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( + 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 pay_lightning_deprecated( + self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]] + ): + """ + Accepts proofs and a lightning invoice to pay in exchange. + """ + logger.warning("Using deprecated API call: POST /melt") + payload = PostMeltRequest_deprecated(proofs=proofs, pr=invoice, outputs=outputs) + + 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( + 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") + split_payload = PostSplitRequest_Deprecated(proofs=proofs, outputs=outputs) + + # 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 = PostSplitResponse_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") + payload = PostMintRequest_deprecated(outputs=outputs) + 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) + return returnObj.outputs, returnObj.promises + + @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) diff --git a/poetry.lock b/poetry.lock index 40405c08..ace9c370 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,24 +2,24 @@ [[package]] name = "anyio" -version = "4.0.0" +version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "anyio-4.0.0-py3-none-any.whl", hash = "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f"}, - {file = "anyio-4.0.0.tar.gz", hash = "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a"}, + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] [package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.22)"] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] [[package]] name = "asn1crypto" @@ -104,33 +104,29 @@ files = [ [[package]] name = "black" -version = "23.9.1" +version = "23.11.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, - {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, - {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, - {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, - {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, - {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, - {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, - {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, - {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, - {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, - {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, - {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, - {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, + {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"}, + {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"}, + {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"}, + {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"}, + {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, + {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, + {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, + {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, + {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"}, + {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"}, + {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"}, + {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"}, + {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"}, + {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"}, + {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"}, + {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"}, + {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, + {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, ] [package.dependencies] @@ -169,13 +165,13 @@ secp256k1 = "*" [[package]] name = "certifi" -version = "2023.7.22" +version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] [[package]] @@ -402,34 +398,34 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "41.0.4" +version = "41.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"}, - {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"}, - {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"}, - {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"}, - {file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"}, - {file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"}, - {file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"}, + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797"}, + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d"}, + {file = "cryptography-41.0.5-cp37-abi3-win32.whl", hash = "sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936"}, + {file = "cryptography-41.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84"}, + {file = "cryptography-41.0.5.tar.gz", hash = "sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7"}, ] [package.dependencies] @@ -497,13 +493,13 @@ tests = ["dj-database-url", "dj-email-url", "django-cache-url", "pytest"] [[package]] name = "exceptiongroup" -version = "1.1.3" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -511,19 +507,20 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.103.0" +version = "0.104.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "fastapi-0.103.0-py3-none-any.whl", hash = "sha256:61ab72c6c281205dd0cbaccf503e829a37e0be108d965ac223779a8479243665"}, - {file = "fastapi-0.103.0.tar.gz", hash = "sha256:4166732f5ddf61c33e9fa4664f73780872511e0598d4d5434b1816dc1e6d9421"}, + {file = "fastapi-0.104.1-py3-none-any.whl", hash = "sha256:752dc31160cdbd0436bb93bad51560b57e525cbb1d4bbf6f4904ceee75548241"}, + {file = "fastapi-0.104.1.tar.gz", hash = "sha256:e5e4540a7c5e1dcfbbcf5b903c234feddcdcd881f191977a1c5dfd917487e7ae"}, ] [package.dependencies] +anyio = ">=3.7.1,<4.0.0" pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" starlette = ">=0.27.0,<0.28.0" -typing-extensions = ">=4.5.0" +typing-extensions = ">=4.8.0" [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] @@ -544,19 +541,19 @@ pyinstrument = ">=4.4.0" [[package]] name = "filelock" -version = "3.12.4" +version = "3.13.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, - {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] -typing = ["typing-extensions (>=4.7.1)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] [[package]] name = "h11" @@ -571,40 +568,40 @@ files = [ [[package]] name = "httpcore" -version = "0.18.0" +version = "1.0.2" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, - {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, + {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, + {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, ] [package.dependencies] -anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = "==1.*" [package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.23.0)"] [[package]] name = "httpx" -version = "0.25.1" +version = "0.25.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, - {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, + {file = "httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"}, + {file = "httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8"}, ] [package.dependencies] anyio = "*" certifi = "*" -httpcore = "*" +httpcore = "==1.*" idna = "*" sniffio = "*" socksio = {version = "==1.*", optional = true, markers = "extra == \"socks\""} @@ -617,13 +614,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.5.30" +version = "2.5.32" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.30-py2.py3-none-any.whl", hash = "sha256:afe67f26ae29bab007ec21b03d4114f41316ab9dd15aa8736a167481e108da54"}, - {file = "identify-2.5.30.tar.gz", hash = "sha256:f302a4256a15c849b91cfcdcec052a8ce914634b2f77ae87dad29cd749f2d88d"}, + {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"}, + {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"}, ] [package.extras] @@ -631,13 +628,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] @@ -721,38 +718,38 @@ files = [ [[package]] name = "mypy" -version = "1.6.0" +version = "1.7.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:091f53ff88cb093dcc33c29eee522c087a438df65eb92acd371161c1f4380ff0"}, - {file = "mypy-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb7ff4007865833c470a601498ba30462b7374342580e2346bf7884557e40531"}, - {file = "mypy-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49499cf1e464f533fc45be54d20a6351a312f96ae7892d8e9f1708140e27ce41"}, - {file = "mypy-1.6.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4c192445899c69f07874dabda7e931b0cc811ea055bf82c1ababf358b9b2a72c"}, - {file = "mypy-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:3df87094028e52766b0a59a3e46481bb98b27986ed6ded6a6cc35ecc75bb9182"}, - {file = "mypy-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c8835a07b8442da900db47ccfda76c92c69c3a575872a5b764332c4bacb5a0a"}, - {file = "mypy-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24f3de8b9e7021cd794ad9dfbf2e9fe3f069ff5e28cb57af6f873ffec1cb0425"}, - {file = "mypy-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:856bad61ebc7d21dbc019b719e98303dc6256cec6dcc9ebb0b214b81d6901bd8"}, - {file = "mypy-1.6.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:89513ddfda06b5c8ebd64f026d20a61ef264e89125dc82633f3c34eeb50e7d60"}, - {file = "mypy-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f8464ed410ada641c29f5de3e6716cbdd4f460b31cf755b2af52f2d5ea79ead"}, - {file = "mypy-1.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:971104bcb180e4fed0d7bd85504c9036346ab44b7416c75dd93b5c8c6bb7e28f"}, - {file = "mypy-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab98b8f6fdf669711f3abe83a745f67f50e3cbaea3998b90e8608d2b459fd566"}, - {file = "mypy-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a69db3018b87b3e6e9dd28970f983ea6c933800c9edf8c503c3135b3274d5ad"}, - {file = "mypy-1.6.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:dccd850a2e3863891871c9e16c54c742dba5470f5120ffed8152956e9e0a5e13"}, - {file = "mypy-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:f8598307150b5722854f035d2e70a1ad9cc3c72d392c34fffd8c66d888c90f17"}, - {file = "mypy-1.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fea451a3125bf0bfe716e5d7ad4b92033c471e4b5b3e154c67525539d14dc15a"}, - {file = "mypy-1.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e28d7b221898c401494f3b77db3bac78a03ad0a0fff29a950317d87885c655d2"}, - {file = "mypy-1.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4b7a99275a61aa22256bab5839c35fe8a6887781862471df82afb4b445daae6"}, - {file = "mypy-1.6.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7469545380dddce5719e3656b80bdfbb217cfe8dbb1438532d6abc754b828fed"}, - {file = "mypy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:7807a2a61e636af9ca247ba8494031fb060a0a744b9fee7de3a54bed8a753323"}, - {file = "mypy-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2dad072e01764823d4b2f06bc7365bb1d4b6c2f38c4d42fade3c8d45b0b4b67"}, - {file = "mypy-1.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b19006055dde8a5425baa5f3b57a19fa79df621606540493e5e893500148c72f"}, - {file = "mypy-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eba8a7a71f0071f55227a8057468b8d2eb5bf578c8502c7f01abaec8141b2f"}, - {file = "mypy-1.6.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e0db37ac4ebb2fee7702767dfc1b773c7365731c22787cb99f507285014fcaf"}, - {file = "mypy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:c69051274762cccd13498b568ed2430f8d22baa4b179911ad0c1577d336ed849"}, - {file = "mypy-1.6.0-py3-none-any.whl", hash = "sha256:9e1589ca150a51d9d00bb839bfeca2f7a04f32cd62fad87a847bc0818e15d7dc"}, - {file = "mypy-1.6.0.tar.gz", hash = "sha256:4f3d27537abde1be6d5f2c96c29a454da333a2a271ae7d5bc7110e6d4b7beb3f"}, + {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"}, + {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"}, + {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"}, + {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"}, + {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"}, + {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"}, + {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"}, + {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"}, + {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"}, + {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"}, + {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"}, + {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"}, + {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"}, + {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"}, + {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"}, + {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"}, + {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"}, + {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"}, + {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"}, + {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"}, + {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"}, + {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"}, + {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"}, + {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"}, + {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"}, + {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"}, + {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"}, ] [package.dependencies] @@ -763,6 +760,7 @@ typing-extensions = ">=4.1.0" [package.extras] dmypy = ["psutil (>=4.0)"] install-types = ["pip"] +mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] @@ -792,13 +790,13 @@ setuptools = "*" [[package]] name = "outcome" -version = "1.2.0" +version = "1.3.0.post0" description = "Capture the outcome of Python function calls." optional = false python-versions = ">=3.7" files = [ - {file = "outcome-1.2.0-py2.py3-none-any.whl", hash = "sha256:c4ab89a56575d6d38a05aa16daeaa333109c1f96167aba8901ab18b6b5e0f7f5"}, - {file = "outcome-1.2.0.tar.gz", hash = "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672"}, + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, ] [package.dependencies] @@ -828,13 +826,13 @@ files = [ [[package]] name = "platformdirs" -version = "3.11.0" +version = "4.0.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, - {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, + {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, + {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, ] [package.extras] @@ -1134,13 +1132,13 @@ types = ["typing-extensions"] [[package]] name = "pytest" -version = "7.4.2" +version = "7.4.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, - {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, ] [package.dependencies] @@ -1517,19 +1515,19 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [[package]] name = "virtualenv" -version = "20.24.5" +version = "20.24.7" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, - {file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, + {file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"}, + {file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<4" +platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] @@ -1553,13 +1551,13 @@ test = ["websockets"] [[package]] name = "wheel" -version = "0.41.2" +version = "0.41.3" description = "A built-package format for Python" optional = false python-versions = ">=3.7" files = [ - {file = "wheel-0.41.2-py3-none-any.whl", hash = "sha256:75909db2664838d015e3d9139004ee16711748a52c8f336b52882266540215d8"}, - {file = "wheel-0.41.2.tar.gz", hash = "sha256:0c5ac5ff2afb79ac23ab82bab027a0be7b5dbcf2e54dc50efe4bf507de1f7985"}, + {file = "wheel-0.41.3-py3-none-any.whl", hash = "sha256:488609bc63a29322326e05560731bf7bfea8e48ad646e1f5e40d366607de0942"}, + {file = "wheel-0.41.3.tar.gz", hash = "sha256:4d4987ce51a49370ea65c0bfd2234e8ce80a12780820d9dc462597a6e60d0841"}, ] [package.extras] @@ -1600,4 +1598,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "b2c312fd906aa18a26712039f700322c2c20889a95e1cd9af787df54d700b2ca" +content-hash = "f7aa2919aca77aa4d1dfcba18c6fc9694a2cc1d5cfd60e7ec991a615251fa86e" diff --git a/pyproject.toml b/pyproject.toml index 98b725a9..b501ab75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.14.0" +version = "0.15.0" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" @@ -11,7 +11,7 @@ SQLAlchemy = "^1.3.24" click = "^8.1.7" pydantic = "^1.10.2" bech32 = "^1.2.0" -fastapi = "0.103.0" +fastapi = "^0.104.1" environs = "^9.5.0" uvicorn = "0.23.2" loguru = "^0.7.0" @@ -31,13 +31,15 @@ httpx = {extras = ["socks"], version = "^0.25.1"} bip32 = "^3.4" mnemonic = "^0.20" bolt11 = "^2.0.5" +black = "23.11.0" +pre-commit = "^3.5.0" [tool.poetry.extras] pgsql = ["psycopg2-binary"] [tool.poetry.group.dev.dependencies] mypy = "^1.5.1" -black = "^23.7.0" +black = "^23.11.0" pytest-asyncio = "^0.21.1" pytest-cov = "^4.0.0" pytest = "^7.4.0" diff --git a/setup.py b/setup.py index 799de957..d9d8e3d8 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="cashu", - version="0.14.1", + version="0.15.0", description="Ecash wallet and mint for Bitcoin Lightning", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/conftest.py b/tests/conftest.py index b2c286a6..82aa4ab0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import asyncio import multiprocessing import os import shutil @@ -9,35 +10,50 @@ import uvicorn from uvicorn import Config, Server +from cashu.core.base import Method, Unit from cashu.core.db import Database from cashu.core.migrations import migrate_databases from cashu.core.settings import settings -from cashu.lightning.fake import FakeWallet from cashu.mint import migrations as migrations_mint -from cashu.mint.crud import LedgerCrud +from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.ledger import Ledger SERVER_PORT = 3337 SERVER_ENDPOINT = f"http://localhost:{SERVER_PORT}" -settings.debug = True +settings.debug = False settings.cashu_dir = "./test_data/" settings.mint_host = "localhost" settings.mint_port = SERVER_PORT settings.mint_host = "0.0.0.0" settings.mint_listen_port = SERVER_PORT settings.mint_url = SERVER_ENDPOINT -settings.lightning = True settings.tor = False +settings.wallet_unit = "sat" settings.mint_lightning_backend = settings.mint_lightning_backend or "FakeWallet" +settings.fakewallet_brr = True +settings.fakewallet_delay_payment = False +settings.fakewallet_stochastic_invoice = False settings.mint_database = "./test_data/test_mint" -settings.mint_derivation_path = "0/0/0/0" +settings.mint_derivation_path = "m/0'/0'/0'" +settings.mint_derivation_path_list = [] settings.mint_private_key = "TEST_PRIVATE_KEY" settings.mint_max_balance = 0 +assert "test" in settings.cashu_dir shutil.rmtree(settings.cashu_dir, ignore_errors=True) Path(settings.cashu_dir).mkdir(parents=True, exist_ok=True) +from cashu.mint.startup import lightning_backend # noqa + + +@pytest.fixture(scope="session") +def event_loop(): + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + class UvicornServer(multiprocessing.Process): def __init__(self, config: Config): @@ -52,6 +68,23 @@ def run(self, *args, **kwargs): self.server.run() +# # This fixture is used for tests that require API access to the mint +@pytest.fixture(autouse=True, scope="session") +def mint(): + config = uvicorn.Config( + "cashu.mint.app:app", + port=settings.mint_listen_port, + host=settings.mint_listen_host, + ) + + server = UvicornServer(config=config) + server.start() + time.sleep(1) + yield server + server.stop() + + +# This fixture is used for all other tests @pytest_asyncio.fixture(scope="function") async def ledger(): async def start_mint_init(ledger: Ledger): @@ -60,35 +93,22 @@ async def start_mint_init(ledger: Ledger): await ledger.load_used_proofs() await ledger.init_keysets() - database_name = "mint" - if not settings.mint_database.startswith("postgres"): # clear sqlite database - db_file = os.path.join(settings.mint_database, database_name + ".sqlite3") + db_file = os.path.join(settings.mint_database, "mint.sqlite3") if os.path.exists(db_file): os.remove(db_file) + backends = { + Method.bolt11: {Unit.sat: lightning_backend}, + } ledger = Ledger( - db=Database(database_name, settings.mint_database), + db=Database("mint", settings.mint_database), seed=settings.mint_private_key, derivation_path=settings.mint_derivation_path, - lightning=FakeWallet(), - crud=LedgerCrud(), + backends=backends, + crud=LedgerCrudSqlite(), ) await start_mint_init(ledger) yield ledger - - -@pytest.fixture(autouse=True, scope="session") -def mint(): - config = uvicorn.Config( - "cashu.mint.app:app", - port=settings.mint_listen_port, - host=settings.mint_listen_host, - ) - - server = UvicornServer(config=config) - server.start() - time.sleep(1) - yield server - server.stop() + print("teardown") diff --git a/tests/helpers.py b/tests/helpers.py index e6d084a6..0cd36a9f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -29,7 +29,7 @@ async def get_random_invoice_data(): WALLET = wallet_class() is_fake: bool = WALLET.__class__.__name__ == "FakeWallet" is_regtest: bool = not is_fake - +is_deprecated_api_only = settings.debug_mint_only_deprecated docker_lightning_cli = [ "docker", diff --git a/tests/test_cli.py b/tests/test_cli.py index 64119ae4..e36c950c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ import asyncio +import base64 +import json from typing import Tuple import pytest @@ -27,8 +29,9 @@ def get_bolt11_and_invoice_id_from_invoice_command(output: str) -> Tuple[str, st async def init_wallet(): + settings.debug = False wallet = await Wallet.with_db( - url=settings.mint_host, + url=settings.mint_url, db="test_data/test_cli_wallet", name="wallet", ) @@ -56,7 +59,7 @@ def test_info_with_mint(cli_prefix): [*cli_prefix, "info", "--mint"], ) assert result.exception is None - print("INFO -M") + print("INFO --MINT") print(result.output) assert "Mint name" in result.output assert result.exit_code == 0 @@ -69,7 +72,7 @@ def test_info_with_mnemonic(cli_prefix): [*cli_prefix, "info", "--mnemonic"], ) assert result.exception is None - print("INFO -M") + print("INFO --MNEMONIC") print(result.output) assert "Mnemonic" in result.output assert result.exit_code == 0 @@ -177,7 +180,7 @@ def test_send(mint, cli_prefix): [*cli_prefix, "send", "10"], ) assert result.exception is None - print(result.output) + print("test_send", result.output) token_str = result.output.split("\n")[0] assert "cashuA" in token_str, "output does not have a token" token = TokenV3.deserialize(token_str) @@ -191,7 +194,7 @@ def test_send_with_dleq(mint, cli_prefix): [*cli_prefix, "send", "10", "--dleq"], ) assert result.exception is None - print(result.output) + print("test_send_with_dleq", result.output) token_str = result.output.split("\n")[0] assert "cashuA" in token_str, "output does not have a token" token = TokenV3.deserialize(token_str) @@ -205,7 +208,7 @@ def test_send_legacy(mint, cli_prefix): [*cli_prefix, "send", "10", "--legacy"], ) assert result.exception is None - print(result.output) + print("test_send_legacy", result.output) # this is the legacy token in the output token_str = result.output.split("\n")[4] assert token_str.startswith("eyJwcm9v"), "output is not as expected" @@ -219,7 +222,7 @@ def test_send_without_split(mint, cli_prefix): ) assert result.exception is None print("SEND") - print(result.output) + print("test_send_without_split", result.output) assert "cashuA" in result.output, "output does not have a token" @@ -234,12 +237,7 @@ def test_send_without_split_but_wrong_amount(mint, cli_prefix): def test_receive_tokenv3(mint, cli_prefix): runner = CliRunner() - token = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIld6TEF2VW53SDlRaFYwQU1rMy1oYWciLC" - "AiQyI6ICIwMmZlMzUxYjAyN2FlMGY1ZDkyN2U2ZjFjMTljMjNjNTc3NzRhZTI2M2UyOGExN2E2MTUxNjY1ZjU3NWNhNjMyNWMifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICJDamFTeTcyR2dVOGwzMGV6bE5zZnVBIiwgIkMiOiAiMDNjMzM0OTJlM2ZlNjI4NzFhMWEzMDhiNWUyYjVhZjBkNWI1Mjk5YzI0YmVkNDI2Zj" - "Q1YzZmNDg5N2QzZjc4NGQ5In1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzcifV19" - ) + token = "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjAwOWExZjI5MzI1M2U0MWUiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICI0NzlkY2E0MzUzNzU4MTM4N2Q1ODllMDU1MGY0Y2Q2MjFmNjE0MDM1MGY5M2Q4ZmI1OTA2YjJlMGRiNmRjYmI3IiwgIkMiOiAiMDM1MGQ0ZmI0YzdiYTMzNDRjMWRjYWU1ZDExZjNlNTIzZGVkOThmNGY4ODdkNTQwZmYyMDRmNmVlOWJjMjkyZjQ1In0sIHsiaWQiOiAiMDA5YTFmMjkzMjUzZTQxZSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogIjZjNjAzNDgwOGQyNDY5N2IyN2YxZTEyMDllNjdjNjVjNmE2MmM2Zjc3NGI4NWVjMGQ5Y2Y3MjE0M2U0NWZmMDEiLCAiQyI6ICIwMjZkNDlhYTE0MmFlNjM1NWViZTJjZGQzYjFhOTdmMjE1MDk2NTlkMDE3YWU0N2FjNDY3OGE4NWVkY2E4MGMxYmQifV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzNyJ9XX0=" # noqa result = runner.invoke( cli, [ @@ -258,12 +256,29 @@ def test_receive_tokenv3_no_mint(mint, cli_prefix): # where the mint URL is not in the token therefore, we need to know the mint keyset # already and have the mint URL in the db runner = CliRunner() - token = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIi1oM0ZXMFFoX1FYLW9ac1V2c0RuNlEiLC" - "AiQyI6ICIwMzY5Mzc4MzdlYjg5ZWI4NjMyNWYwOWUyOTIxMWQxYTI4OTRlMzQ2YmM1YzQwZTZhMThlNTk5ZmVjNjEwOGRmMGIifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICI3d0VhNUgzZGhSRGRNZl94c1k3c3JnIiwgIkMiOiAiMDJiZmZkM2NlZDkxNjUyMzcxMDg2NjQxMzJiMjgxYjBhZjY1ZTNlZWVkNTY3MmFkZj" - "M0Y2VhNzE5ODhhZWM1NWI1In1dfV19" - ) + token_dict = { + "token": [ + { + "proofs": [ + { + "id": "009a1f293253e41e", + "amount": 2, + "secret": "ea3420987e1ecd71de58e4ff00e8a94d1f1f9333dad98e923e3083d21bf314e2", + "C": "0204eb99cf27105b4de4029478376d6f71e9e3d5af1cc28a652c028d1bcd6537cc", + }, + { + "id": "009a1f293253e41e", + "amount": 8, + "secret": "3447975db92f43b269290e05b91805df7aa733f622e55d885a2cab78e02d4a72", + "C": "0286c78750d414bc067178cbac0f3551093cea47d213ebf356899c972448ee6255", + }, + ] + } + ] + } + token = "cashuA" + base64.b64encode(json.dumps(token_dict).encode()).decode() + print("RECEIVE") + print(token) result = runner.invoke( cli, [ @@ -273,18 +288,37 @@ def test_receive_tokenv3_no_mint(mint, cli_prefix): ], ) assert result.exception is None - print("RECEIVE") print(result.output) def test_receive_tokenv2(mint, cli_prefix): runner = CliRunner() - token = ( - "eyJwcm9vZnMiOiBbeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICJhUmREbzlFdW9yZUVfOW90enRNVVpnIiwgIkMiOiAiMDNhMzY5ZmUy" - "N2IxYmVmOTg4MzA3NDQyN2RjMzc1NmU0NThlMmMwYjQ1NWMwYmVmZGM4ZjVmNTA3YmM5MGQxNmU3In0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3VudCI6IDgsICJzZWNy" - "ZXQiOiAiTEZQbFp6Ui1MWHFfYXFDMGhUeDQyZyIsICJDIjogIjAzNGNiYzQxYWY0ODIxMGFmNjVmYjVjOWIzOTNkMjhmMmQ5ZDZhOWE5MzI2YmI3MzQ2YzVkZmRmMTU5MDk1MzI2" - "YyJ9XSwgIm1pbnRzIjogW3sidXJsIjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzNyIsICJpZHMiOiBbIjFjQ05JQVoyWC93MSJdfV19" - ) + token_dict = { + "proofs": [ + { + "id": "009a1f293253e41e", + "amount": 2, + "secret": ( + "a1efb610726b342aec209375397fee86a0b88732779ce218e99132f9a975db2a" + ), + "C": ( + "03057e5fe352bac785468ffa51a1ecf0f75af24d2d27ab1fd00164672a417d9523" + ), + }, + { + "id": "009a1f293253e41e", + "amount": 8, + "secret": ( + "b065a17938bc79d6224dc381873b8b7f3a46267e8b00d9ce59530354d9d81ae4" + ), + "C": ( + "021e83773f5eb66f837a5721a067caaa8d7018ef0745b4302f4e2c6cac8806dc69" + ), + }, + ], + "mints": [{"url": "http://localhost:3337", "ids": ["009a1f293253e41e"]}], + } + token = base64.b64encode(json.dumps(token_dict).encode()).decode() result = runner.invoke( cli, [*cli_prefix, "receive", token], @@ -296,11 +330,25 @@ def test_receive_tokenv2(mint, cli_prefix): def test_receive_tokenv1(mint, cli_prefix): runner = CliRunner() - token = ( - "W3siaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3VudCI6IDIsICJzZWNyZXQiOiAiRnVsc2dzMktQV1FMcUlLX200SzgwQSIsICJDIjogIjAzNTc4OThlYzlhMjIxN2VhYWIx" - "ZDc3YmM1Mzc2OTUwMjJlMjU2YTljMmMwNjc0ZDJlM2FiM2JiNGI0ZDMzMWZiMSJ9LCB7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogInJlRDBD" - "azVNS2xBTUQ0dWk2OEtfbEEiLCAiQyI6ICIwMjNkODNkNDE0MDU0NWQ1NTg4NjUyMzU5YjJhMjFhODljODY1ZGIzMzAyZTkzMTZkYTM5NjA0YTA2ZDYwYWQzOGYifV0=" - ) + token_dict = [ + { + "id": "009a1f293253e41e", + "amount": 2, + "secret": ( + "bc0360c041117969ef7b8add48d0981c669619aa5743cccce13d4a771c9e164d" + ), + "C": "026fd492f933e9240f36fb2559a7327f47b3441b895a5f8f0b1d6825fee73438f0", + }, + { + "id": "009a1f293253e41e", + "amount": 8, + "secret": ( + "cf83bd8df35bb104d3818511c1653e9ebeb2b645a36fd071b2229aa2c3044acd" + ), + "C": "0279606f3dfd7784757c6320b17e1bf2211f284318814c12bfaa40680e017abd34", + }, + ] + token = base64.b64encode(json.dumps(token_dict).encode()).decode() result = runner.invoke( cli, [*cli_prefix, "receive", token], diff --git a/tests/test_core.py b/tests/test_core.py index e41df38c..d0aa2371 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,7 +9,7 @@ def test_get_output_split(): assert amount_split(13) == [1, 4, 8] -def test_tokenv3_get_amount(): +def test_tokenv3_deserialize_get_attributes(): token_str = ( "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" @@ -18,16 +18,6 @@ def test_tokenv3_get_amount(): ) token = TokenV3.deserialize(token_str) assert token.get_amount() == 10 - - -def test_tokenv3_get_proofs(): - token_str = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" - "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiMDIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" - "NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV19" - ) - token = TokenV3.deserialize(token_str) assert len(token.get_proofs()) == 2 @@ -117,6 +107,43 @@ def test_tokenv3_deserialize_with_memo(): assert token.memo == "Test memo" +def test_serialize_example_token_nut00(): + token_dict = { + "token": [ + { + "mint": "https://8333.space:3338", + "proofs": [ + { + "id": "9bb9d58392cd823e", + "amount": 2, + "secret": "EhpennC9qB3iFlW8FZ_pZw", + "C": "02c020067db727d586bc3183aecf97fcb800c3f4cc4759f69c626c9db5d8f5b5d4", + }, + { + "id": "9bb9d58392cd823e", + "amount": 8, + "secret": "TmS6Cv0YT5PU_5ATVKnukw", + "C": "02ac910bef28cbe5d7325415d5c263026f15f9b967a079ca9779ab6e5c2db133a7", + }, + ], + } + ], + "memo": "Thank you.", + } + tokenObj = TokenV3.parse_obj(token_dict) + assert ( + tokenObj.serialize() + == "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjliYjlkNTgzOTJjZDg" + "yM2UiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICJFaHBlbm5DOXFCM2lGbFc4Rlpf" + "cFp3IiwgIkMiOiAiMDJjMDIwMDY3ZGI3MjdkNTg2YmMzMTgzYWVjZjk3ZmNiODAwY" + "zNmNGNjNDc1OWY2OWM2MjZjOWRiNWQ4ZjViNWQ0In0sIHsiaWQiOiAiOWJiOWQ1OD" + "M5MmNkODIzZSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogIlRtUzZDdjBZVDVQVV8" + "1QVRWS251a3ciLCAiQyI6ICIwMmFjOTEwYmVmMjhjYmU1ZDczMjU0MTVkNWMyNjMw" + "MjZmMTVmOWI5NjdhMDc5Y2E5Nzc5YWI2ZTVjMmRiMTMzYTcifV0sICJtaW50IjogI" + "mh0dHBzOi8vODMzMy5zcGFjZTozMzM4In1dLCAibWVtbyI6ICJUaGFuayB5b3UuIn0=" + ) + + def test_calculate_number_of_blank_outputs(): # Example from NUT-08 specification. fee_reserve_sat = 1000 diff --git a/tests/test_mint.py b/tests/test_mint.py index c646bbba..534fc051 100644 --- a/tests/test_mint.py +++ b/tests/test_mint.py @@ -2,11 +2,12 @@ import pytest -from cashu.core.base import BlindedMessage, Proof +from cashu.core.base import BlindedMessage, PostMintQuoteRequest, Proof from cashu.core.crypto.b_dhke import step1_alice from cashu.core.helpers import calculate_number_of_blank_outputs from cashu.core.settings import settings from cashu.mint.ledger import Ledger +from tests.helpers import pay_if_regtest async def assert_err(f, msg): @@ -29,11 +30,11 @@ async def test_pubkeys(ledger: Ledger): assert ledger.keyset.public_keys assert ( ledger.keyset.public_keys[1].serialize().hex() - == "03190ebc0c3e2726a5349904f572a2853ea021b0128b269b8b6906501d262edaa8" + == "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" ) assert ( ledger.keyset.public_keys[2 ** (settings.max_order - 1)].serialize().hex() - == "032dc008b88b85fdc2301a499bfaaef774c191a6307d8c9434838fc2eaa2e48d51" + == "023c84c0895cc0e827b348ea0a62951ca489a5e436f3ea7545f3c1d5f1bea1c866" ) @@ -42,19 +43,30 @@ async def test_privatekeys(ledger: Ledger): assert ledger.keyset.private_keys assert ( ledger.keyset.private_keys[1].serialize() - == "67de62e1bf8b5ccf88dbad6768b7d13fa0f41433b0a89caf915039505f2e00a7" + == "8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d" ) assert ( ledger.keyset.private_keys[2 ** (settings.max_order - 1)].serialize() - == "3b1340c703b02028a11025302d2d9e68d2a6dd721ab1a2770f0942d15eacb8d0" + == "b0477644cb3d82ffcc170bc0a76e0409727232e87c5ae51d64a259936228c7be" ) @pytest.mark.asyncio async def test_keysets(ledger: Ledger): - assert len(ledger.keysets.keysets) - assert len(ledger.keysets.get_ids()) - assert ledger.keyset.id == "1cCNIAZ2X/w1" + assert len(ledger.keysets) + assert len(list(ledger.keysets.keys())) + assert ledger.keyset.id == "009a1f293253e41e" + + +@pytest.mark.asyncio +async def test_keysets_backwards_compatibility_pre_v0_15(ledger: Ledger): + """Backwards compatibility test for keysets pre v0.15.0 + We expect two instances of the same keyset but with different IDs. + First one is the new hex ID, second one is the old base64 ID. + """ + assert len(ledger.keysets) == 2 + assert list(ledger.keysets.keys()) == ["009a1f293253e41e", "eGnEWtdJ0PIM"] + assert ledger.keyset.id == "009a1f293253e41e" @pytest.mark.asyncio @@ -66,33 +78,37 @@ async def test_get_keyset(ledger: Ledger): @pytest.mark.asyncio async def test_mint(ledger: Ledger): - invoice, id = await ledger.request_mint(8) + quote = await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat")) + pay_if_regtest(quote.request) blinded_messages_mock = [ BlindedMessage( amount=8, B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", + id="009a1f293253e41e", ) ] - promises = await ledger.mint(blinded_messages_mock, id=id) + promises = await ledger.mint(outputs=blinded_messages_mock, quote_id=quote.quote) assert len(promises) assert promises[0].amount == 8 assert ( promises[0].C_ - == "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15" + == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e" ) @pytest.mark.asyncio async def test_mint_invalid_blinded_message(ledger: Ledger): - invoice, id = await ledger.request_mint(8) + quote = await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat")) + pay_if_regtest(quote.request) blinded_messages_mock_invalid_key = [ BlindedMessage( amount=8, B_="02634a2c2b34bec9e8a4aba4361f6bff02d7fa2365379b0840afe249a7a9d71237", + id="009a1f293253e41e", ) ] await assert_err( - ledger.mint(blinded_messages_mock_invalid_key, id=id), + ledger.mint(outputs=blinded_messages_mock_invalid_key, quote_id=quote.quote), "invalid public key", ) @@ -103,14 +119,39 @@ async def test_generate_promises(ledger: Ledger): BlindedMessage( amount=8, B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", + id="009a1f293253e41e", + ) + ] + promises = await ledger._generate_promises(blinded_messages_mock) + assert ( + promises[0].C_ + == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e" + ) + assert promises[0].amount == 8 + assert promises[0].id == "009a1f293253e41e" + + # DLEQ proof present + assert promises[0].dleq + assert promises[0].dleq.s + assert promises[0].dleq.e + + +@pytest.mark.asyncio +async def test_generate_promises_deprecated_keyset_id(ledger: Ledger): + blinded_messages_mock = [ + BlindedMessage( + amount=8, + B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", + id="eGnEWtdJ0PIM", ) ] promises = await ledger._generate_promises(blinded_messages_mock) assert ( promises[0].C_ - == "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15" + == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e" ) assert promises[0].amount == 8 + assert promises[0].id == "eGnEWtdJ0PIM" # DLEQ proof present assert promises[0].dleq @@ -118,6 +159,32 @@ async def test_generate_promises(ledger: Ledger): assert promises[0].dleq.e +@pytest.mark.asyncio +async def test_generate_promises_keyset_backwards_compatibility_pre_v0_15( + ledger: Ledger, +): + """Backwards compatibility test for keysets pre v0.15.0 + We want to generate promises using the old keyset ID. + We expect the promise to have the old base64 ID. + """ + blinded_messages_mock = [ + BlindedMessage( + amount=8, + B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", + id="eGnEWtdJ0PIM", + ) + ] + promises = await ledger._generate_promises( + blinded_messages_mock, keyset=ledger.keysets["eGnEWtdJ0PIM"] + ) + assert ( + promises[0].C_ + == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e" + ) + assert promises[0].amount == 8 + assert promises[0].id == "eGnEWtdJ0PIM" + + @pytest.mark.asyncio async def test_generate_change_promises(ledger: Ledger): # Example slightly adapted from NUT-08 because we want to ensure the dynamic change @@ -125,7 +192,7 @@ async def test_generate_change_promises(ledger: Ledger): invoice_amount = 100_000 fee_reserve = 2_000 total_provided = invoice_amount + fee_reserve - actual_fee_msat = 100_000 + actual_fee = 100 expected_returned_promises = 7 # Amounts = [4, 8, 32, 64, 256, 512, 1024] expected_returned_fees = 1900 @@ -133,11 +200,16 @@ async def test_generate_change_promises(ledger: Ledger): n_blank_outputs = calculate_number_of_blank_outputs(fee_reserve) blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)] outputs = [ - BlindedMessage(amount=1, B_=b.serialize().hex()) for b, _ in blinded_msgs + BlindedMessage( + amount=1, + B_=b.serialize().hex(), + id="009a1f293253e41e", + ) + for b, _ in blinded_msgs ] promises = await ledger._generate_change_promises( - total_provided, invoice_amount, actual_fee_msat, outputs + total_provided, invoice_amount, actual_fee, outputs ) assert len(promises) == expected_returned_promises @@ -151,7 +223,7 @@ async def test_generate_change_promises_legacy_wallet(ledger: Ledger): invoice_amount = 100_000 fee_reserve = 2_000 total_provided = invoice_amount + fee_reserve - actual_fee_msat = 100_000 + actual_fee = 100 expected_returned_promises = 4 # Amounts = [64, 256, 512, 1024] expected_returned_fees = 1856 @@ -159,11 +231,16 @@ async def test_generate_change_promises_legacy_wallet(ledger: Ledger): n_blank_outputs = 4 blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)] outputs = [ - BlindedMessage(amount=1, B_=b.serialize().hex()) for b, _ in blinded_msgs + BlindedMessage( + amount=1, + B_=b.serialize().hex(), + id="009a1f293253e41e", + ) + for b, _ in blinded_msgs ] promises = await ledger._generate_change_promises( - total_provided, invoice_amount, actual_fee_msat, outputs + total_provided, invoice_amount, actual_fee, outputs ) assert len(promises) == expected_returned_promises @@ -193,9 +270,9 @@ async def test_get_balance(ledger: Ledger): @pytest.mark.asyncio async def test_maximum_balance(ledger: Ledger): settings.mint_max_balance = 1000 - invoice, id = await ledger.request_mint(8) + await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat")) await assert_err( - ledger.request_mint(8000), + ledger.mint_quote(PostMintQuoteRequest(amount=8000, unit="sat")), "Mint has reached maximum balance.", ) settings.mint_max_balance = 0 diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index dae6c37d..14de13b2 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -1,71 +1,371 @@ +import bolt11 import httpx import pytest +import pytest_asyncio -from cashu.core.base import CheckSpendableRequest, CheckSpendableResponse, Proof +from cashu.core.base import ( + PostCheckStateRequest, + PostCheckStateResponse, + SpentState, +) +from cashu.core.settings import settings +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest BASE_URL = "http://localhost:3337" +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=BASE_URL, + db="test_data/wallet_mint_api", + name="wallet_mint_api", + ) + await wallet1.load_mint() + yield wallet1 + + @pytest.mark.asyncio -async def test_info(ledger): - response = httpx.get(f"{BASE_URL}/info") +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_info(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/v1/info") assert response.status_code == 200, f"{response.url} {response.status_code}" + assert ledger.pubkey assert response.json()["pubkey"] == ledger.pubkey.serialize().hex() @pytest.mark.asyncio -async def test_api_keys(ledger): - response = httpx.get(f"{BASE_URL}/keys") +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_keys(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/v1/keys") assert response.status_code == 200, f"{response.url} {response.status_code}" - assert response.json() == { - str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items() + assert ledger.keyset.public_keys + expected = { + "keysets": [ + { + "id": keyset.id, + "unit": keyset.unit.name, + "keys": { + str(k): v.serialize().hex() for k, v in keyset.public_keys.items() # type: ignore + }, + } + for keyset in ledger.keysets.values() + ] } + assert response.json() == expected @pytest.mark.asyncio -async def test_api_keysets(ledger): - response = httpx.get(f"{BASE_URL}/keysets") +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_keysets(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/v1/keysets") assert response.status_code == 200, f"{response.url} {response.status_code}" - assert response.json()["keysets"] == list(ledger.keysets.keysets.keys()) + expected = { + "keysets": [ + { + "id": "009a1f293253e41e", + "unit": "sat", + "active": True, + }, + # for backwards compatibility of the new keyset ID format, + # we also return the same keyset with the old base64 ID + { + "id": "eGnEWtdJ0PIM", + "unit": "sat", + "active": True, + }, + ] + } + assert response.json() == expected @pytest.mark.asyncio -async def test_api_keyset_keys(ledger): - response = httpx.get( - f"{BASE_URL}/keys/{'1cCNIAZ2X/w1'.replace('/', '_').replace('+', '-')}" - ) +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_keyset_keys(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/v1/keys/009a1f293253e41e") assert response.status_code == 200, f"{response.url} {response.status_code}" - assert response.json() == { - str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items() + assert ledger.keyset.public_keys + expected = { + "keysets": [ + { + "id": "009a1f293253e41e", + "unit": "sat", + "keys": { + str(k): v.serialize().hex() + for k, v in ledger.keysets["009a1f293253e41e"].public_keys.items() # type: ignore + }, + } + ] } + assert response.json() == expected @pytest.mark.asyncio -async def test_api_mint_validation(ledger): - response = httpx.get(f"{BASE_URL}/mint?amount=-21") - assert "detail" in response.json() - response = httpx.get(f"{BASE_URL}/mint?amount=0") - assert "detail" in response.json() - response = httpx.get(f"{BASE_URL}/mint?amount=2100000000000001") - assert "detail" in response.json() - response = httpx.get(f"{BASE_URL}/mint?amount=1") - assert "detail" not in response.json() +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_keyset_keys_old_keyset_id(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/v1/keys/eGnEWtdJ0PIM") + assert response.status_code == 200, f"{response.url} {response.status_code}" + assert ledger.keyset.public_keys + expected = { + "keysets": [ + { + "id": "eGnEWtdJ0PIM", + "unit": "sat", + "keys": { + str(k): v.serialize().hex() + for k, v in ledger.keysets["eGnEWtdJ0PIM"].public_keys.items() # type: ignore + }, + } + ] + } + assert response.json() == expected + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_split(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + secrets, rs, derivation_paths = await wallet.generate_n_secrets(2) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + # outputs = wallet._construct_outputs([32, 32], ["a", "b"], ["c", "d"]) + inputs_payload = [p.to_dict() for p in wallet.proofs] + outputs_payload = [o.dict() for o in outputs] + payload = {"inputs": inputs_payload, "outputs": outputs_payload} + response = httpx.post(f"{BASE_URL}/v1/swap", json=payload) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result["signatures"]) == 2 + assert result["signatures"][0]["amount"] == 32 + assert result["signatures"][1]["amount"] == 32 + assert result["signatures"][0]["id"] == "009a1f293253e41e" + assert result["signatures"][0]["dleq"] + assert "e" in result["signatures"][0]["dleq"] + assert "s" in result["signatures"][0]["dleq"] + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_quote(ledger: Ledger): + response = httpx.post( + f"{BASE_URL}/v1/mint/quote/bolt11", + json={"unit": "sat", "amount": 100}, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result["quote"] + assert result["request"] + invoice = bolt11.decode(result["request"]) + assert invoice.amount_msat == 100 * 1000 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + quote_id = invoice.id + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11", + json={"quote": quote_id, "outputs": outputs_payload}, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result["signatures"]) == 2 + assert result["signatures"][0]["amount"] == 32 + assert result["signatures"][1]["amount"] == 32 + assert result["signatures"][0]["id"] == "009a1f293253e41e" + assert result["signatures"][0]["dleq"] + assert "e" in result["signatures"][0]["dleq"] + assert "s" in result["signatures"][0]["dleq"] + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +@pytest.mark.skipif( + is_regtest, + reason="regtest", +) +async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice = await wallet.request_mint(64) + request = invoice.bolt11 + response = httpx.post( + f"{BASE_URL}/v1/melt/quote/bolt11", + json={"unit": "sat", "request": request}, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result["quote"] + assert result["amount"] == 64 + # TODO: internal invoice, fee should be 0 + assert result["fee_reserve"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +@pytest.mark.skipif( + is_fake, + reason="only works on regtest", +) +async def test_melt_quote_external(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice_dict = get_real_invoice(64) + request = invoice_dict["payment_request"] + response = httpx.post( + f"{BASE_URL}/v1/melt/quote/bolt11", + json={"unit": "sat", "request": request}, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result["quote"] + assert result["amount"] == 64 + # external invoice, fee should be 2 + assert result["fee_reserve"] == 2 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_melt_internal(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create invoice to melt to + invoice = await wallet.request_mint(64) + invoice_payment_request = invoice.bolt11 + + quote = await wallet.melt_quote(invoice_payment_request) + assert quote.amount == 64 + assert quote.fee_reserve == 0 + + inputs_payload = [p.to_dict() for p in wallet.proofs] + + # outputs for change + secrets, rs, derivation_paths = await wallet.generate_n_secrets(1) + outputs, rs = wallet._construct_outputs([2], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/v1/melt/bolt11", + json={ + "quote": quote.quote, + "inputs": inputs_payload, + "outputs": outputs_payload, + }, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result.get("payment_preimage") is not None + assert result["paid"] is True + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +@pytest.mark.skipif( + is_fake, + reason="only works on regtest", +) +async def test_melt_external(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + invoice_dict = get_real_invoice(62) + invoice_payment_request = invoice_dict["payment_request"] + + quote = await wallet.melt_quote(invoice_payment_request) + assert quote.amount == 62 + assert quote.fee_reserve == 2 + + keep, send = await wallet.split_to_send(wallet.proofs, 64) + inputs_payload = [p.to_dict() for p in send] + + # outputs for change + secrets, rs, derivation_paths = await wallet.generate_n_secrets(1) + outputs, rs = wallet._construct_outputs([2], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/v1/melt/bolt11", + json={ + "quote": quote.quote, + "inputs": inputs_payload, + "outputs": outputs_payload, + }, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result.get("payment_preimage") is not None + assert result["paid"] is True + assert result["change"] + # we get back 2 sats because Lightning was free to pay on regtest + assert result["change"][0]["amount"] == 2 @pytest.mark.asyncio -async def test_api_check_state(ledger): - proofs = [ - Proof(id="1234", amount=0, secret="asdasdasd", C="asdasdasd"), - Proof(id="1234", amount=0, secret="asdasdasd1", C="asdasdasd1"), - ] - payload = CheckSpendableRequest(proofs=proofs) +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_check_state(ledger: Ledger): + payload = PostCheckStateRequest(secrets=["asdasdasd", "asdasdasd1"]) response = httpx.post( - f"{BASE_URL}/check", + f"{BASE_URL}/v1/checkstate", json=payload.dict(), ) assert response.status_code == 200, f"{response.url} {response.status_code}" - states = CheckSpendableResponse.parse_obj(response.json()) - assert states.spendable - assert len(states.spendable) == 2 - assert states.pending - assert len(states.pending) == 2 + response = PostCheckStateResponse.parse_obj(response.json()) + assert response + assert len(response.states) == 2 + assert response.states[0].state == SpentState.unspent diff --git a/tests/test_mint_api_deprecated.py b/tests/test_mint_api_deprecated.py new file mode 100644 index 00000000..12676ebc --- /dev/null +++ b/tests/test_mint_api_deprecated.py @@ -0,0 +1,289 @@ +import httpx +import pytest +import pytest_asyncio + +from cashu.core.base import ( + CheckSpendableRequest_deprecated, + CheckSpendableResponse_deprecated, + Proof, +) +from cashu.core.settings import settings +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest + +BASE_URL = "http://localhost:3337" + + +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=BASE_URL, + db="test_data/wallet_mint_api_deprecated", + name="wallet_mint_api_deprecated", + ) + await wallet1.load_mint() + yield wallet1 + + +@pytest.mark.asyncio +async def test_info(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/info") + assert response.status_code == 200, f"{response.url} {response.status_code}" + assert ledger.pubkey + assert response.json()["pubkey"] == ledger.pubkey.serialize().hex() + + +@pytest.mark.asyncio +async def test_api_keys(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/keys") + assert response.status_code == 200, f"{response.url} {response.status_code}" + assert ledger.keyset.public_keys + assert response.json() == { + str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items() + } + + +@pytest.mark.asyncio +async def test_api_keysets(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/keysets") + assert response.status_code == 200, f"{response.url} {response.status_code}" + assert ledger.keyset.public_keys + assert response.json()["keysets"] == list(ledger.keysets.keys()) + + +@pytest.mark.asyncio +async def test_api_keyset_keys(ledger: Ledger): + response = httpx.get(f"{BASE_URL}/keys/009a1f293253e41e") + assert response.status_code == 200, f"{response.url} {response.status_code}" + assert ledger.keyset.public_keys + assert response.json() == { + str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items() + } + + +@pytest.mark.asyncio +async def test_split(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(20000, 20001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + # outputs = wallet._construct_outputs([32, 32], ["a", "b"], ["c", "d"]) + inputs_payload = [p.to_dict() for p in wallet.proofs] + outputs_payload = [o.dict() for o in outputs] + # strip "id" from outputs_payload, which is not used in the deprecated split endpoint + for o in outputs_payload: + o.pop("id") + payload = {"proofs": inputs_payload, "outputs": outputs_payload} + response = httpx.post(f"{BASE_URL}/split", json=payload, timeout=None) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result["promises"] + + +@pytest.mark.asyncio +async def test_split_deprecated_with_amount(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(80000, 80001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + # outputs = wallet._construct_outputs([32, 32], ["a", "b"], ["c", "d"]) + inputs_payload = [p.to_dict() for p in wallet.proofs] + outputs_payload = [o.dict() for o in outputs] + # strip "id" from outputs_payload, which is not used in the deprecated split endpoint + for o in outputs_payload: + o.pop("id") + # we supply an amount here, which should cause the very old deprecated split endpoint to be used + payload = {"proofs": inputs_payload, "outputs": outputs_payload, "amount": 32} + response = httpx.post(f"{BASE_URL}/split", json=payload, timeout=None) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + # old deprecated output format + assert result["fst"] + assert result["snd"] + + +@pytest.mark.asyncio +async def test_api_mint_validation(ledger): + response = httpx.get(f"{BASE_URL}/mint?amount=-21") + assert "detail" in response.json() + response = httpx.get(f"{BASE_URL}/mint?amount=0") + assert "detail" in response.json() + response = httpx.get(f"{BASE_URL}/mint?amount=2100000000000001") + assert "detail" in response.json() + response = httpx.get(f"{BASE_URL}/mint?amount=1") + assert "detail" not in response.json() + + +@pytest.mark.asyncio +async def test_mint(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + quote_id = invoice.id + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + response = httpx.post( + f"{BASE_URL}/mint", + json={"outputs": outputs_payload}, + params={"hash": quote_id}, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result["promises"]) == 2 + assert result["promises"][0]["amount"] == 32 + assert result["promises"][1]["amount"] == 32 + if settings.debug_mint_only_deprecated: + assert result["promises"][0]["id"] == "eGnEWtdJ0PIM" + else: + assert result["promises"][0]["id"] == "009a1f293253e41e" + assert result["promises"][0]["dleq"] + assert "e" in result["promises"][0]["dleq"] + assert "s" in result["promises"][0]["dleq"] + + +@pytest.mark.asyncio +async def test_melt_internal(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create invoice to melt to + invoice = await wallet.request_mint(64) + + invoice_payment_request = invoice.bolt11 + + quote = await wallet.melt_quote(invoice_payment_request) + assert quote.amount == 64 + assert quote.fee_reserve == 0 + + inputs_payload = [p.to_dict() for p in wallet.proofs] + + # outputs for change + secrets, rs, derivation_paths = await wallet.generate_n_secrets(1) + outputs, rs = wallet._construct_outputs([2], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/melt", + json={ + "pr": invoice_payment_request, + "proofs": inputs_payload, + "outputs": outputs_payload, + }, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result.get("preimage") is not None + assert result["paid"] is True + + +@pytest.mark.asyncio +@pytest.mark.skipif( + is_fake, + reason="only works on regtest", +) +async def test_melt_external(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create invoice to melt to + # use 2 sat less because we need to pay the fee + invoice_dict = get_real_invoice(62) + invoice_payment_request = invoice_dict["payment_request"] + + quote = await wallet.melt_quote(invoice_payment_request) + assert quote.amount == 62 + assert quote.fee_reserve == 2 + + inputs_payload = [p.to_dict() for p in wallet.proofs] + + # outputs for change + secrets, rs, derivation_paths = await wallet.generate_n_secrets(1) + outputs, rs = wallet._construct_outputs([2], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/melt", + json={ + "pr": invoice_payment_request, + "proofs": inputs_payload, + "outputs": outputs_payload, + }, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result.get("preimage") is not None + assert result["paid"] is True + assert result["change"] + # we get back 2 sats because Lightning was free to pay on regtest + assert result["change"][0]["amount"] == 2 + + +@pytest.mark.asyncio +async def test_checkfees(ledger: Ledger, wallet: Wallet): + # internal invoice + invoice = await wallet.request_mint(64) + response = httpx.post( + f"{BASE_URL}/checkfees", + json={ + "pr": invoice.bolt11, + }, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + # internal invoice, so no fee + assert result["fee"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.skipif(not is_regtest, reason="only works on regtest") +async def test_checkfees_external(ledger: Ledger, wallet: Wallet): + # external invoice + invoice_dict = get_real_invoice(62) + invoice_payment_request = invoice_dict["payment_request"] + response = httpx.post( + f"{BASE_URL}/checkfees", + json={"pr": invoice_payment_request}, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + # external invoice, so fee + assert result["fee"] == 2 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_check_state(ledger: Ledger): + proofs = [ + Proof(id="1234", amount=0, secret="asdasdasd", C="asdasdasd"), + Proof(id="1234", amount=0, secret="asdasdasd1", C="asdasdasd1"), + ] + payload = CheckSpendableRequest_deprecated(proofs=proofs) + response = httpx.post( + f"{BASE_URL}/check", + json=payload.dict(), + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + states = CheckSpendableResponse_deprecated.parse_obj(response.json()) + assert states.spendable + assert len(states.spendable) == 2 + assert states.pending + assert len(states.pending) == 2 diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index 1c8e2752..46c05ec0 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -1,11 +1,13 @@ import pytest import pytest_asyncio +from cashu.core.base import PostMeltQuoteRequest, PostMintQuoteRequest +from cashu.core.helpers import sum_proofs from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 from tests.conftest import SERVER_ENDPOINT -from tests.helpers import pay_if_regtest +from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest async def assert_err(f, msg): @@ -20,36 +22,120 @@ async def assert_err(f, msg): @pytest_asyncio.fixture(scope="function") -async def wallet1(mint): +async def wallet1(ledger: Ledger): wallet1 = await Wallet1.with_db( url=SERVER_ENDPOINT, db="test_data/wallet1", name="wallet1", ) await wallet1.load_mint() - wallet1.status() yield wallet1 @pytest.mark.asyncio -async def test_melt(wallet1: Wallet, ledger: Ledger): +@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") +async def test_melt_internal(wallet1: Wallet, ledger: Ledger): # mint twice so we have enough to pay the second invoice back - invoice = await wallet1.request_mint(64) - pay_if_regtest(invoice.bolt11) - await wallet1.mint(64, id=invoice.id) - invoice2 = await wallet1.request_mint(64) - pay_if_regtest(invoice2.bolt11) - await wallet1.mint(64, id=invoice2.id) + invoice = await wallet1.request_mint(128) + await wallet1.mint(128, id=invoice.id) assert wallet1.balance == 128 - total_amount, fee_reserve_sat = await wallet1.get_pay_amount_with_fees( - invoice2.bolt11 + + # create a mint quote so that we can melt to it internally + invoice_to_pay = await wallet1.request_mint(64) + invoice_payment_request = invoice_to_pay.bolt11 + + melt_quote = await ledger.melt_quote( + PostMeltQuoteRequest(request=invoice_payment_request, unit="sat") ) - melt_fees = await ledger.get_melt_fees(invoice2.bolt11) - assert melt_fees == fee_reserve_sat + assert not melt_quote.paid + assert melt_quote.amount == 64 + assert melt_quote.fee_reserve == 0 + + melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote) + assert not melt_quote_pre_payment.paid, "melt quote should not be paid" + + keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, 64) + await ledger.melt(proofs=send_proofs, quote=melt_quote.quote) + + melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote) + assert melt_quote_post_payment.paid, "melt quote should be paid" + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only works with Regtest") +async def test_melt_external(wallet1: Wallet, ledger: Ledger): + # mint twice so we have enough to pay the second invoice back + invoice = await wallet1.request_mint(128) + pay_if_regtest(invoice.bolt11) + await wallet1.mint(128, id=invoice.id) + assert wallet1.balance == 128 + + invoice_dict = get_real_invoice(64) + invoice_payment_request = invoice_dict["payment_request"] + + mint_quote = await wallet1.get_pay_amount_with_fees(invoice_payment_request) + total_amount = mint_quote.amount + mint_quote.fee_reserve keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) + melt_quote = await ledger.melt_quote( + PostMeltQuoteRequest(request=invoice_payment_request, unit="sat") + ) + + melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote) + assert not melt_quote_pre_payment.paid, "melt quote should not be paid" - await ledger.melt(send_proofs, invoice2.bolt11, outputs=None) + assert not melt_quote.paid, "melt quote should not be paid" + await ledger.melt(proofs=send_proofs, quote=melt_quote.quote) + + melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote) + assert melt_quote_post_payment.paid, "melt quote should be paid" + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") +async def test_mint_internal(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(128) + + mint_quote = await ledger.get_mint_quote(invoice.id) + + assert mint_quote.paid, "mint quote should be paid" + + output_amounts = [128] + secrets, rs, derivation_paths = await wallet1.generate_n_secrets( + len(output_amounts) + ) + outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) + await ledger.mint(outputs=outputs, quote_id=invoice.id) + + await assert_err( + ledger.mint(outputs=outputs, quote_id=invoice.id), + "outputs have already been signed before.", + ) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only works with Regtest") +async def test_mint_external(wallet1: Wallet, ledger: Ledger): + quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat")) + + mint_quote = await ledger.get_mint_quote(quote.quote) + assert not mint_quote.paid, "mint quote not should be paid" + + await assert_err( + wallet1.mint(128, id=quote.quote), + "quote not paid", + ) + + pay_if_regtest(quote.request) + + mint_quote = await ledger.get_mint_quote(quote.quote) + assert mint_quote.paid, "mint quote should be paid" + + output_amounts = [128] + secrets, rs, derivation_paths = await wallet1.generate_n_secrets( + len(output_amounts) + ) + outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) + await ledger.mint(outputs=outputs, quote_id=quote.quote) @pytest.mark.asyncio @@ -120,14 +206,13 @@ async def test_split_with_input_more_than_outputs(wallet1: Wallet, ledger: Ledge # make sure we can still spend our tokens keep_proofs, send_proofs = await wallet1.split(inputs, 10) - print(keep_proofs, send_proofs) @pytest.mark.asyncio async def test_split_twice_with_same_outputs(wallet1: Wallet, ledger: Ledger): invoice = await wallet1.request_mint(128) pay_if_regtest(invoice.bolt11) - await wallet1.mint(128, [64, 64], id=invoice.id) + await wallet1.mint(128, split=[64, 64], id=invoice.id) inputs1 = wallet1.proofs[:1] inputs2 = wallet1.proofs[1:] @@ -164,14 +249,14 @@ async def test_mint_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger): len(output_amounts) ) outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) - await ledger.mint(outputs, id=invoice.id) + await ledger.mint(outputs=outputs, quote_id=invoice.id) # now try to mint with the same outputs again invoice2 = await wallet1.request_mint(128) pay_if_regtest(invoice2.bolt11) await assert_err( - ledger.mint(outputs, id=invoice2.id), + ledger.mint(outputs=outputs, quote_id=invoice2.id), "outputs have already been signed before.", ) @@ -191,16 +276,79 @@ async def test_melt_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger): # we use the outputs once for minting invoice2 = await wallet1.request_mint(128) pay_if_regtest(invoice2.bolt11) - await ledger.mint(outputs, id=invoice2.id) + await ledger.mint(outputs=outputs, quote_id=invoice2.id) # use the same outputs for melting - invoice3 = await wallet1.request_mint(128) + mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128)) + melt_quote = await ledger.melt_quote( + PostMeltQuoteRequest(unit="sat", request=mint_quote.request) + ) await assert_err( - ledger.melt(wallet1.proofs, invoice3.bolt11, outputs=outputs), + ledger.melt(proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs), "outputs have already been signed before.", ) +@pytest.mark.asyncio +async def test_melt_with_less_inputs_than_invoice(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(32) + pay_if_regtest(invoice.bolt11) + await wallet1.mint(32, id=invoice.id) + + # outputs for fee return + output_amounts = [1, 1, 1, 1] + secrets, rs, derivation_paths = await wallet1.generate_n_secrets( + len(output_amounts) + ) + outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) + + # create a mint quote to pay + mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128)) + # prepare melt quote + melt_quote = await ledger.melt_quote( + PostMeltQuoteRequest(unit="sat", request=mint_quote.request) + ) + + assert melt_quote.amount + melt_quote.fee_reserve > sum_proofs(wallet1.proofs) + + # try to pay with not enough inputs + await assert_err( + ledger.melt(proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs), + "not enough inputs provided for melt", + ) + + +@pytest.mark.asyncio +async def test_melt_with_more_inputs_than_invoice(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(130) + pay_if_regtest(invoice.bolt11) + await wallet1.mint(130, split=[64, 64, 2], id=invoice.id) + + # outputs for fee return + output_amounts = [1, 1, 1, 1] + secrets, rs, derivation_paths = await wallet1.generate_n_secrets( + len(output_amounts) + ) + outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) + + # create a mint quote to pay + mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128)) + # prepare melt quote + melt_quote = await ledger.melt_quote( + PostMeltQuoteRequest(unit="sat", request=mint_quote.request) + ) + # fees are 0 because it's internal + assert melt_quote.fee_reserve == 0 + + # 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( + 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 + + @pytest.mark.asyncio async def test_check_proof_state(wallet1: Wallet, ledger: Ledger): invoice = await wallet1.request_mint(64) @@ -209,6 +357,7 @@ async def test_check_proof_state(wallet1: Wallet, ledger: Ledger): keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, 10) - spendable, pending = await ledger.check_proof_state(proofs=send_proofs) - assert sum(spendable) == len(send_proofs) - assert sum(pending) == 0 + proof_states = await ledger.check_proofs_state( + secrets=[p.secret for p in send_proofs] + ) + assert all([p.state.value == "UNSPENT" for p in proof_states]) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index d719ce94..15c253dc 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,6 +1,4 @@ import copy -import shutil -from pathlib import Path from typing import List, Union import pytest @@ -10,12 +8,12 @@ from cashu.core.errors import CashuError, KeysetNotFoundError from cashu.core.helpers import sum_proofs from cashu.core.settings import settings -from cashu.wallet.crud import get_keyset, get_lightning_invoice, get_proofs +from cashu.wallet.crud import get_keysets, get_lightning_invoice, get_proofs from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet2 from tests.conftest import SERVER_ENDPOINT -from tests.helpers import get_real_invoice, is_regtest, pay_if_regtest +from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest async def assert_err(f, msg: Union[str, CashuError]): @@ -56,47 +54,32 @@ async def wallet1(mint): name="wallet1", ) await wallet1.load_mint() - wallet1.status() yield wallet1 @pytest_asyncio.fixture(scope="function") -async def wallet2(mint): +async def wallet2(): wallet2 = await Wallet2.with_db( url=SERVER_ENDPOINT, db="test_data/wallet2", name="wallet2", ) await wallet2.load_mint() - wallet2.status() yield wallet2 -@pytest_asyncio.fixture(scope="function") -async def wallet3(mint): - dirpath = Path("test_data/wallet3") - if dirpath.exists() and dirpath.is_dir(): - shutil.rmtree(dirpath) - - wallet3 = await Wallet1.with_db( - url=SERVER_ENDPOINT, - db="test_data/wallet3", - name="wallet3", - ) - await wallet3.db.execute("DELETE FROM proofs") - await wallet3.db.execute("DELETE FROM proofs_used") - await wallet3.load_mint() - wallet3.status() - yield wallet3 - - @pytest.mark.asyncio async def test_get_keys(wallet1: Wallet): assert wallet1.keysets[wallet1.keyset_id].public_keys assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order - keyset = await wallet1._get_keys(wallet1.url) + keysets = await wallet1._get_keys() + keyset = keysets[0] assert keyset.id is not None - assert keyset.id == "1cCNIAZ2X/w1" + # assert keyset.id_deprecated == "eGnEWtdJ0PIM" + if settings.debug_mint_only_deprecated: + assert keyset.id == "eGnEWtdJ0PIM" + else: + assert keyset.id == "009a1f293253e41e" assert isinstance(keyset.id, str) assert len(keyset.id) > 0 @@ -106,13 +89,14 @@ async def test_get_keyset(wallet1: Wallet): assert wallet1.keysets[wallet1.keyset_id].public_keys assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order # let's get the keys first so we can get a keyset ID that we use later - keys1 = await wallet1._get_keys(wallet1.url) + keysets = await wallet1._get_keys() + keyset = keysets[0] # gets the keys of a specific keyset - assert keys1.id is not None - assert keys1.public_keys is not None - keys2 = await wallet1._get_keys_of_keyset(wallet1.url, keys1.id) + assert keyset.id is not None + assert keyset.public_keys is not None + keys2 = await wallet1._get_keys_of_keyset(keyset.id) assert keys2.public_keys is not None - assert len(keys1.public_keys) == len(keys2.public_keys) + assert len(keyset.public_keys) == len(keys2.public_keys) @pytest.mark.asyncio @@ -130,32 +114,39 @@ async def test_get_keyset_from_db(wallet1: Wallet): assert keyset1.id == keyset2.id # load it directly from the db - keyset3 = await get_keyset(db=wallet1.db, id=keyset1.id) - assert keyset3 + keysets_local = await get_keysets(db=wallet1.db, id=keyset1.id) + assert keysets_local[0] + keyset3 = keysets_local[0] assert keyset1.public_keys == keyset3.public_keys assert keyset1.id == keyset3.id @pytest.mark.asyncio async def test_get_info(wallet1: Wallet): - info = await wallet1._get_info(wallet1.url) + info = await wallet1._get_info() assert info.name @pytest.mark.asyncio async def test_get_nonexistent_keyset(wallet1: Wallet): await assert_err( - wallet1._get_keys_of_keyset(wallet1.url, "nonexistent"), + wallet1._get_keys_of_keyset("nonexistent"), KeysetNotFoundError(), ) @pytest.mark.asyncio async def test_get_keyset_ids(wallet1: Wallet): - keyset = await wallet1._get_keyset_ids(wallet1.url) - assert isinstance(keyset, list) - assert len(keyset) > 0 - assert keyset[-1] == wallet1.keyset_id + keysets = await wallet1._get_keyset_ids() + assert isinstance(keysets, list) + assert len(keysets) > 0 + assert wallet1.keyset_id in keysets + + +@pytest.mark.asyncio +async def test_request_mint(wallet1: Wallet): + invoice = await wallet1.request_mint(64) + assert invoice.payment_hash @pytest.mark.asyncio @@ -181,9 +172,9 @@ async def test_mint(wallet1: Wallet): @pytest.mark.asyncio async def test_mint_amounts(wallet1: Wallet): """Mint predefined amounts""" - invoice = await wallet1.request_mint(64) - pay_if_regtest(invoice.bolt11) amts = [1, 1, 1, 2, 2, 4, 16] + invoice = await wallet1.request_mint(sum(amts)) + pay_if_regtest(invoice.bolt11) await wallet1.mint(amount=sum(amts), split=amts, id=invoice.id) assert wallet1.balance == 27 assert wallet1.proof_amounts == amts @@ -192,9 +183,11 @@ async def test_mint_amounts(wallet1: Wallet): @pytest.mark.asyncio async def test_mint_amounts_wrong_sum(wallet1: Wallet): """Mint predefined amounts""" + amts = [1, 1, 1, 2, 2, 4, 16] + invoice = await wallet1.request_mint(sum(amts)) await assert_err( - wallet1.mint(amount=sum(amts) + 1, split=amts), + wallet1.mint(amount=sum(amts) + 1, split=amts, id=invoice.id), "split must sum to amount", ) @@ -203,8 +196,9 @@ async def test_mint_amounts_wrong_sum(wallet1: Wallet): async def test_mint_amounts_wrong_order(wallet1: Wallet): """Mint amount that is not part in 2^n""" amts = [1, 2, 3] + invoice = await wallet1.request_mint(sum(amts)) await assert_err( - wallet1.mint(amount=sum(amts), split=[1, 2, 3]), + wallet1.mint(amount=sum(amts), split=[1, 2, 3], id=invoice.id), f"Can only mint amounts with 2^n up to {2**settings.max_order}.", ) @@ -257,42 +251,54 @@ async def test_split_more_than_balance(wallet1: Wallet): @pytest.mark.asyncio async def test_melt(wallet1: Wallet): # mint twice so we have enough to pay the second invoice back - invoice = await wallet1.request_mint(64) - pay_if_regtest(invoice.bolt11) - await wallet1.mint(64, id=invoice.id) - invoice = await wallet1.request_mint(64) - pay_if_regtest(invoice.bolt11) - await wallet1.mint(64, id=invoice.id) + topup_invoice = await wallet1.request_mint(128) + pay_if_regtest(topup_invoice.bolt11) + await wallet1.mint(128, id=topup_invoice.id) assert wallet1.balance == 128 - total_amount, fee_reserve_sat = await wallet1.get_pay_amount_with_fees( - invoice.bolt11 - ) - assert total_amount == 66 - - assert fee_reserve_sat == 2 - _, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) - - invoice_to_pay = invoice.bolt11 - invoice_payment_hash = str(invoice.payment_hash) + invoice_payment_request = "" + invoice_payment_hash = "" if is_regtest: invoice_dict = get_real_invoice(64) - invoice_to_pay = invoice_dict["payment_request"] invoice_payment_hash = str(invoice_dict["r_hash"]) + invoice_payment_request = invoice_dict["payment_request"] + + if is_fake: + invoice = await wallet1.request_mint(64) + invoice_payment_hash = str(invoice.payment_hash) + invoice_payment_request = invoice.bolt11 + + quote = await wallet1.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + + if is_regtest: + # we expect a fee reserve of 2 sat for regtest + assert total_amount == 66 + assert quote.fee_reserve == 2 + if is_fake: + # we expect a fee reserve of 0 sat for fake + assert total_amount == 64 + assert quote.fee_reserve == 0 + + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) melt_response = await wallet1.pay_lightning( - send_proofs, invoice=invoice_to_pay, fee_reserve_sat=fee_reserve_sat + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, ) - assert melt_response.change, "No change returned" - assert len(melt_response.change) == 1, "More than one change returned" - # NOTE: we assume that we will get a token back from the same keyset as the ones we melted - # this could be wrong if we melted tokens from an old keyset but the returned ones are - # from a newer one. - assert melt_response.change[0].id == send_proofs[0].id, "Wrong keyset returned" + if is_regtest: + assert melt_response.change, "No change returned" + assert len(melt_response.change) == 1, "More than one change returned" + # NOTE: we assume that we will get a token back from the same keyset as the ones we melted + # this could be wrong if we melted tokens from an old keyset but the returned ones are + # from a newer one. + assert melt_response.change[0].id == send_proofs[0].id, "Wrong keyset returned" # verify that proofs in proofs_used db have the same melt_id as the invoice in the db - assert invoice.payment_hash, "No payment hash in invoice" + assert invoice_payment_hash, "No payment hash in invoice" invoice_db = await get_lightning_invoice( db=wallet1.db, payment_hash=invoice_payment_hash, out=True ) @@ -305,7 +311,7 @@ async def test_melt(wallet1: Wallet): assert all([p.melt_id == invoice_db.id for p in proofs_used]), "Wrong melt_id" # the payment was without fees so we need to remove it from the total amount - assert wallet1.balance == 128 - (total_amount - fee_reserve_sat), "Wrong balance" + assert wallet1.balance == 128 - (total_amount - quote.fee_reserve), "Wrong balance" assert wallet1.balance == 64, "Wrong balance" @@ -368,23 +374,23 @@ async def test_send_and_redeem(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio -async def test_invalidate_unspent_proofs(wallet1: Wallet): +async def test_invalidate_all_proofs(wallet1: Wallet): """Try to invalidate proofs that have not been spent yet. Should not work!""" invoice = await wallet1.request_mint(64) pay_if_regtest(invoice.bolt11) await wallet1.mint(64, id=invoice.id) await wallet1.invalidate(wallet1.proofs) - assert wallet1.balance == 64 + assert wallet1.balance == 0 @pytest.mark.asyncio -async def test_invalidate_unspent_proofs_without_checking(wallet1: Wallet): +async def test_invalidate_unspent_proofs_with_checking(wallet1: Wallet): """Try to invalidate proofs that have not been spent yet but force no check.""" invoice = await wallet1.request_mint(64) pay_if_regtest(invoice.bolt11) await wallet1.mint(64, id=invoice.id) - await wallet1.invalidate(wallet1.proofs, check_spendable=False) - assert wallet1.balance == 0 + await wallet1.invalidate(wallet1.proofs, check_spendable=True) + assert wallet1.balance == 64 @pytest.mark.asyncio @@ -405,5 +411,21 @@ async def test_token_state(wallet1: Wallet): await wallet1.mint(64, id=invoice.id) assert wallet1.balance == 64 resp = await wallet1.check_proof_state(wallet1.proofs) - assert resp.dict()["spendable"] - assert resp.dict()["pending"] + assert resp.states[0].state.value == "UNSPENT" + + +@pytest.mark.asyncio +async def test_load_mint_keys_specific_keyset(wallet1: Wallet): + await wallet1._load_mint_keys() + if settings.debug_mint_only_deprecated: + assert list(wallet1.keysets.keys()) == ["eGnEWtdJ0PIM"] + else: + assert list(wallet1.keysets.keys()) == ["009a1f293253e41e", "eGnEWtdJ0PIM"] + await wallet1._load_mint_keys(keyset_id=wallet1.keyset_id) + await wallet1._load_mint_keys(keyset_id="009a1f293253e41e") + # expect deprecated keyset id to be present + await wallet1._load_mint_keys(keyset_id="eGnEWtdJ0PIM") + await assert_err( + wallet1._load_mint_keys(keyset_id="nonexistent"), + KeysetNotFoundError(), + ) diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 4bd66369..7005948d 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -12,14 +12,13 @@ @pytest_asyncio.fixture(scope="function") -async def wallet(mint): +async def wallet(): wallet = await Wallet.with_db( url=SERVER_ENDPOINT, db="test_data/wallet", name="wallet", ) await wallet.load_mint() - wallet.status() yield wallet diff --git a/tests/test_wallet_htlc.py b/tests/test_wallet_htlc.py index 0f6cbff3..2900402f 100644 --- a/tests/test_wallet_htlc.py +++ b/tests/test_wallet_htlc.py @@ -35,25 +35,23 @@ def assert_amt(proofs: List[Proof], expected: int): @pytest_asyncio.fixture(scope="function") -async def wallet1(mint): +async def wallet1(): wallet1 = await Wallet1.with_db( SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1" ) await migrate_databases(wallet1.db, migrations) await wallet1.load_mint() - wallet1.status() yield wallet1 @pytest_asyncio.fixture(scope="function") -async def wallet2(mint): +async def wallet2(): wallet2 = await Wallet2.with_db( SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2" ) await migrate_databases(wallet2.db, migrations) wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True) await wallet2.load_mint() - wallet2.status() yield wallet2 diff --git a/tests/test_wallet_lightning.py b/tests/test_wallet_lightning.py new file mode 100644 index 00000000..b797f3c5 --- /dev/null +++ b/tests/test_wallet_lightning.py @@ -0,0 +1,130 @@ +from typing import List, Union + +import pytest +import pytest_asyncio + +from cashu.core.base import Proof +from cashu.core.errors import CashuError +from cashu.wallet.lightning import LightningWallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest + + +async def assert_err(f, msg: Union[str, CashuError]): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + error_message: str = str(exc.args[0]) + if isinstance(msg, CashuError): + if msg.detail not in error_message: + raise Exception( + f"CashuError. Expected error: {msg.detail}, got: {error_message}" + ) + return + if msg not in error_message: + raise Exception(f"Expected error: {msg}, got: {error_message}") + return + raise Exception(f"Expected error: {msg}, got no error") + + +def assert_amt(proofs: List[Proof], expected: int): + """Assert amounts the proofs contain.""" + assert [p.amount for p in proofs] == expected + + +async def reset_wallet_db(wallet: LightningWallet): + await wallet.db.execute("DELETE FROM proofs") + await wallet.db.execute("DELETE FROM proofs_used") + await wallet.db.execute("DELETE FROM keysets") + await wallet._load_mint() + + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await LightningWallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet1", + name="wallet1", + ) + await wallet.async_init() + yield wallet + + +@pytest.mark.asyncio +async def test_create_invoice(wallet: LightningWallet): + invoice = await wallet.create_invoice(64) + assert invoice.payment_request + assert invoice.payment_request.startswith("ln") + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") +async def test_check_invoice_internal(wallet: LightningWallet): + # fill wallet + invoice = await wallet.create_invoice(64) + assert invoice.payment_request + assert invoice.checking_id + status = await wallet.get_invoice_status(invoice.checking_id) + assert status.paid + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only works with Regtest") +async def test_check_invoice_external(wallet: LightningWallet): + # fill wallet + invoice = await wallet.create_invoice(64) + assert invoice.payment_request + assert invoice.checking_id + status = await wallet.get_invoice_status(invoice.checking_id) + assert not status.paid + pay_if_regtest(invoice.payment_request) + status = await wallet.get_invoice_status(invoice.checking_id) + assert status.paid + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") +async def test_pay_invoice_internal(wallet: LightningWallet): + # fill wallet + invoice = await wallet.create_invoice(64) + assert invoice.payment_request + assert invoice.checking_id + await wallet.get_invoice_status(invoice.checking_id) + assert wallet.available_balance >= 64 + + # pay invoice + invoice2 = await wallet.create_invoice(16) + assert invoice2.payment_request + status = await wallet.pay_invoice(invoice2.payment_request) + + assert status.ok + + # check payment + assert invoice2.checking_id + status = await wallet.get_payment_status(invoice2.checking_id) + assert status.paid + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only works with Regtest") +async def test_pay_invoice_external(wallet: LightningWallet): + # fill wallet + invoice = await wallet.create_invoice(64) + assert invoice.payment_request + assert invoice.checking_id + pay_if_regtest(invoice.payment_request) + status = await wallet.get_invoice_status(invoice.checking_id) + assert status.paid + assert wallet.available_balance >= 64 + + # pay invoice + invoice_real = get_real_invoice(16) + status = await wallet.pay_invoice(invoice_real["payment_request"]) + + assert status.ok + + # check payment + assert status.checking_id + status = await wallet.get_payment_status(status.checking_id) + assert status.paid diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index bb6771c4..c52ac5d7 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -1,12 +1,13 @@ import asyncio import copy +import json import secrets from typing import List import pytest import pytest_asyncio -from cashu.core.base import Proof +from cashu.core.base import Proof, SpentState from cashu.core.crypto.secp import PrivateKey, PublicKey from cashu.core.migrations import migrate_databases from cashu.core.p2pk import SigFlags @@ -16,7 +17,7 @@ from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet2 from tests.conftest import SERVER_ENDPOINT -from tests.helpers import pay_if_regtest +from tests.helpers import is_deprecated_api_only, pay_if_regtest async def assert_err(f, msg): @@ -36,25 +37,23 @@ def assert_amt(proofs: List[Proof], expected: int): @pytest_asyncio.fixture(scope="function") -async def wallet1(mint): +async def wallet1(): wallet1 = await Wallet1.with_db( SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1" ) await migrate_databases(wallet1.db, migrations) await wallet1.load_mint() - wallet1.status() yield wallet1 @pytest_asyncio.fixture(scope="function") -async def wallet2(mint): +async def wallet2(): wallet2 = await Wallet2.with_db( SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2" ) await migrate_databases(wallet2.db, migrations) wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True) await wallet2.load_mint() - wallet2.status() yield wallet2 @@ -80,6 +79,16 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet): ) await wallet2.redeem(send_proofs) + proof_states = await wallet2.check_proof_state(send_proofs) + assert all([p.state == SpentState.spent for p in proof_states.states]) + + if not is_deprecated_api_only: + for state in proof_states.states: + assert state.witness is not None + witness_obj = json.loads(state.witness) + assert len(witness_obj["signatures"]) == 1 + assert len(witness_obj["signatures"][0]) == 128 + @pytest.mark.asyncio async def test_p2pk_sig_all(wallet1: Wallet, wallet2: Wallet): @@ -222,9 +231,9 @@ async def test_p2pk_locktime_with_second_refund_pubkey( secret_lock = await wallet1.create_p2pk_lock( garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey locktime_seconds=2, # locktime - tags=Tags( - [["refund", pubkey_wallet2, pubkey_wallet1]] - ), # multiple refund pubkeys + tags=Tags([ + ["refund", pubkey_wallet2, pubkey_wallet1] + ]), # multiple refund pubkeys ) # sender side _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock @@ -379,9 +388,9 @@ async def test_p2pk_multisig_with_wrong_first_private_key( def test_tags(): - tags = Tags( - [["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]] - ) + tags = Tags([ + ["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"] + ]) assert tags.get_tag("key1") == "value1" assert tags["key1"] == "value1" assert tags.get_tag("key2") == "value2" diff --git a/tests/test_wallet_restore.py b/tests/test_wallet_restore.py index 1e9b213a..76b3c7e6 100644 --- a/tests/test_wallet_restore.py +++ b/tests/test_wallet_restore.py @@ -8,6 +8,7 @@ from cashu.core.base import Proof from cashu.core.crypto.secp import PrivateKey from cashu.core.errors import CashuError +from cashu.core.settings import settings from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet2 @@ -46,31 +47,29 @@ async def reset_wallet_db(wallet: Wallet): @pytest_asyncio.fixture(scope="function") -async def wallet1(mint): +async def wallet1(): wallet1 = await Wallet1.with_db( url=SERVER_ENDPOINT, db="test_data/wallet1", name="wallet1", ) await wallet1.load_mint() - wallet1.status() yield wallet1 @pytest_asyncio.fixture(scope="function") -async def wallet2(mint): +async def wallet2(): wallet2 = await Wallet2.with_db( url=SERVER_ENDPOINT, db="test_data/wallet2", name="wallet2", ) await wallet2.load_mint() - wallet2.status() yield wallet2 @pytest_asyncio.fixture(scope="function") -async def wallet3(mint): +async def wallet3(): dirpath = Path("test_data/wallet3") if dirpath.exists() and dirpath.is_dir(): shutil.rmtree(dirpath) @@ -83,11 +82,14 @@ async def wallet3(mint): await wallet3.db.execute("DELETE FROM proofs") await wallet3.db.execute("DELETE FROM proofs_used") await wallet3.load_mint() - wallet3.status() yield wallet3 @pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) async def test_bump_secret_derivation(wallet3: Wallet): await wallet3._init_private_key( "half depart obvious quality work element tank gorilla view sugar picture" @@ -95,23 +97,27 @@ async def test_bump_secret_derivation(wallet3: Wallet): ) secrets1, rs1, derivation_paths1 = await wallet3.generate_n_secrets(5) secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(0, 4) - assert wallet3.keyset_id == "1cCNIAZ2X/w1" + assert wallet3.keyset_id == "009a1f293253e41e" assert secrets1 == secrets2 assert [r.private_key for r in rs1] == [r.private_key for r in rs2] assert derivation_paths1 == derivation_paths2 + for s in secrets1: + print('"' + s + '",') assert secrets1 == [ - "9d32fc57e6fa2942d05ee475d28ba6a56839b8cb8a3f174b05ed0ed9d3a420f6", - "1c0f2c32e7438e7cc992612049e9dfcdbffd454ea460901f24cc429921437802", - "327c606b761af03cbe26fa13c4b34a6183b868c52cda059fe57fdddcb4e1e1e7", - "53476919560398b56c0fdc5dd92cf8628b1e06de6f2652b0f7d6e8ac319de3b7", - "b2f5d632229378a716be6752fc79ac8c2b43323b820859a7956f2dfe5432b7b4", + "485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae", + "8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270", + "bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8", + "59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf", + "576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0", ] + for d in derivation_paths1: + print('"' + d + '",') assert derivation_paths1 == [ - "m/129372'/0'/2004500376'/0'", - "m/129372'/0'/2004500376'/1'", - "m/129372'/0'/2004500376'/2'", - "m/129372'/0'/2004500376'/3'", - "m/129372'/0'/2004500376'/4'", + "m/129372'/0'/864559728'/0'", + "m/129372'/0'/864559728'/1'", + "m/129372'/0'/864559728'/2'", + "m/129372'/0'/864559728'/3'", + "m/129372'/0'/864559728'/4'", ] @@ -191,7 +197,7 @@ async def test_restore_wallet_after_split_to_send(wallet3: Wallet): assert wallet3.balance == 0 await wallet3.restore_promises_from_to(0, 100) assert wallet3.balance == 64 * 2 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 64 @@ -216,7 +222,7 @@ async def test_restore_wallet_after_send_and_receive(wallet3: Wallet, wallet2: W assert wallet3.balance == 0 await wallet3.restore_promises_from_to(0, 100) assert wallet3.balance == 64 + 2 * 32 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 32 @@ -257,7 +263,7 @@ async def test_restore_wallet_after_send_and_self_receive(wallet3: Wallet): assert wallet3.balance == 0 await wallet3.restore_promises_from_to(0, 100) assert wallet3.balance == 64 + 2 * 32 + 32 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 64 @@ -290,7 +296,7 @@ async def test_restore_wallet_after_send_twice( await wallet3.restore_promises_from_to(0, 10) box.add(wallet3.proofs) assert wallet3.balance == 5 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 2 # again @@ -310,7 +316,7 @@ async def test_restore_wallet_after_send_twice( await wallet3.restore_promises_from_to(0, 15) box.add(wallet3.proofs) assert wallet3.balance == 7 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 2 @@ -345,7 +351,7 @@ async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value( await wallet3.restore_promises_from_to(0, 20) box.add(wallet3.proofs) assert wallet3.balance == 138 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 64 # again @@ -362,5 +368,5 @@ async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value( assert wallet3.balance == 0 await wallet3.restore_promises_from_to(0, 50) assert wallet3.balance == 182 - await wallet3.invalidate(wallet3.proofs) + await wallet3.invalidate(wallet3.proofs, check_spendable=True) assert wallet3.balance == 64