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
This commit is contained in:
callebtc
2024-01-08 00:57:15 +01:00
committed by GitHub
parent 375b27833a
commit a518274f7e
64 changed files with 5362 additions and 2046 deletions

View File

@@ -7,7 +7,7 @@ inputs:
default: "3.10" default: "3.10"
poetry-version: poetry-version:
description: "Poetry Version" description: "Poetry Version"
default: "1.5.1" default: "1.7.1"
runs: runs:
using: "composite" using: "composite"

View File

@@ -7,7 +7,7 @@ on:
default: "3.10.4" default: "3.10.4"
type: string type: string
poetry-version: poetry-version:
default: "1.5.1" default: "1.7.1"
type: string type: string
jobs: jobs:
@@ -46,7 +46,7 @@ jobs:
- name: Setup mypy - name: Setup mypy
run: yes | poetry run mypy cashu --install-types || true run: yes | poetry run mypy cashu --install-types || true
- name: Run mypy - name: Run mypy
run: poetry run mypy cashu --ignore-missing run: poetry run mypy cashu --ignore-missing --check-untyped-defs
ruff: ruff:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -10,25 +10,29 @@ jobs:
uses: ./.github/workflows/checks.yml uses: ./.github/workflows/checks.yml
tests: tests:
strategy: strategy:
fail-fast: false
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
python-version: ["3.10"] python-version: ["3.10"]
poetry-version: ["1.5.1"] poetry-version: ["1.7.1"]
mint-cache-secrets: ["true", "false"] 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: ["", "postgres://cashu:cashu@localhost:5432/test"] # TODO: Postgres test not working
db-url: [""] db-url: [""]
backend-wallet-class: ["FakeWallet"] backend-wallet-class: ["FakeWallet"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
os: ${{ matrix.os }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }} poetry-version: ${{ matrix.poetry-version }}
mint-cache-secrets: ${{ matrix.mint-cache-secrets }} mint-cache-secrets: ${{ matrix.mint-cache-secrets }}
mint-only-deprecated: ${{ matrix.mint-only-deprecated }}
regtest: regtest:
uses: ./.github/workflows/regtest.yml uses: ./.github/workflows/regtest.yml
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
poetry-version: ["1.5.1"] poetry-version: ["1.7.1"]
backend-wallet-class: backend-wallet-class:
["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] ["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"]
with: with:

View File

@@ -7,7 +7,7 @@ on:
default: "3.10.4" default: "3.10.4"
type: string type: string
poetry-version: poetry-version:
default: "1.5.1" default: "1.7.1"
type: string type: string
db-url: db-url:
default: "" default: ""
@@ -18,9 +18,13 @@ on:
mint-cache-secrets: mint-cache-secrets:
default: "false" default: "false"
type: string type: string
mint-only-deprecated:
default: "false"
type: string
jobs: jobs:
poetry: poetry:
name: Run (mint-cache-secrets ${{ inputs.mint-cache-secrets }}, mint-only-deprecated ${{ inputs.mint-only-deprecated }})
runs-on: ${{ inputs.os }} runs-on: ${{ inputs.os }}
services: services:
postgres: postgres:
@@ -51,6 +55,7 @@ jobs:
MINT_PORT: 3337 MINT_PORT: 3337
MINT_DATABASE: ${{ inputs.db-url }} MINT_DATABASE: ${{ inputs.db-url }}
MINT_CACHE_SECRETS: ${{ inputs.mint-cache-secrets }} MINT_CACHE_SECRETS: ${{ inputs.mint-cache-secrets }}
DEBUG_MINT_ONLY_DEPRECATED: ${{ inputs.mint-only-deprecated }}
TOR: false TOR: false
run: | run: |
make test make test

View File

@@ -12,17 +12,18 @@ repos:
- id: debug-statements - id: debug-statements
- id: mixed-line-ending - id: mixed-line-ending
- id: check-case-conflict - id: check-case-conflict
- repo: https://github.com/psf/black # - repo: https://github.com/psf/black
rev: 23.7.0 # rev: 23.11.0
hooks: # hooks:
- id: black # - id: black
# args: [--line-length=150]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.283 rev: v0.0.283
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]
# - repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.6.0 rev: v1.6.0
# hooks: hooks:
# - id: mypy - id: mypy
# args: [--ignore-missing] args: [--ignore-missing, --check-untyped-defs]

View File

@@ -11,7 +11,7 @@ black-check:
poetry run black . --check poetry run black . --check
mypy: mypy:
poetry run mypy cashu --ignore-missing --check-untyped-defs poetry run mypy cashu --check-untyped-defs
format: black ruff format: black ruff

View File

@@ -182,9 +182,9 @@ To run the tests in this repository, first install the dev dependencies with
poetry install --with dev 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 ```bash
LIGHTNING=FALSE MINT_LIGHTNING_BACKEND=FakeWallet
TOR=FALSE TOR=FALSE
``` ```
You can run the tests with You can run the tests with

View File

@@ -1,14 +1,24 @@
import base64 import base64
import json import json
import math
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row from sqlite3 import Row
from typing import Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel, Field
from .crypto.keys import derive_keys, derive_keyset_id, derive_pubkeys 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 .crypto.secp import PrivateKey, PublicKey
from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12
from .settings import settings
class DLEQ(BaseModel): class DLEQ(BaseModel):
@@ -155,6 +165,9 @@ class BlindedMessage(BaseModel):
""" """
amount: int 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 B_: str # Hex-encoded blinded message
witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) 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] = "" 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 -------
# ------- API: INFO ------- # ------- API: INFO -------
class GetInfoResponse(BaseModel): 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 name: Optional[str] = None
pubkey: Optional[str] = None pubkey: Optional[str] = None
version: Optional[str] = None version: Optional[str] = None
@@ -216,40 +269,121 @@ class GetInfoResponse(BaseModel):
# ------- API: KEYS ------- # ------- API: KEYS -------
class KeysResponseKeyset(BaseModel):
id: str
unit: str
keys: Dict[int, str]
class KeysResponse(BaseModel): class KeysResponse(BaseModel):
__root__: Dict[str, str] keysets: List[KeysResponseKeyset]
class KeysetsResponseKeyset(BaseModel):
id: str
unit: str
active: bool
class KeysetsResponse(BaseModel): class KeysetsResponse(BaseModel):
keysets: list[KeysetsResponseKeyset]
class KeysResponse_deprecated(BaseModel):
__root__: Dict[str, str]
class KeysetsResponse_deprecated(BaseModel):
keysets: list[str] 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 ------- # ------- API: MINT -------
class PostMintRequest(BaseModel): 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): class PostMintResponse(BaseModel):
signatures: List[BlindedSignature] = []
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] = [] promises: List[BlindedSignature] = []
class GetMintResponse(BaseModel): # ------- API: MELT QUOTE -------
pr: str
hash: str
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 ------- # ------- API: MELT -------
class PostMeltRequest(BaseModel): class PostMeltRequest(BaseModel):
proofs: List[Proof] quote: str = Field(..., max_length=settings.mint_max_request_length) # quote id
pr: str inputs: List[Proof] = Field(..., max_items=settings.mint_max_request_length)
outputs: Union[List[BlindedMessage], None] outputs: Union[List[BlindedMessage], None] = Field(
..., max_items=settings.mint_max_request_length
)
class GetMeltResponse(BaseModel): 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 PostMeltResponse_deprecated(BaseModel):
paid: Union[bool, None] paid: Union[bool, None]
preimage: Union[str, None] preimage: Union[str, None]
change: Union[List[BlindedSignature], None] = None change: Union[List[BlindedSignature], None] = None
@@ -259,17 +393,30 @@ class GetMeltResponse(BaseModel):
class PostSplitRequest(BaseModel): class PostSplitRequest(BaseModel):
proofs: List[Proof] inputs: List[Proof] = Field(..., max_items=settings.mint_max_request_length)
amount: Optional[int] = None # deprecated since 0.13.0 outputs: List[BlindedMessage] = Field(
outputs: List[BlindedMessage] ..., max_items=settings.mint_max_request_length
)
class PostSplitResponse(BaseModel): class PostSplitResponse(BaseModel):
promises: List[BlindedSignature] signatures: List[BlindedSignature]
# deprecated since 0.13.0 # 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): class PostSplitResponse_Deprecated(BaseModel):
promises: List[BlindedSignature] = []
class PostSplitResponse_Very_Deprecated(BaseModel):
fst: List[BlindedSignature] = [] fst: List[BlindedSignature] = []
snd: List[BlindedSignature] = [] snd: List[BlindedSignature] = []
deprecated: str = "The amount field is deprecated since 0.13.0" deprecated: str = "The amount field is deprecated since 0.13.0"
@@ -278,23 +425,43 @@ class PostSplitResponse_Deprecated(BaseModel):
# ------- API: CHECK ------- # ------- API: CHECK -------
class CheckSpendableRequest(BaseModel): class PostCheckStateRequest(BaseModel):
proofs: List[Proof] secrets: List[str] = Field(..., max_items=settings.mint_max_request_length)
class CheckSpendableResponse(BaseModel): 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 CheckSpendableRequest_deprecated(BaseModel):
proofs: List[Proof] = Field(..., max_items=settings.mint_max_request_length)
class CheckSpendableResponse_deprecated(BaseModel):
spendable: List[bool] spendable: List[bool]
pending: Optional[List[bool]] = ( pending: 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)
class CheckFeesRequest(BaseModel): class CheckFeesRequest_deprecated(BaseModel):
pr: str pr: str = Field(..., max_length=settings.mint_max_request_length)
class CheckFeesResponse(BaseModel): class CheckFeesResponse_deprecated(BaseModel):
fee: Union[int, None] fee: Union[int, None]
@@ -319,12 +486,70 @@ class KeyBase(BaseModel):
pubkey: str 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: class WalletKeyset:
""" """
Contains the keyset from the wallets's perspective. Contains the keyset from the wallets's perspective.
""" """
id: str id: str
unit: Unit
public_keys: Dict[int, PublicKey] public_keys: Dict[int, PublicKey]
mint_url: Union[str, None] = None mint_url: Union[str, None] = None
valid_from: Union[str, None] = None valid_from: Union[str, None] = None
@@ -335,12 +560,14 @@ class WalletKeyset:
def __init__( def __init__(
self, self,
public_keys: Dict[int, PublicKey], public_keys: Dict[int, PublicKey],
id=None, unit: str,
id: Optional[str] = None,
mint_url=None, mint_url=None,
valid_from=None, valid_from=None,
valid_to=None, valid_to=None,
first_seen=None, first_seen=None,
active=None, active=True,
use_deprecated_id=False, # BACKWARDS COMPATIBILITY < 0.15.0
): ):
self.valid_from = valid_from self.valid_from = valid_from
self.valid_to = valid_to self.valid_to = valid_to
@@ -350,17 +577,34 @@ class WalletKeyset:
self.public_keys = public_keys self.public_keys = public_keys
# overwrite id by deriving it from the public keys # overwrite id by deriving it from the public keys
if not id:
self.id = derive_keyset_id(self.public_keys) 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.") 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( logger.warning(
f"WARNING: Keyset id {self.id} does not match the given id {id}." f"WARNING: Keyset id {self.id} does not match the given id {id}."
" Overwriting."
) )
self.id = id
def serialize(self): def serialize(self):
return json.dumps( return json.dumps({
{amount: key.serialize().hex() for amount, key in self.public_keys.items()} amount: key.serialize().hex() for amount, key in self.public_keys.items()
) })
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: Row):
@@ -372,6 +616,7 @@ class WalletKeyset:
return cls( return cls(
id=row["id"], id=row["id"],
unit=row["unit"],
public_keys=( public_keys=(
deserialize(str(row["public_keys"])) deserialize(str(row["public_keys"]))
if dict(row).get("public_keys") if dict(row).get("public_keys")
@@ -391,74 +636,107 @@ class MintKeyset:
""" """
id: str id: str
derivation_path: str
private_keys: Dict[int, PrivateKey] private_keys: Dict[int, PrivateKey]
active: bool
unit: Unit
derivation_path: str
seed: Optional[str] = None
public_keys: Union[Dict[int, PublicKey], None] = None public_keys: Union[Dict[int, PublicKey], None] = None
valid_from: Union[str, None] = None valid_from: Union[str, None] = None
valid_to: Union[str, None] = None valid_to: Union[str, None] = None
first_seen: Union[str, None] = None first_seen: Union[str, None] = None
active: Union[bool, None] = True
version: Union[str, None] = None version: Union[str, None] = None
duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0
def __init__( def __init__(
self, self,
*,
id="", id="",
valid_from=None, valid_from=None,
valid_to=None, valid_to=None,
first_seen=None, first_seen=None,
active=None, active=None,
seed: str = "", seed: Optional[str] = None,
derivation_path: str = "", derivation_path: Optional[str] = None,
version: str = "1", 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.id = id
self.valid_from = valid_from self.valid_from = valid_from
self.valid_to = valid_to self.valid_to = valid_to
self.first_seen = first_seen self.first_seen = first_seen
self.active = active self.active = bool(active) if active is not None else False
self.version = version self.version = version
# generate keys from seed
if seed:
self.generate_keys(seed)
def generate_keys(self, seed): 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 self.seed and self.derivation_path:
self.generate_keys()
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.""" """Generates keys of a keyset from a seed."""
backwards_compatibility_pre_0_12 = False assert self.seed, "seed not set"
if ( assert self.derivation_path, "derivation path not set"
self.version
and len(self.version.split(".")) > 1 if self.version_tuple < (0, 12):
and int(self.version.split(".")[0]) == 0
and int(self.version.split(".")[1]) <= 11
):
backwards_compatibility_pre_0_12 = True
# WARNING: Broken key derivation for backwards compatibility with < 0.12 # WARNING: Broken key derivation for backwards compatibility with < 0.12
self.private_keys = derive_keys_backwards_compatible_insecure_pre_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.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:
logger.warning( logger.warning(
f"WARNING: Using weak key derivation for keyset {self.id} (backwards" f"WARNING: Using weak key derivation for keyset {self.id} (backwards"
" compatibility < 0.12)" " compatibility < 0.12)"
) )
self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
elif self.version_tuple < (0, 15):
class MintKeysets: self.private_keys = derive_keys_sha256(self.seed, self.derivation_path)
""" logger.warning(
Collection of keyset IDs and the corresponding keyset of the mint. f"WARNING: Using non-bip32 derivation for keyset {self.id} (backwards"
""" " compatibility < 0.15)"
)
keysets: Dict[str, MintKeyset] self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
def __init__(self, keysets: List[MintKeyset]): else:
self.keysets = {k.id: k for k in keysets} # type: ignore self.private_keys = derive_keys(self.seed, self.derivation_path)
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
def get_ids(self): self.id = derive_keyset_id(self.public_keys) # type: ignore
return [k for k, _ in self.keysets.items()]
# ------- TOKEN ------- # ------- TOKEN -------
@@ -541,7 +819,7 @@ class TokenV3(BaseModel):
@classmethod @classmethod
def deserialize(cls, tokenv3_serialized: str) -> "TokenV3": def deserialize(cls, tokenv3_serialized: str) -> "TokenV3":
""" """
Takes a TokenV3 and serializes it as "cashuA<json_urlsafe_base64>. Ingesta a serialized "cashuA<json_urlsafe_base64>" token and returns a TokenV3.
""" """
prefix = "cashuA" prefix = "cashuA"
assert tokenv3_serialized.startswith(prefix), Exception( assert tokenv3_serialized.startswith(prefix), Exception(

View File

@@ -3,19 +3,29 @@ import hashlib
import random import random
from typing import Dict from typing import Dict
from bip32 import BIP32
from ..settings import settings from ..settings import settings
from .secp import PrivateKey, PublicKey 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") def derive_keys(mnemonic: str, derivation_path: str):
# bip44_xpub = bip44_xprv.to_public() """
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. Deterministic derivation of keys for 2^n values.
TODO: Implement BIP32. TODO: Implement BIP32.
@@ -40,15 +50,23 @@ def derive_pubkey(master_key: str):
def derive_pubkeys(keys: Dict[int, PrivateKey]): def derive_pubkeys(keys: Dict[int, PrivateKey]):
return { return {amt: keys[amt].pubkey for amt in [2**i for i in range(settings.max_order)]}
amt: keys[amt].pubkey for amt in [2**i for i in range(settings.max_order)]
}
def derive_keyset_id(keys: Dict[int, PublicKey]): def derive_keyset_id(keys: Dict[int, PublicKey]):
"""Deterministic derivation keyset_id from set of public keys.""" """Deterministic derivation keyset_id from set of public keys."""
# sort public keys by amount # sort public keys by amount
sorted_keys = dict(sorted(keys.items())) 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()]) pubkeys_concat = "".join([p.serialize().hex() for _, p in sorted_keys.items()])
return base64.b64encode( return base64.b64encode(
hashlib.sha256((pubkeys_concat).encode("utf-8")).digest() hashlib.sha256((pubkeys_concat).encode("utf-8")).digest()

View File

@@ -39,10 +39,8 @@ def async_unwrap(to_await):
return async_response[0] 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""" """Function for calculating the Lightning fee reserve"""
if internal:
return 0
return max( return max(
int(settings.lightning_reserve_fee_min), int(settings.lightning_reserve_fee_min),
int(amount_msat * settings.lightning_fee_percent / 100.0), int(amount_msat * settings.lightning_fee_percent / 100.0),

View File

@@ -1,38 +1,10 @@
import hashlib import hashlib
from secp256k1 import PrivateKey, PublicKey from secp256k1 import PrivateKey
from ..core.settings import settings 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( def derive_keys_backwards_compatible_insecure_pre_0_12(
master_key: str, derivation_path: str = "" master_key: str, derivation_path: str = ""
): ):

49
cashu/core/logging.py Normal file
View File

@@ -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 = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> |"
" <level>{level}</level> | <level>{message}</level>\n"
)
if settings.debug:
self.fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level:"
" <4}</level> |"
" <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
" | <level>{message}</level>\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()]

View File

@@ -8,7 +8,7 @@ from pydantic import BaseSettings, Extra, Field
env = Env() env = Env()
VERSION = "0.14.1" VERSION = "0.15.0"
def find_env_file(): def find_env_file():
@@ -25,7 +25,6 @@ def find_env_file():
class CashuSettings(BaseSettings): class CashuSettings(BaseSettings):
env_file: str = Field(default=None) env_file: str = Field(default=None)
lightning: bool = Field(default=True)
lightning_fee_percent: float = Field(default=1.0) lightning_fee_percent: float = Field(default=1.0)
lightning_reserve_fee_min: int = Field(default=2000) lightning_reserve_fee_min: int = Field(default=2000)
max_order: int = Field(default=64) max_order: int = Field(default=64)
@@ -45,11 +44,13 @@ class EnvSettings(CashuSettings):
log_level: str = Field(default="INFO") log_level: str = Field(default="INFO")
cashu_dir: str = Field(default=os.path.join(str(Path.home()), ".cashu")) cashu_dir: str = Field(default=os.path.join(str(Path.home()), ".cashu"))
debug_profiling: bool = Field(default=False) debug_profiling: bool = Field(default=False)
debug_mint_only_deprecated: bool = Field(default=False)
class MintSettings(CashuSettings): class MintSettings(CashuSettings):
mint_private_key: str = Field(default=None) 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_host: str = Field(default="127.0.0.1")
mint_listen_port: int = Field(default=3338) mint_listen_port: int = Field(default=3338)
mint_lightning_backend: str = Field(default="LNbitsWallet") mint_lightning_backend: str = Field(default="LNbitsWallet")
@@ -57,11 +58,19 @@ class MintSettings(CashuSettings):
mint_peg_out_only: bool = Field(default=False) mint_peg_out_only: bool = Field(default=False)
mint_max_peg_in: int = Field(default=None) mint_max_peg_in: int = Field(default=None)
mint_max_peg_out: 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_max_balance: int = Field(default=None)
mint_lnbits_endpoint: str = Field(default=None) mint_lnbits_endpoint: str = Field(default=None)
mint_lnbits_key: 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) mint_cache_secrets: bool = Field(default=True)
@@ -75,7 +84,6 @@ class MintInformation(CashuSettings):
class WalletSettings(CashuSettings): class WalletSettings(CashuSettings):
lightning: bool = Field(default=True)
tor: bool = Field(default=True) tor: bool = Field(default=True)
socks_host: str = Field(default=None) # deprecated socks_host: str = Field(default=None) # deprecated
socks_port: int = Field(default=9050) # deprecated socks_port: int = Field(default=9050) # deprecated
@@ -85,6 +93,7 @@ class WalletSettings(CashuSettings):
mint_host: str = Field(default="8333.space") mint_host: str = Field(default="8333.space")
mint_port: int = Field(default=3338) mint_port: int = Field(default=3338)
wallet_name: str = Field(default="wallet") wallet_name: str = Field(default="wallet")
wallet_unit: str = Field(default="sat")
api_port: int = Field(default=4448) api_port: int = Field(default=4448)
api_host: str = Field(default="127.0.0.1") api_host: str = Field(default="127.0.0.1")
@@ -121,6 +130,7 @@ class Settings(
EnvSettings, EnvSettings,
LndRestFundingSource, LndRestFundingSource,
CoreLightningRestFundingSource, CoreLightningRestFundingSource,
FakeWalletSettings,
MintSettings, MintSettings,
MintInformation, MintInformation,
WalletSettings, WalletSettings,

View File

@@ -4,6 +4,7 @@ from .corelightningrest import CoreLightningRestWallet # noqa: F401
from .fake import FakeWallet # noqa: F401 from .fake import FakeWallet # noqa: F401
from .lnbits import LNbitsWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401
from .lndrest import LndRestWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401
from .strike import StrikeUSDWallet # noqa: F401
if settings.mint_lightning_backend is None: if settings.mint_lightning_backend is None:
raise Exception("MINT_LIGHTNING_BACKEND not configured") raise Exception("MINT_LIGHTNING_BACKEND not configured")

View File

@@ -1,12 +1,25 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Coroutine, Optional from typing import Coroutine, Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
from ..core.base import Amount, MeltQuote, Unit
class StatusResponse(BaseModel): class StatusResponse(BaseModel):
error_message: Optional[str] 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): class InvoiceResponse(BaseModel):
@@ -19,14 +32,14 @@ class InvoiceResponse(BaseModel):
class PaymentResponse(BaseModel): class PaymentResponse(BaseModel):
ok: Optional[bool] = None # True: paid, False: failed, None: pending or unknown ok: Optional[bool] = None # True: paid, False: failed, None: pending or unknown
checking_id: Optional[str] = None checking_id: Optional[str] = None
fee_msat: Optional[int] = None fee: Optional[Amount] = None
preimage: Optional[str] = None preimage: Optional[str] = None
error_message: Optional[str] = None error_message: Optional[str] = None
class PaymentStatus(BaseModel): class PaymentStatus(BaseModel):
paid: Optional[bool] = None paid: Optional[bool] = None
fee_msat: Optional[int] = None fee: Optional[Amount] = None
preimage: Optional[str] = None preimage: Optional[str] = None
@property @property
@@ -48,7 +61,13 @@ class PaymentStatus(BaseModel):
return "unknown (should never happen)" 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 @abstractmethod
def status(self) -> Coroutine[None, None, StatusResponse]: def status(self) -> Coroutine[None, None, StatusResponse]:
pass pass
@@ -56,7 +75,7 @@ class Wallet(ABC):
@abstractmethod @abstractmethod
def create_invoice( def create_invoice(
self, self,
amount: int, amount: Amount,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
) -> Coroutine[None, None, InvoiceResponse]: ) -> Coroutine[None, None, InvoiceResponse]:
@@ -64,7 +83,7 @@ class Wallet(ABC):
@abstractmethod @abstractmethod
def pay_invoice( def pay_invoice(
self, bolt11: str, fee_limit_msat: int self, quote: MeltQuote, fee_limit_msat: int
) -> Coroutine[None, None, PaymentResponse]: ) -> Coroutine[None, None, PaymentResponse]:
pass pass
@@ -80,6 +99,20 @@ class Wallet(ABC):
) -> Coroutine[None, None, PaymentStatus]: ) -> Coroutine[None, None, PaymentStatus]:
pass pass
@abstractmethod
async def get_payment_quote(
self,
bolt11: str,
) -> PaymentQuoteResponse:
pass
# @abstractmethod
# async def get_invoice_quote(
# self,
# bolt11: str,
# ) -> InvoiceQuoteResponse:
# pass
# @abstractmethod # @abstractmethod
# def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# pass # pass

View File

@@ -4,23 +4,30 @@ import random
from typing import AsyncGenerator, Dict, Optional from typing import AsyncGenerator, Dict, Optional
import httpx import httpx
from bolt11 import Bolt11Exception from bolt11 import (
from bolt11.decode import decode Bolt11Exception,
decode,
)
from loguru import logger from loguru import logger
from ..core.base import Amount, MeltQuote, Unit
from ..core.helpers import fee_reserve
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
LightningBackend,
PaymentQuoteResponse,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
Unsupported, Unsupported,
Wallet,
) )
from .macaroon import load_macaroon from .macaroon import load_macaroon
class CoreLightningRestWallet(Wallet): class CoreLightningRestWallet(LightningBackend):
units = set([Unit.sat, Unit.msat])
def __init__(self): def __init__(self):
macaroon = settings.mint_corelightning_rest_macaroon macaroon = settings.mint_corelightning_rest_macaroon
assert macaroon, "missing cln-rest macaroon" assert macaroon, "missing cln-rest macaroon"
@@ -72,26 +79,27 @@ class CoreLightningRestWallet(Wallet):
error_message=( error_message=(
f"Failed to connect to {self.url}, got: '{error_message}...'" f"Failed to connect to {self.url}, got: '{error_message}...'"
), ),
balance_msat=0, balance=0,
) )
data = r.json() data = r.json()
if len(data) == 0: 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) 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( async def create_invoice(
self, self,
amount: int, amount: Amount,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs, **kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
self.assert_unit_supported(amount.unit)
label = f"lbl{random.random()}" label = f"lbl{random.random()}"
data: Dict = { data: Dict = {
"amount": amount * 1000, "amount": amount.to(Unit.msat, round="up").amount,
"description": memo, "description": memo,
"label": label, "label": label,
} }
@@ -139,14 +147,16 @@ class CoreLightningRestWallet(Wallet):
error_message=None, 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: try:
invoice = decode(bolt11) invoice = decode(quote.request)
except Bolt11Exception as exc: except Bolt11Exception as exc:
return PaymentResponse( return PaymentResponse(
ok=False, ok=False,
checking_id=None, checking_id=None,
fee_msat=None, fee=None,
preimage=None, preimage=None,
error_message=str(exc), error_message=str(exc),
) )
@@ -156,7 +166,7 @@ class CoreLightningRestWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=False, ok=False,
checking_id=None, checking_id=None,
fee_msat=None, fee=None,
preimage=None, preimage=None,
error_message=error_message, error_message=error_message,
) )
@@ -164,7 +174,7 @@ class CoreLightningRestWallet(Wallet):
r = await self.client.post( r = await self.client.post(
f"{self.url}/v1/pay", f"{self.url}/v1/pay",
data={ data={
"invoice": bolt11, "invoice": quote.request,
"maxfeepercent": f"{fee_limit_percent:.11}", "maxfeepercent": f"{fee_limit_percent:.11}",
"exemptfee": 0, # so fee_limit_percent is applied even on payments "exemptfee": 0, # so fee_limit_percent is applied even on payments
# with fee < 5000 millisatoshi (which is default value of exemptfee) # with fee < 5000 millisatoshi (which is default value of exemptfee)
@@ -181,7 +191,7 @@ class CoreLightningRestWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=False, ok=False,
checking_id=None, checking_id=None,
fee_msat=None, fee=None,
preimage=None, preimage=None,
error_message=error_message, error_message=error_message,
) )
@@ -192,7 +202,7 @@ class CoreLightningRestWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=False, ok=False,
checking_id=None, checking_id=None,
fee_msat=None, fee=None,
preimage=None, preimage=None,
error_message="payment failed", error_message="payment failed",
) )
@@ -204,7 +214,7 @@ class CoreLightningRestWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=self.statuses.get(data["status"]), ok=self.statuses.get(data["status"]),
checking_id=checking_id, checking_id=checking_id,
fee_msat=fee_msat, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage, preimage=preimage,
error_message=None, error_message=None,
) )
@@ -249,7 +259,7 @@ class CoreLightningRestWallet(Wallet):
return PaymentStatus( return PaymentStatus(
paid=self.statuses.get(pay["status"]), 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, preimage=preimage,
) )
except Exception as e: except Exception as e:
@@ -274,7 +284,6 @@ class CoreLightningRestWallet(Wallet):
except Exception: except Exception:
continue continue
logger.trace(f"paid invoice: {inv}") logger.trace(f"paid invoice: {inv}")
yield inv["label"]
# NOTE: use payment_hash when corelightning-rest returns it # NOTE: use payment_hash when corelightning-rest returns it
# when using waitAnyInvoice # when using waitAnyInvoice
# payment_hash = inv["payment_hash"] # payment_hash = inv["payment_hash"]
@@ -299,3 +308,14 @@ class CoreLightningRestWallet(Wallet):
"reconnecting..." "reconnecting..."
) )
await asyncio.sleep(0.02) 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
)

View File

@@ -14,22 +14,21 @@ from bolt11 import (
encode, encode,
) )
from ..core.base import Amount, MeltQuote, Unit
from ..core.helpers import fee_reserve
from ..core.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
LightningBackend,
PaymentQuoteResponse,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, 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) queue: asyncio.Queue[Bolt11] = asyncio.Queue(0)
payment_secrets: Dict[str, str] = dict() payment_secrets: Dict[str, str] = dict()
paid_invoices: Set[str] = set() paid_invoices: Set[str] = set()
@@ -43,17 +42,18 @@ class FakeWallet(Wallet):
).hex() ).hex()
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
return StatusResponse(error_message=None, balance_msat=1337) return StatusResponse(error_message=None, balance=1337)
async def create_invoice( async def create_invoice(
self, self,
amount: int, amount: Amount,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None, expiry: Optional[int] = None,
payment_secret: Optional[bytes] = None, payment_secret: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
self.assert_unit_supported(amount.unit)
tags = Tags() tags = Tags()
if description_hash: if description_hash:
@@ -83,7 +83,7 @@ class FakeWallet(Wallet):
bolt11 = Bolt11( bolt11 = Bolt11(
currency="bc", currency="bc",
amount_msat=MilliSatoshi(amount * 1000), amount_msat=MilliSatoshi(amount.to(Unit.msat, round="up").amount),
date=int(datetime.now().timestamp()), date=int(datetime.now().timestamp()),
tags=tags, tags=tags,
) )
@@ -94,19 +94,19 @@ class FakeWallet(Wallet):
ok=True, checking_id=payment_hash, payment_request=payment_request ok=True, checking_id=payment_hash, 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: int) -> PaymentResponse:
invoice = decode(bolt11) invoice = decode(quote.request)
if DELAY_PAYMENT: if settings.fakewallet_delay_payment:
await asyncio.sleep(5) 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) await self.queue.put(invoice)
self.paid_invoices.add(invoice.payment_hash) self.paid_invoices.add(invoice.payment_hash)
return PaymentResponse( return PaymentResponse(
ok=True, ok=True,
checking_id=invoice.payment_hash, 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, preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64,
) )
else: else:
@@ -115,10 +115,10 @@ class FakeWallet(Wallet):
) )
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
if STOCHASTIC_INVOICE: if settings.fakewallet_stochastic_invoice:
paid = random.random() > 0.7 paid = random.random() > 0.7
return PaymentStatus(paid=paid) 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) return PaymentStatus(paid=paid or None)
async def get_payment_status(self, _: str) -> PaymentStatus: async def get_payment_status(self, _: str) -> PaymentStatus:
@@ -128,3 +128,20 @@ class FakeWallet(Wallet):
while True: while True:
value: Bolt11 = await self.queue.get() value: Bolt11 = await self.queue.get()
yield value.payment_hash 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
)

View File

@@ -2,20 +2,28 @@
from typing import Optional from typing import Optional
import httpx 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 ..core.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
LightningBackend,
PaymentQuoteResponse,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
Wallet,
) )
class LNbitsWallet(Wallet): class LNbitsWallet(LightningBackend):
"""https://github.com/lnbits/lnbits""" """https://github.com/lnbits/lnbits"""
units = set([Unit.sat])
def __init__(self): def __init__(self):
self.endpoint = settings.mint_lnbits_endpoint self.endpoint = settings.mint_lnbits_endpoint
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
@@ -30,7 +38,7 @@ class LNbitsWallet(Wallet):
except Exception as exc: except Exception as exc:
return StatusResponse( return StatusResponse(
error_message=f"Failed to connect to {self.endpoint} due to: {exc}", error_message=f"Failed to connect to {self.endpoint} due to: {exc}",
balance_msat=0, balance=0,
) )
try: try:
@@ -40,23 +48,25 @@ class LNbitsWallet(Wallet):
error_message=( error_message=(
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'" f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'"
), ),
balance_msat=0, balance=0,
) )
if "detail" in data: if "detail" in data:
return StatusResponse( 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( async def create_invoice(
self, self,
amount: int, amount: Amount,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
data = {"out": False, "amount": amount} self.assert_unit_supported(amount.unit)
data = {"out": False, "amount": amount.to(Unit.sat).amount}
if description_hash: if description_hash:
data["description_hash"] = description_hash.hex() data["description_hash"] = description_hash.hex()
if unhashed_description: if unhashed_description:
@@ -83,11 +93,13 @@ class LNbitsWallet(Wallet):
payment_request=payment_request, 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: try:
r = await self.client.post( r = await self.client.post(
url=f"{self.endpoint}/api/v1/payments", url=f"{self.endpoint}/api/v1/payments",
json={"out": True, "bolt11": bolt11}, json={"out": True, "bolt11": quote.request},
timeout=None, timeout=None,
) )
r.raise_for_status() r.raise_for_status()
@@ -107,7 +119,7 @@ class LNbitsWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=True, ok=True,
checking_id=checking_id, checking_id=checking_id,
fee_msat=payment.fee_msat, fee=payment.fee,
preimage=payment.preimage, preimage=payment.preimage,
) )
@@ -138,6 +150,17 @@ class LNbitsWallet(Wallet):
return PaymentStatus( return PaymentStatus(
paid=data["paid"], paid=data["paid"],
fee_msat=data["details"]["fee"], fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])),
preimage=data["preimage"], 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
)

View File

@@ -5,22 +5,30 @@ import json
from typing import AsyncGenerator, Dict, Optional from typing import AsyncGenerator, Dict, Optional
import httpx import httpx
from bolt11 import (
decode,
)
from loguru import logger from loguru import logger
from ..core.base import Amount, MeltQuote, Unit
from ..core.helpers import fee_reserve
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
LightningBackend,
PaymentQuoteResponse,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
Wallet,
) )
from .macaroon import load_macaroon from .macaroon import load_macaroon
class LndRestWallet(Wallet): class LndRestWallet(LightningBackend):
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" """https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
units = set([Unit.sat, Unit.msat])
def __init__(self): def __init__(self):
endpoint = settings.mint_lnd_rest_endpoint endpoint = settings.mint_lnd_rest_endpoint
cert = settings.mint_lnd_rest_cert cert = settings.mint_lnd_rest_cert
@@ -67,7 +75,7 @@ class LndRestWallet(Wallet):
except (httpx.ConnectError, httpx.RequestError) as exc: except (httpx.ConnectError, httpx.RequestError) as exc:
return StatusResponse( return StatusResponse(
error_message=f"Unable to connect to {self.endpoint}. {exc}", error_message=f"Unable to connect to {self.endpoint}. {exc}",
balance_msat=0, balance=0,
) )
try: try:
@@ -75,21 +83,24 @@ class LndRestWallet(Wallet):
if r.is_error: if r.is_error:
raise Exception raise Exception
except Exception: except Exception:
return StatusResponse(error_message=r.text[:200], balance_msat=0) return StatusResponse(error_message=r.text[:200], balance=0)
return StatusResponse( return StatusResponse(error_message=None, balance=int(data["balance"]) * 1000)
error_message=None, balance_msat=int(data["balance"]) * 1000
)
async def create_invoice( async def create_invoice(
self, self,
amount: int, amount: Amount,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs, **kwargs,
) -> InvoiceResponse: ) -> 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"): if kwargs.get("expiry"):
data["expiry"] = kwargs["expiry"] data["expiry"] = kwargs["expiry"]
if description_hash: if description_hash:
@@ -101,7 +112,10 @@ class LndRestWallet(Wallet):
hashlib.sha256(unhashed_description).digest() hashlib.sha256(unhashed_description).digest()
).decode("ascii") ).decode("ascii")
try:
r = await self.client.post(url="/v1/invoices", json=data) 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: if r.is_error:
error_message = r.text error_message = r.text
@@ -128,14 +142,16 @@ class LndRestWallet(Wallet):
error_message=None, 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 # set the fee limit for the payment
lnrpcFeeLimit = dict() lnrpcFeeLimit = dict()
lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}" lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}"
r = await self.client.post( r = await self.client.post(
url="/v1/channels/transactions", url="/v1/channels/transactions",
json={"payment_request": bolt11, "fee_limit": lnrpcFeeLimit}, json={"payment_request": quote.request, "fee_limit": lnrpcFeeLimit},
timeout=None, timeout=None,
) )
@@ -144,7 +160,7 @@ class LndRestWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=False, ok=False,
checking_id=None, checking_id=None,
fee_msat=None, fee=None,
preimage=None, preimage=None,
error_message=error_message, error_message=error_message,
) )
@@ -156,7 +172,7 @@ class LndRestWallet(Wallet):
return PaymentResponse( return PaymentResponse(
ok=True, ok=True,
checking_id=checking_id, checking_id=checking_id,
fee_msat=fee_msat, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage, preimage=preimage,
error_message=None, error_message=None,
) )
@@ -209,7 +225,11 @@ class LndRestWallet(Wallet):
if payment is not None and payment.get("status"): if payment is not None and payment.get("status"):
return PaymentStatus( return PaymentStatus(
paid=statuses[payment["status"]], 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"), preimage=payment.get("payment_preimage"),
) )
else: else:
@@ -240,3 +260,14 @@ class LndRestWallet(Wallet):
" seconds" " seconds"
) )
await asyncio.sleep(5) 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
)

219
cashu/lightning/strike.py Normal file
View File

@@ -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)

View File

@@ -1,4 +1,3 @@
import logging
import sys import sys
from traceback import print_exception from traceback import print_exception
@@ -14,8 +13,10 @@ from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request from starlette.requests import Request
from ..core.errors import CashuError from ..core.errors import CashuError
from ..core.logging import configure_logger
from ..core.settings import settings from ..core.settings import settings
from .router import router from .router import router
from .router_deprecated import router_deprecated
from .startup import start_mint_init from .startup import start_mint_init
if settings.debug_profiling: if settings.debug_profiling:
@@ -37,48 +38,6 @@ if settings.debug_profiling:
def create_app(config_object="core.settings") -> FastAPI: def create_app(config_object="core.settings") -> FastAPI:
def configure_logger() -> None:
class Formatter:
def __init__(self):
self.padding = 0
self.minimal_fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> |"
" <level>{level}</level> | <level>{message}</level>\n"
)
if settings.debug:
self.fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level:"
" <4}</level> |"
" <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
" | <level>{message}</level>\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() configure_logger()
# middleware = [ # middleware = [
@@ -99,8 +58,8 @@ def create_app(config_object="core.settings") -> FastAPI:
] ]
app = FastAPI( app = FastAPI(
title="Cashu Python Mint", title="Nutshell Cashu Mint",
description="Ecash wallet and mint for Bitcoin", description="Ecash wallet and mint based on the Cashu protocol.",
version=settings.version, version=settings.version,
license_info={ license_info={
"name": "MIT License", "name": "MIT License",
@@ -176,5 +135,10 @@ async def startup_mint():
await start_mint_init() 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) app.add_exception_handler(RequestValidationError, request_validation_exception_handler)

View File

@@ -255,9 +255,9 @@ class LedgerSpendingConditions:
# check if all secrets are P2PK # check if all secrets are P2PK
# NOTE: This is redundant, because P2PKSecret.from_secret() already checks for the kind # NOTE: This is redundant, because P2PKSecret.from_secret() already checks for the kind
# Leaving it in for explicitness # Leaving it in for explicitness
if not all( if not all([
[SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets] SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets
): ]):
# not all secrets are P2PK # not all secrets are P2PK
return True return True

View File

@@ -1,10 +1,18 @@
import time
from abc import ABC, abstractmethod
from typing import Any, List, Optional 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 from ..core.db import Connection, Database, table_with_schema
class LedgerCrud: class LedgerCrud(ABC):
""" """
Database interface for Cashu mint. Database interface for Cashu mint.
@@ -12,117 +20,183 @@ class LedgerCrud:
to use their own database. to use their own database.
""" """
@abstractmethod
async def get_keyset( async def get_keyset(
self, self,
*,
db: Database, db: Database,
id: str = "", id: str = "",
derivation_path: str = "", derivation_path: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> List[MintKeyset]: ...
return await get_keyset(
db=db,
id=id,
derivation_path=derivation_path,
conn=conn,
)
async def get_lightning_invoice( @abstractmethod
self, async def get_spent_proofs(
db: Database,
id: str,
conn: Optional[Connection] = None,
) -> Optional[Invoice]:
return await get_lightning_invoice(
db=db,
id=id,
conn=conn,
)
async def get_secrets_used(
self, self,
*,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> List[str]: ) -> List[Proof]: ...
return await get_secrets_used(
db=db,
conn=conn,
)
async def get_proof_used( async def get_proof_used(
self, self,
*,
db: Database, db: Database,
proof: Proof, secret: str,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[Proof]: ) -> Optional[Proof]: ...
return await get_proof_used(
db=db,
proof=proof,
conn=conn,
)
@abstractmethod
async def invalidate_proof( async def invalidate_proof(
self, self,
*,
db: Database, db: Database,
proof: Proof, proof: Proof,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None: ...
return await invalidate_proof(
db=db,
proof=proof,
conn=conn,
)
@abstractmethod
async def get_proofs_pending( async def get_proofs_pending(
self, self,
*,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> List[Proof]: ...
return await get_proofs_pending(db=db, conn=conn)
@abstractmethod
async def set_proof_pending( async def set_proof_pending(
self, self,
*,
db: Database, db: Database,
proof: Proof, proof: Proof,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None: ...
return await set_proof_pending(
db=db,
proof=proof,
conn=conn,
)
@abstractmethod
async def unset_proof_pending( async def unset_proof_pending(
self, proof: Proof, db: Database, conn: Optional[Connection] = None self, *, proof: Proof, db: Database, conn: Optional[Connection] = None
): ) -> None: ...
return await unset_proof_pending(
proof=proof,
db=db,
conn=conn,
)
@abstractmethod
async def store_keyset( async def store_keyset(
self, self,
*,
db: Database, db: Database,
keyset: MintKeyset, keyset: MintKeyset,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None: ...
return await store_keyset(
db=db,
keyset=keyset,
conn=conn,
)
async def store_lightning_invoice( @abstractmethod
async def get_balance(
self, self,
db: Database, db: Database,
invoice: Invoice,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> int: ...
return await store_lightning_invoice(
db=db, @abstractmethod
invoice=invoice, async def store_promise(
conn=conn, self,
) *,
db: Database,
amount: int,
B_: str,
C_: str,
id: str,
e: str = "",
s: str = "",
conn: Optional[Connection] = None,
) -> 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( async def store_promise(
self, self,
@@ -135,71 +209,12 @@ class LedgerCrud:
e: str = "", e: str = "",
s: str = "", s: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None:
return await store_promise(
db=db,
amount=amount,
B_=B_,
C_=C_,
id=id,
e=e,
s=s,
conn=conn,
)
async def get_promise(
self,
db: Database,
B_: str,
conn: Optional[Connection] = None,
):
return await get_promise(
db=db,
B_=B_,
conn=conn,
)
async def update_lightning_invoice(
self,
db: Database,
id: str,
issued: bool,
conn: Optional[Connection] = None,
):
return await update_lightning_invoice(
db=db,
id=id,
issued=issued,
conn=conn,
)
async def get_balance(
self,
db: Database,
conn: Optional[Connection] = None,
) -> int:
return await get_balance(
db=db,
conn=conn,
)
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( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'promises')} INSERT INTO {table_with_schema(db, 'promises')}
(amount, B_b, C_b, e, s, id) (amount, B_b, C_b, e, s, id, created)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
( (
amount, amount,
@@ -208,15 +223,17 @@ async def store_promise(
e, e,
s, s,
id, id,
int(time.time()),
), ),
) )
async def get_promise(
async def get_promise( self,
*,
db: Database, db: Database,
B_: str, B_: str,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> Optional[BlindedSignature]:
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
f""" f"""
SELECT * from {table_with_schema(db, 'promises')} SELECT * from {table_with_schema(db, 'promises')}
@@ -226,202 +243,326 @@ async def get_promise(
) )
return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None
async def get_spent_proofs(
async def get_secrets_used( self,
*,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> List[str]: ) -> List[Proof]:
rows = await (conn or db).fetchall(f""" rows = await (conn or db).fetchall(f"""
SELECT secret from {table_with_schema(db, 'proofs_used')} SELECT * from {table_with_schema(db, 'proofs_used')}
""") """)
return [row[0] for row in rows] return [Proof(**r) for r in rows] if rows else []
async def invalidate_proof(
async def invalidate_proof( self,
*,
db: Database, db: Database,
proof: Proof, proof: Proof,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None:
# we add the proof and secret to the used list # we add the proof and secret to the used list
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'proofs_used')} INSERT INTO {table_with_schema(db, 'proofs_used')}
(amount, C, secret, id) (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 (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""", """,
( (
proof.amount, proof.amount,
str(proof.C), str(proof.C),
str(proof.secret), str(proof.secret),
str(proof.id), int(time.time()),
), ),
) )
async def unset_proof_pending(
async def get_proofs_pending( self,
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, proof: Proof,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None:
await (conn or db).execute( await (conn or db).execute(
f""" f"""
DELETE FROM {table_with_schema(db, 'proofs_pending')} DELETE FROM {table_with_schema(db, 'proofs_pending')}
WHERE secret = ? WHERE secret = ?
""", """,
(str(proof["secret"]),), (proof.secret,),
) )
async def store_mint_quote(
async def store_lightning_invoice( self,
*,
quote: MintQuote,
db: Database, db: Database,
invoice: Invoice,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None:
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'invoices')} INSERT INTO {table_with_schema(db, 'mint_quotes')}
(amount, bolt11, id, issued, payment_hash, out) (quote, method, request, checking_id, unit, amount, issued, paid, created_time, paid_time)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
invoice.amount, quote.quote,
invoice.bolt11, quote.method,
invoice.id, quote.request,
invoice.issued, quote.checking_id,
invoice.payment_hash, quote.unit,
invoice.out, quote.amount,
quote.issued,
quote.paid,
quote.created_time,
quote.paid_time,
), ),
) )
async def get_mint_quote(
async def get_lightning_invoice( self,
db: Database,
*, *,
id: Optional[str] = None, quote_id: str,
payment_hash: Optional[str] = None, db: Database,
conn: Optional[Connection] = None, 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 = [] clauses = []
values: List[Any] = [] values: List[Any] = []
if id: if quote_id:
clauses.append("id = ?") clauses.append("quote = ?")
values.append(id) values.append(quote_id)
if payment_hash: if checking_id:
clauses.append("payment_hash = ?") clauses.append("checking_id = ?")
values.append(payment_hash) values.append(checking_id)
if request:
clauses.append("request = ?")
values.append(request)
where = "" where = ""
if clauses: if clauses:
where = f"WHERE {' AND '.join(clauses)}" where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
f""" f"""
SELECT * from {table_with_schema(db, 'invoices')} SELECT * from {table_with_schema(db, 'melt_quotes')}
{where} {where}
""", """,
tuple(values), tuple(values),
) )
row_dict = dict(row) if row is None:
return Invoice(**row_dict) if row_dict else None return None
return MeltQuote(**dict(row)) if row else None
async def update_melt_quote(
async def update_lightning_invoice( self,
*,
quote: MeltQuote,
db: Database, db: Database,
id: str,
issued: bool,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None:
await (conn or db).execute( await (conn or db).execute(
f"UPDATE {table_with_schema(db, 'invoices')} SET issued = ? WHERE id = ?", f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, fee_paid = ?,"
" paid_time = ?, proof = ? WHERE quote = ?",
( (
issued, quote.paid,
id, quote.fee_paid,
quote.paid_time,
quote.proof,
quote.quote,
), ),
) )
async def store_keyset(
async def store_keyset( self,
*,
db: Database, db: Database,
keyset: MintKeyset, keyset: MintKeyset,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ) -> None:
await (conn or db).execute( # type: ignore await (conn or db).execute( # type: ignore
f""" f"""
INSERT INTO {table_with_schema(db, 'keysets')} INSERT INTO {table_with_schema(db, 'keysets')}
(id, derivation_path, valid_from, valid_to, first_seen, active, version) (id, seed, derivation_path, valid_from, valid_to, first_seen, active, version, unit)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
keyset.id, keyset.id,
keyset.seed,
keyset.derivation_path, keyset.derivation_path,
keyset.valid_from or db.timestamp_now, keyset.valid_from or int(time.time()),
keyset.valid_to or db.timestamp_now, keyset.valid_to or int(time.time()),
keyset.first_seen or db.timestamp_now, keyset.first_seen or int(time.time()),
True, True,
keyset.version, keyset.version,
keyset.unit.name,
), ),
) )
async def get_balance(
async def get_keyset( self,
db: Database, db: Database,
id: str = "",
derivation_path: str = "",
conn: Optional[Connection] = None, 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_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 = [] clauses = []
values: List[Any] = [] values: List[Any] = []
if active is not None:
clauses.append("active = ?") clauses.append("active = ?")
values.append(True) values.append(active)
if id: if id is not None:
clauses.append("id = ?") clauses.append("id = ?")
values.append(id) values.append(id)
if derivation_path: if derivation_path is not None:
clauses.append("derivation_path = ?") clauses.append("derivation_path = ?")
values.append(derivation_path) values.append(derivation_path)
if unit is not None:
clauses.append("unit = ?")
values.append(unit)
where = "" where = ""
if clauses: if clauses:
where = f"WHERE {' AND '.join(clauses)}" where = f"WHERE {' AND '.join(clauses)}"
@@ -435,13 +576,17 @@ async def get_keyset(
) )
return [MintKeyset(**row) for row in rows] return [MintKeyset(**row) for row in rows]
async def get_proof_used(
async def get_balance( self,
db: Database, db: Database,
secret: str,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> int: ) -> Optional[Proof]:
row = await (conn or db).fetchone(f""" row = await (conn or db).fetchone(
SELECT * from {table_with_schema(db, 'balance')} f"""
""") SELECT * from {table_with_schema(db, 'proofs_used')}
assert row, "Balance not found" WHERE secret = ?
return int(row[0]) """,
(secret,),
)
return Proof(**row) if row else None

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -32,7 +32,6 @@ def main(
for a in ctx.args: for a in ctx.args:
item = a.split("=") item = a.split("=")
if len(item) > 1: # argument like --key=value if len(item) > 1: # argument like --key=value
print(a, item)
d[item[0].strip("--").replace("-", "_")] = ( d[item[0].strip("--").replace("-", "_")] = (
int(item[1]) # need to convert to int if it's a number int(item[1]) # need to convert to int if it's a number
if item[1].isdigit() if item[1].isdigit()
@@ -49,5 +48,6 @@ def main(
ssl_certfile=ssl_certfile, ssl_certfile=ssl_certfile,
**d, # type: ignore **d, # type: ignore
) )
server = uvicorn.Server(config) server = uvicorn.Server(config)
server.run() server.run()

View File

@@ -1,4 +1,7 @@
import time
from ..core.db import Connection, Database, table_with_schema from ..core.db import Connection, Database, table_with_schema
from ..core.settings import settings
async def m000_create_migrations_table(conn: Connection): 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" " proofs_used_secret_idx ON"
f" {table_with_schema(db, 'proofs_used')} (secret)" 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')}")

View File

@@ -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 ..core.db import Database
from ..lightning.base import Wallet from ..lightning.base import LightningBackend
from ..mint.crud import LedgerCrud from ..mint.crud import LedgerCrud
class SupportsKeysets(Protocol): class SupportsKeysets(Protocol):
keyset: MintKeyset keyset: MintKeyset
keysets: MintKeysets keysets: Dict[str, MintKeyset]
class SupportLightning(Protocol): class SupportLightning(Protocol):
lightning: Wallet lightning: Dict[Unit, LightningBackend]
class SupportsDb(Protocol): class SupportsDb(Protocol):

View File

@@ -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 loguru import logger
from ..core.base import ( from ..core.base import (
BlindedSignature,
CheckFeesRequest,
CheckFeesResponse,
CheckSpendableRequest,
CheckSpendableResponse,
GetInfoResponse, GetInfoResponse,
GetMeltResponse,
GetMintResponse,
KeysetsResponse, KeysetsResponse,
KeysetsResponseKeyset,
KeysResponse, KeysResponse,
KeysResponseKeyset,
PostCheckStateRequest,
PostCheckStateResponse,
PostMeltQuoteRequest,
PostMeltQuoteResponse,
PostMeltRequest, PostMeltRequest,
PostMeltResponse,
PostMintQuoteRequest,
PostMintQuoteResponse,
PostMintRequest, PostMintRequest,
PostMintResponse, PostMintResponse,
PostRestoreResponse, PostRestoreResponse,
PostSplitRequest, PostSplitRequest,
PostSplitResponse, PostSplitResponse,
PostSplitResponse_Deprecated,
) )
from ..core.errors import CashuError from ..core.errors import CashuError
from ..core.settings import settings from ..core.settings import settings
@@ -30,14 +31,38 @@ router: APIRouter = APIRouter()
@router.get( @router.get(
"/info", "/v1/info",
name="Mint information", name="Mint information",
summary="Mint information, operator contact information, and other info.", summary="Mint information, operator contact information, and other info.",
response_model=GetInfoResponse, response_model=GetInfoResponse,
response_model_exclude_none=True, response_model_exclude_none=True,
) )
async def info() -> GetInfoResponse: 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( return GetInfoResponse(
name=settings.mint_info_name, name=settings.mint_info_name,
pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None, pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None,
@@ -45,18 +70,13 @@ async def info() -> GetInfoResponse:
description=settings.mint_info_description, description=settings.mint_info_description,
description_long=settings.mint_info_description_long, description_long=settings.mint_info_description_long,
contact=settings.mint_info_contact, contact=settings.mint_info_contact,
nuts=settings.mint_info_nuts, nuts=mint_features,
motd=settings.mint_info_motd, 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( @router.get(
"/keys", "/v1/keys",
name="Mint public keys", name="Mint public keys",
summary="Get the public keys of the newest mint keyset", summary="Get the public keys of the newest mint keyset",
response_description=( response_description=(
@@ -67,14 +87,23 @@ async def info() -> GetInfoResponse:
) )
async def keys(): async def keys():
"""This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" """This endpoint returns a dictionary of all supported token values of the mint and their associated public key."""
logger.trace("> GET /keys") logger.trace("> GET /v1/keys")
keyset = ledger.get_keyset() keyset = ledger.keyset
keys = KeysResponse.parse_obj(keyset) keyset_for_response = []
return keys.__root__ 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( @router.get(
"/keys/{idBase64Urlsafe}", "/v1/keys/{keyset_id}",
name="Keyset public keys", name="Keyset public keys",
summary="Public keys of a specific keyset", summary="Public keys of a specific keyset",
response_description=( response_description=(
@@ -83,21 +112,33 @@ async def keys():
), ),
response_model=KeysResponse, 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. 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}") logger.trace(f"> GET /v1/keys/{keyset_id}")
id = idBase64Urlsafe.replace("-", "+").replace("_", "/") # BEGIN BACKWARDS COMPATIBILITY < 0.15.0
keyset = ledger.get_keyset(keyset_id=id) # if keyset_id is not hex, we assume it is base64 and sanitize it
keys = KeysResponse.parse_obj(keyset) try:
return keys.__root__ 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( @router.get(
"/keysets", "/v1/keysets",
name="Active keysets", name="Active keysets",
summary="Get all active keyset id of the mind", summary="Get all active keyset id of the mind",
response_model=KeysetsResponse, response_model=KeysetsResponse,
@@ -105,44 +146,75 @@ async def keyset_keys(idBase64Urlsafe: str):
) )
async def keysets() -> KeysetsResponse: async def keysets() -> KeysetsResponse:
"""This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from."""
logger.trace("> GET /keysets") logger.trace("> GET /v1/keysets")
keysets = KeysetsResponse(keysets=ledger.keysets.get_ids()) keysets = []
return 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( @router.post(
"/mint", "/v1/mint/quote/bolt11",
name="Request mint", name="Request mint quote",
summary="Request minting of new tokens", summary="Request a quote for minting of new tokens",
response_model=GetMintResponse, response_model=PostMintQuoteResponse,
response_description=( response_description="A payment request to mint tokens of a denomination",
"A Lightning invoice to be paid and a hash to request minting of new tokens"
" after payment."
),
) )
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. Request minting of new tokens. The mint responds with a Lightning invoice.
This endpoint can be used for a Lightning invoice UX flow. 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: if amount > 21_000_000 * 100_000_000 or amount <= 0:
raise CashuError(code=0, detail="Amount must be a valid amount of sat.") raise CashuError(code=0, detail="Amount must be a valid amount of sat.")
if settings.mint_peg_out_only: if settings.mint_peg_out_only:
raise CashuError(code=0, detail="Mint does not allow minting new tokens.") raise CashuError(code=0, detail="Mint does not allow minting new tokens.")
payment_request, hash = await ledger.request_mint(amount) quote = await ledger.mint_quote(payload)
resp = GetMintResponse(pr=payment_request, hash=hash) resp = PostMintQuoteResponse(
logger.trace(f"< GET /mint: {resp}") 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 return resp
@router.post( @router.post(
"/mint", "/v1/mint/bolt11",
name="Mint tokens", 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_model=PostMintResponse,
response_description=( response_description=(
"A list of blinded signatures that can be used to create proofs." "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( async def mint(
payload: PostMintRequest, payload: PostMintRequest,
hash: Optional[str] = None,
payment_hash: Optional[str] = None,
) -> PostMintResponse: ) -> PostMintResponse:
""" """
Requests the minting of tokens belonging to a paid payment request. 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 promises = await ledger.mint(outputs=payload.outputs, quote_id=payload.quote)
# We use the payment_hash to lookup the hash from the database and pass that one along. blinded_signatures = PostMintResponse(signatures=promises)
id = payment_hash or hash logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}")
# 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}")
return blinded_signatures return blinded_signatures
@router.post( @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", name="Melt tokens",
summary=( summary=(
"Melt tokens for a Bitcoin payment that the mint will make for the user in" "Melt tokens for a Bitcoin payment that the mint will make for the user in"
" exchange" " exchange"
), ),
response_model=GetMeltResponse, response_model=PostMeltResponse,
response_description=( response_description=(
"The state of the payment, a preimage as proof of payment, and a list of" "The state of the payment, a preimage as proof of payment, and a list of"
" promises for change." " promises for change."
), ),
) )
async def melt(payload: PostMeltRequest) -> GetMeltResponse: async def melt(payload: PostMeltRequest) -> PostMeltResponse:
""" """
Requests tokens to be destroyed and sent out via Lightning. Requests tokens to be destroyed and sent out via Lightning.
""" """
logger.trace(f"> POST /melt: {payload}") logger.trace(f"> POST /v1/melt/bolt11: {payload}")
ok, preimage, change_promises = await ledger.melt( preimage, change_promises = await ledger.melt(
payload.proofs, payload.pr, payload.outputs proofs=payload.inputs, quote=payload.quote, outputs=payload.outputs
) )
resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises) resp = PostMeltResponse(
logger.trace(f"< POST /melt: {resp}") paid=True, payment_preimage=preimage, change=change_promises
)
logger.trace(f"< POST /v1/melt/bolt11: {resp}")
return resp return resp
@router.post( @router.post(
"/check", "/v1/swap",
name="Check proof state", name="Swap tokens",
summary="Check whether a proof is spent already or is pending in a transaction", summary="Swap inputs for outputs of the same value",
response_model=CheckSpendableResponse, response_model=PostSplitResponse,
response_description=( response_description=(
"Two lists of booleans indicating whether the provided proofs " "An array of blinded signatures that can be used to create 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 <spendable>: {spendableList}")
logger.trace(f"< POST /check <pending>: {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."
), ),
) )
async def split( async def split(
payload: PostSplitRequest, payload: PostSplitRequest,
) -> Union[PostSplitResponse, PostSplitResponse_Deprecated]: ) -> PostSplitResponse:
""" """
Requests a set of Proofs to be split into two a new set of BlindedSignatures. 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. 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. 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.") assert payload.outputs, Exception("no outputs provided.")
promises = await ledger.split(proofs=payload.proofs, outputs=payload.outputs) signatures = await ledger.split(proofs=payload.inputs, outputs=payload.outputs)
if payload.amount: return PostSplitResponse(signatures=signatures)
# 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)
@router.post( @router.post(
"/restore", "/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(
"/v1/restore",
name="Restore", name="Restore",
summary="Restores a blinded signature from a secret", summary="Restores a blinded signature from a secret",
response_model=PostRestoreResponse, response_model=PostRestoreResponse,

View File

@@ -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)

View File

@@ -6,11 +6,12 @@ import importlib
from loguru import logger from loguru import logger
from ..core.base import Method, Unit
from ..core.db import Database from ..core.db import Database
from ..core.migrations import migrate_databases from ..core.migrations import migrate_databases
from ..core.settings import settings from ..core.settings import settings
from ..mint import migrations from ..mint import migrations
from ..mint.crud import LedgerCrud from ..mint.crud import LedgerCrudSqlite
from ..mint.ledger import Ledger from ..mint.ledger import Ledger
logger.debug("Enviroment Settings:") logger.debug("Enviroment Settings:")
@@ -22,16 +23,29 @@ lightning_backend = getattr(wallets_module, settings.mint_lightning_backend)()
assert settings.mint_private_key is not None, "No mint private key set." 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( ledger = Ledger(
db=Database("mint", settings.mint_database), db=Database("mint", settings.mint_database),
seed=settings.mint_private_key, seed=settings.mint_private_key,
derivation_path=settings.mint_derivation_path, derivation_path=settings.mint_derivation_path,
lightning=lightning_backend, backends=backends,
crud=LedgerCrud(), crud=LedgerCrudSqlite(),
) )
async def rotate_keys(n_seconds=10): async def rotate_keys(n_seconds=60):
"""Rotate keyset epoch every n_seconds. """Rotate keyset epoch every n_seconds.
Note: This is just a helper function for testing purposes. Note: This is just a helper function for testing purposes.
""" """
@@ -39,8 +53,10 @@ async def rotate_keys(n_seconds=10):
while True: while True:
i += 1 i += 1
logger.info("Rotating keys.") logger.info("Rotating keys.")
ledger.derivation_path = f"0/0/0/{i}" incremented_derivation_path = (
await ledger.init_keysets() "/".join(ledger.derivation_path.split("/")[:-1]) + f"/{i}"
)
await ledger.activate_keyset(incremented_derivation_path)
logger.info(f"Current keyset: {ledger.keyset.id}") logger.info(f"Current keyset: {ledger.keyset.id}")
await asyncio.sleep(n_seconds) await asyncio.sleep(n_seconds)
@@ -51,16 +67,24 @@ async def start_mint_init():
await ledger.load_used_proofs() await ledger.load_used_proofs()
await ledger.init_keysets() await ledger.init_keysets()
if settings.lightning: for derivation_path in settings.mint_derivation_path_list:
logger.info(f"Using backend: {settings.mint_lightning_backend}") await ledger.activate_keyset(derivation_path)
status = await ledger.lightning.status()
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}'"
)
status = await ledger.backends[method][unit].status()
if status.error_message: if status.error_message:
logger.warning( logger.warning(
f"The backend for {ledger.lightning.__class__.__name__} isn't" "The backend for"
f" {ledger.backends[method][unit].__class__.__name__} isn't"
f" working properly: '{status.error_message}'", f" working properly: '{status.error_message}'",
RuntimeWarning, RuntimeWarning,
) )
logger.info(f"Lightning balance: {status.balance_msat} msat") logger.info(f"Backend balance: {status.balance} {unit.name}")
logger.info(f"Data dir: {settings.cashu_dir}") logger.info(f"Data dir: {settings.cashu_dir}")
logger.info("Mint started.") logger.info("Mint started.")

View File

@@ -1,4 +1,4 @@
from typing import List, Literal, Optional, Set, Union from typing import Dict, List, Literal, Optional, Union
from loguru import logger from loguru import logger
@@ -6,7 +6,6 @@ from ..core.base import (
BlindedMessage, BlindedMessage,
BlindedSignature, BlindedSignature,
MintKeyset, MintKeyset,
MintKeysets,
Proof, Proof,
) )
from ..core.crypto import b_dhke from ..core.crypto import b_dhke
@@ -29,20 +28,20 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb):
"""Verification functions for the ledger.""" """Verification functions for the ledger."""
keyset: MintKeyset keyset: MintKeyset
keysets: MintKeysets keysets: Dict[str, MintKeyset]
secrets_used: Set[str] = set() spent_proofs: Dict[str, Proof]
crud: LedgerCrud crud: LedgerCrud
db: Database db: Database
async def verify_inputs_and_outputs( 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. """Checks all proofs and outputs for validity.
Args: Args:
proofs (List[Proof]): List of proofs to check. proofs (List[Proof]): List of proofs to check.
outputs (Optional[List[BlindedMessage]], optional): List of outputs 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: Raises:
Exception: Scripts did not validate. Exception: Scripts did not validate.
@@ -52,8 +51,8 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb):
""" """
# Verify inputs # Verify inputs
# Verify proofs are spendable # Verify proofs are spendable
spendable = await self._check_proofs_spendable(proofs) spent_proofs = await self._get_proofs_spent([p.secret for p in proofs])
if not all(spendable): if not len(spent_proofs) == 0:
raise TokenAlreadySpentError() raise TokenAlreadySpentError()
# Verify amounts of inputs # Verify amounts of inputs
if not all([self._verify_amount(p.amount) for p in proofs]): if not all([self._verify_amount(p.amount) for p in proofs]):
@@ -83,12 +82,31 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb):
# Verify inputs and outputs together # Verify inputs and outputs together
if not self._verify_input_output_amounts(proofs, outputs): if not self._verify_input_output_amounts(proofs, outputs):
raise TransactionError("input amounts less than output.") 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 # Verify output spending conditions
if outputs and not self._verify_output_spending_conditions(proofs, outputs): if outputs and not self._verify_output_spending_conditions(proofs, outputs):
raise TransactionError("validation of output spending conditions failed.") raise TransactionError("validation of output spending conditions failed.")
async def _verify_outputs(self, outputs: List[BlindedMessage]): async def _verify_outputs(self, outputs: List[BlindedMessage]):
"""Verify that the outputs are valid.""" """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 # Verify amounts of outputs
if not all([self._verify_amount(o.amount) for o in outputs]): if not all([self._verify_amount(o.amount) for o in outputs]):
raise TransactionError("invalid amount.") raise TransactionError("invalid amount.")
@@ -98,6 +116,7 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb):
# verify that outputs have not been signed previously # verify that outputs have not been signed previously
if any(await self._check_outputs_issued_before(outputs)): if any(await self._check_outputs_issued_before(outputs)):
raise TransactionError("outputs have already been signed before.") 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]): async def _check_outputs_issued_before(self, outputs: List[BlindedMessage]):
"""Checks whether the provided outputs have previously been signed by the mint """Checks whether the provided outputs have previously been signed by the mint
@@ -118,24 +137,32 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb):
result.append(False if promise is None else True) result.append(False if promise is None else True)
return result return result
async def _check_proofs_spendable(self, proofs: List[Proof]) -> List[bool]: async def _get_proofs_pending(self, secrets: List[str]) -> Dict[str, Proof]:
"""Checks whether the proof was already spent.""" """Returns only those proofs that are pending."""
spendable_states = [] 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: if settings.mint_cache_secrets:
# check used secrets in memory # check used secrets in memory
for p in proofs: for secret in secrets:
spendable_state = p.secret not in self.secrets_used if secret in self.spent_proofs:
spendable_states.append(spendable_state) proofs_spent.append(self.spent_proofs[secret])
else: else:
# check used secrets in database # check used secrets in database
async with self.db.connect() as conn: async with self.db.connect() as conn:
for p in proofs: for secret in secrets:
spendable_state = ( spent_proof = await self.crud.get_proof_used(
await self.crud.get_proof_used(db=self.db, proof=p, conn=conn) db=self.db, secret=secret, conn=conn
is None
) )
spendable_states.append(spendable_state) if spent_proof:
return spendable_states 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]: def _verify_secret_criteria(self, proof: Proof) -> Literal[True]:
"""Verifies that a secret is present and is not too long (DOS prevention).""" """Verifies that a secret is present and is not too long (DOS prevention)."""
@@ -145,23 +172,27 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb):
raise SecretTooLongError() raise SecretTooLongError()
return True 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.""" """Verifies that the proof of promise was issued by this ledger."""
# if no keyset id is given in proof, assume the current one # if no keyset id is given in proof, assume the current one
if not proof.id: if not proof.id:
private_key_amount = self.keyset.private_keys[proof.amount] private_key_amount = self.keyset.private_keys[proof.amount]
else: 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( 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 # use the appropriate active keyset for this proof.id
private_key_amount = self.keysets.keysets[proof.id].private_keys[ private_key_amount = self.keysets[proof.id].private_keys[proof.amount]
proof.amount
]
C = PublicKey(bytes.fromhex(proof.C), raw=True) 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( def _verify_input_output_amounts(
self, inputs: List[Proof], outputs: List[BlindedMessage] self, inputs: List[Proof], outputs: List[BlindedMessage]

View File

@@ -45,9 +45,9 @@ class NostrClient:
def connect(self): def connect(self):
for relay in self.relays: for relay in self.relays:
self.relay_manager.add_relay(relay) self.relay_manager.add_relay(relay)
self.relay_manager.open_connections( self.relay_manager.open_connections({
{"cert_reqs": ssl.CERT_NONE} "cert_reqs": ssl.CERT_NONE
) # NOTE: This disables ssl certificate verification }) # NOTE: This disables ssl certificate verification
def close(self): def close(self):
self.relay_manager.close_connections() self.relay_manager.close_connections()
@@ -105,15 +105,13 @@ class NostrClient:
self.relay_manager.publish_event(dm) self.relay_manager.publish_event(dm)
def get_dm(self, sender_publickey: PublicKey, callback_func=None, filter_kwargs={}): def get_dm(self, sender_publickey: PublicKey, callback_func=None, filter_kwargs={}):
filters = Filters( filters = Filters([
[
Filter( Filter(
kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE],
pubkey_refs=[sender_publickey.hex()], pubkey_refs=[sender_publickey.hex()],
**filter_kwargs, **filter_kwargs,
) )
] ])
)
subscription_id = os.urandom(4).hex() subscription_id = os.urandom(4).hex()
self.relay_manager.add_subscription(subscription_id, filters) self.relay_manager.add_subscription(subscription_id, filters)

View File

@@ -77,8 +77,7 @@ class Event:
) )
def to_message(self) -> str: def to_message(self) -> str:
return json.dumps( return json.dumps([
[
ClientMessageType.EVENT, ClientMessageType.EVENT,
{ {
"id": self.id, "id": self.id,
@@ -89,8 +88,7 @@ class Event:
"content": self.content, "content": self.content,
"sig": self.signature, "sig": self.signature,
}, },
] ])
)
@dataclass @dataclass

View File

@@ -1,5 +1,5 @@
from ...core.base import TokenV3 from ...core.base import TokenV3
from ...wallet.crud import get_keyset from ...wallet.crud import get_keysets
async def verify_mints(wallet, tokenObj: TokenV3): 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.") raise Exception("Token has missing mint information.")
for mint in mints: for mint in mints:
assert mint assert mint
mint_keysets = await get_keyset(mint_url=mint, db=wallet.db) mint_keysets = await get_keysets(mint_url=mint, db=wallet.db)
assert mint_keysets, "We don't know this mint." assert len(mint_keysets), "We don't know this mint."

View File

@@ -92,7 +92,9 @@ async def pay(
if mint: if mint:
wallet = await mint_wallet(mint) wallet = await mint_wallet(mint)
payment_response = await wallet.pay_invoice(bolt11) 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( @router.get(
@@ -162,10 +164,8 @@ async def lightning_balance() -> StatusResponse:
try: try:
await wallet.load_proofs(reload=True) await wallet.load_proofs(reload=True)
except Exception as exc: except Exception as exc:
return StatusResponse(error_message=str(exc), balance_msat=0) return StatusResponse(error_message=str(exc), balance=0)
return StatusResponse( return StatusResponse(error_message=None, balance=wallet.available_balance * 1000)
error_message=None, balance_msat=wallet.available_balance * 1000
)
@router.post( @router.post(
@@ -179,8 +179,6 @@ async def swap(
outgoing_mint: str = Query(default=..., description="URL of outgoing mint"), outgoing_mint: str = Query(default=..., description="URL of outgoing mint"),
incoming_mint: str = Query(default=..., description="URL of incoming 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) incoming_wallet = await mint_wallet(incoming_mint)
outgoing_wallet = await mint_wallet(outgoing_mint) outgoing_wallet = await mint_wallet(outgoing_mint)
if incoming_wallet.url == outgoing_wallet.url: if incoming_wallet.url == outgoing_wallet.url:
@@ -191,17 +189,17 @@ async def swap(
# pay invoice from outgoing mint # pay invoice from outgoing mint
await outgoing_wallet.load_proofs(reload=True) await outgoing_wallet.load_proofs(reload=True)
total_amount, fee_reserve_sat = await outgoing_wallet.get_pay_amount_with_fees( quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11)
invoice.bolt11 total_amount = quote.amount + quote.fee_reserve
)
assert total_amount > 0, "amount must be positive"
if outgoing_wallet.available_balance < total_amount: if outgoing_wallet.available_balance < total_amount:
raise Exception("balance too low") raise Exception("balance too low")
_, send_proofs = await outgoing_wallet.split_to_send( _, send_proofs = await outgoing_wallet.split_to_send(
outgoing_wallet.proofs, total_amount, set_reserved=True 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 # mint token in incoming mint
await incoming_wallet.mint(amount, id=invoice.id) await incoming_wallet.mint(amount, id=invoice.id)
@@ -325,9 +323,9 @@ async def burn(
proofs = tokenObj.get_proofs() proofs = tokenObj.get_proofs()
if delete: if delete:
await wallet.invalidate(proofs, check_spendable=False)
else:
await wallet.invalidate(proofs) await wallet.invalidate(proofs)
else:
await wallet.invalidate(proofs, check_spendable=True)
return BurnResponse(balance=wallet.available_balance) return BurnResponse(balance=wallet.available_balance)
@@ -361,8 +359,7 @@ async def pending(
reserved_date = datetime.utcfromtimestamp( reserved_date = datetime.utcfromtimestamp(
int(grouped_proofs[0].time_reserved) # type: ignore int(grouped_proofs[0].time_reserved) # type: ignore
).strftime("%Y-%m-%d %H:%M:%S") ).strftime("%Y-%m-%d %H:%M:%S")
result.update( result.update({
{
f"{i}": { f"{i}": {
"amount": sum_proofs(grouped_proofs), "amount": sum_proofs(grouped_proofs),
"time": reserved_date, "time": reserved_date,
@@ -370,8 +367,7 @@ async def pending(
"token": token, "token": token,
"mint": mint, "mint": mint,
} }
} })
)
return PendingResponse(pending_token=result) return PendingResponse(pending_token=result)
@@ -416,22 +412,20 @@ async def wallets():
if w == wallet.name: if w == wallet.name:
active_wallet = True active_wallet = True
if active_wallet: if active_wallet:
result.update( result.update({
{
f"{w}": { f"{w}": {
"balance": sum_proofs(wallet.proofs), "balance": sum_proofs(wallet.proofs),
"available": sum_proofs( "available": sum_proofs([
[p for p in wallet.proofs if not p.reserved] p for p in wallet.proofs if not p.reserved
), ]),
} }
} })
)
except Exception: except Exception:
pass pass
return WalletsResponse(wallets=result) 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( async def restore(
to: int = Query(default=..., description="Counter to which restore the wallet"), to: int = Query(default=..., description="Counter to which restore the wallet"),
): ):
@@ -439,8 +433,7 @@ async def restore(
raise Exception("Counter must be positive") raise Exception("Counter must be positive")
await wallet.load_mint() await wallet.load_mint()
await wallet.restore_promises_from_to(0, to) await wallet.restore_promises_from_to(0, to)
await wallet.invalidate(wallet.proofs) await wallet.invalidate(wallet.proofs, check_spendable=True)
wallet.status()
return RestoreResponse(balance=wallet.available_balance) return RestoreResponse(balance=wallet.available_balance)

View File

@@ -14,7 +14,9 @@ import click
from click import Context from click import Context
from loguru import logger 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.helpers import sum_proofs
from ...core.settings import settings from ...core.settings import settings
from ...nostr.client.client import NostrClient from ...nostr.client.client import NostrClient
@@ -26,7 +28,13 @@ from ...wallet.crud import (
) )
from ...wallet.wallet import Wallet as Wallet from ...wallet.wallet import Wallet as Wallet
from ..api.api_server import start_api_server 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 ( from ..helpers import (
deserialize_token_from_string, deserialize_token_from_string,
init_wallet, init_wallet,
@@ -74,6 +82,13 @@ def coro(f):
default=settings.wallet_name, default=settings.wallet_name,
help=f"Wallet name (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( @click.option(
"--daemon", "--daemon",
"-d", "-d",
@@ -92,7 +107,9 @@ def coro(f):
) )
@click.pass_context @click.pass_context
@coro @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(): if settings.tor and not TorProxy().check_platform():
error_str = ( error_str = (
"Your settings say TOR=true but the built-in Tor bundle is not supported on" "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.ensure_object(dict)
ctx.obj["HOST"] = host or settings.mint_url ctx.obj["HOST"] = host or settings.mint_url
ctx.obj["UNIT"] = unit
ctx.obj["WALLET_NAME"] = walletname ctx.obj["WALLET_NAME"] = walletname
settings.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 # otherwise it will create a mnemonic and store it in the database
if ctx.invoked_subcommand == "restore": if ctx.invoked_subcommand == "restore":
wallet = await Wallet.with_db( 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: else:
# # we need to run the migrations before we load the wallet for the first time # # 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 # # otherwise the wallet will not be able to generate a new private key and store it
wallet = await Wallet.with_db( 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 # 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) 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 ctx.obj["WALLET"] = wallet
# await init_wallet(ctx.obj["WALLET"], load_proofs=False) # 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 # 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 a mint host is already specified as an argument `host`, use it
if ctx.invoked_subcommand not in ["send", "invoice", "pay"] or host: if ctx.invoked_subcommand not in ["send", "invoice", "pay"] or host:
return 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 # else: we ask the user to select one
ctx.obj["WALLET"] = await get_mint_wallet( ctx.obj["WALLET"] = await get_mint_wallet(
ctx 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): async def pay(ctx: Context, invoice: str, yes: bool):
wallet: Wallet = ctx.obj["WALLET"] wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint() await wallet.load_mint()
wallet.status() print_balance(ctx)
total_amount, fee_reserve_sat = await wallet.get_pay_amount_with_fees(invoice) quote = await wallet.get_pay_amount_with_fees(invoice)
logger.debug(f"Quote: {quote}")
total_amount = quote.amount + quote.fee_reserve
if not yes: if not yes:
potential = ( 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( click.confirm(
message, message,
abort=True, abort=True,
@@ -181,27 +205,26 @@ async def pay(ctx: Context, invoice: str, yes: bool):
print("Paying Lightning invoice ...", end="", flush=True) print("Paying Lightning invoice ...", end="", flush=True)
assert total_amount > 0, "amount is not positive" assert total_amount > 0, "amount is not positive"
if wallet.available_balance < total_amount: if wallet.available_balance < total_amount:
print("Error: Balance too low.") print(" Error: Balance too low.")
return return
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
try: try:
melt_response = await wallet.pay_lightning( melt_response = await wallet.pay_lightning(
send_proofs, invoice, fee_reserve_sat send_proofs, invoice, quote.fee_reserve, quote.quote
) )
except Exception as e: except Exception as e:
print(f"\nError paying invoice: {str(e)}") print(f" Error paying invoice: {str(e)}")
return return
print(" Invoice paid", end="", flush=True) print(" Invoice paid", end="", flush=True)
if melt_response.preimage and melt_response.preimage != "0" * 64: if melt_response.payment_preimage and melt_response.payment_preimage != "0" * 64:
print(f" (Proof: {melt_response.preimage}).") print(f" (Preimage: {melt_response.payment_preimage}).")
else: else:
print(".") print(".")
wallet.status() print_balance(ctx)
@cli.command("invoice", help="Create Lighting invoice.") @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("--id", default="", help="Id of the paid invoice.", type=str)
@click.option( @click.option(
"--split", "--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): async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool):
wallet: Wallet = ctx.obj["WALLET"] wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint() 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 # in case the user wants a specific split, we create a list of amounts
optional_split = None optional_split = None
if split: 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" assert amount >= split, "split must smaller or equal amount"
n_splits = amount // split n_splits = amount // split
optional_split = [split] * n_splits 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 # user requests an invoice
elif amount and not id: if amount and not id:
invoice = await wallet.request_mint(amount) invoice = await wallet.request_mint(amount)
if invoice.bolt11: if invoice.bolt11:
print(f"Pay invoice to mint {amount} sat:") print("")
print(f"Pay invoice to mint {wallet.unit.str(amount)}:")
print("") print("")
print(f"Invoice: {invoice.bolt11}") print(f"Invoice: {invoice.bolt11}")
print("") 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 # user paid invoice and want to check it
elif amount and id: elif amount and id:
await wallet.mint(amount, split=optional_split, id=id) await wallet.mint(amount, split=optional_split, id=id)
wallet.status() print("")
print_balance(ctx)
return return
@@ -288,8 +314,6 @@ async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool
@click.pass_context @click.pass_context
@coro @coro
async def swap(ctx: Context): async def swap(ctx: Context):
if not settings.lightning:
raise Exception("lightning not supported.")
print("Select the mint to swap from:") print("Select the mint to swap from:")
outgoing_wallet = await get_mint_wallet(ctx, force_select=True) 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: if incoming_wallet.url == outgoing_wallet.url:
raise Exception("mints for swap have to be different") 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" assert amount > 0, "amount is not positive"
# request invoice from incoming mint # request invoice from incoming mint
invoice = await incoming_wallet.request_mint(amount) invoice = await incoming_wallet.request_mint(amount)
# pay invoice from outgoing mint # pay invoice from outgoing mint
total_amount, fee_reserve_sat = await outgoing_wallet.get_pay_amount_with_fees( quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11)
invoice.bolt11 total_amount = quote.amount + quote.fee_reserve
)
if outgoing_wallet.available_balance < total_amount: if outgoing_wallet.available_balance < total_amount:
raise Exception("balance too low") raise Exception("balance too low")
_, send_proofs = await outgoing_wallet.split_to_send( _, send_proofs = await outgoing_wallet.split_to_send(
outgoing_wallet.proofs, total_amount, set_reserved=True 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 # mint token in incoming mint
await incoming_wallet.mint(amount, id=invoice.id) await incoming_wallet.mint(amount, id=invoice.id)
@@ -339,34 +364,44 @@ async def swap(ctx: Context):
@coro @coro
async def balance(ctx: Context, verbose): async def balance(ctx: Context, verbose):
wallet: Wallet = ctx.obj["WALLET"] 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: if verbose:
# show balances per keyset # show balances per keyset
keyset_balances = wallet.balance_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(f"You have balances in {len(keyset_balances)} keysets:")
print("") print("")
for k, v in keyset_balances.items(): for k, v in keyset_balances.items(): # type: ignore
unit = Unit[str(v["unit"])]
print( print(
f"Keyset: {k} - Balance: {v['available']} sat (pending:" f"Keyset: {k} - Balance: {unit.str(int(v['available']))} (pending:"
f" {v['balance']-v['available']} sat)" f" {unit.str(int(v['balance'])-int(v['available']))})"
) )
print("") print("")
await print_mint_balances(wallet) await print_mint_balances(wallet)
await wallet.load_proofs(reload=True)
if verbose: if verbose:
print( print(
f"Balance: {wallet.available_balance} sat (pending:" f"Balance: {wallet.unit.str(wallet.available_balance)} (pending:"
f" {wallet.balance-wallet.available_balance} sat) in" f" {wallet.unit.str(wallet.balance-wallet.available_balance)}) in"
f" {len([p for p in wallet.proofs if not p.reserved])} tokens" f" {len([p for p in wallet.proofs if not p.reserved])} tokens"
) )
else: else:
print(f"Balance: {wallet.available_balance} sat") print(f"Balance: {wallet.unit.str(wallet.available_balance)}")
@cli.command("send", help="Send tokens.") @cli.command("send", help="Send tokens.")
@click.argument("amount", type=int) @click.argument("amount", type=float)
@click.argument("nostr", type=str, required=False) @click.argument("nostr", type=str, required=False)
@click.option( @click.option(
"--nostr", "--nostr",
@@ -426,6 +461,7 @@ async def send_command(
nosplit: bool, nosplit: bool,
): ):
wallet: Wallet = ctx.obj["WALLET"] wallet: Wallet = ctx.obj["WALLET"]
amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount)
if not nostr and not nopt: if not nostr and not nopt:
await send( await send(
wallet, wallet,
@@ -439,6 +475,7 @@ async def send_command(
await send_nostr( await send_nostr(
wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes
) )
print_balance(ctx)
@cli.command("receive", help="Receive tokens.") @cli.command("receive", help="Receive tokens.")
@@ -493,6 +530,8 @@ async def receive_cli(
await receive(wallet, tokenObj) await receive(wallet, tokenObj)
else: else:
print("Error: enter token or use either flag --nostr or --all.") print("Error: enter token or use either flag --nostr or --all.")
return
print_balance(ctx)
@cli.command("burn", help="Burn spent tokens.") @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() proofs = tokenObj.get_proofs()
if delete: if delete:
await wallet.invalidate(proofs, check_spendable=False)
else:
await wallet.invalidate(proofs) await wallet.invalidate(proofs)
wallet.status() else:
await wallet.invalidate(proofs, check_spendable=True)
print_balance(ctx)
@cli.command("pending", help="Show pending tokens.") @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) int(grouped_proofs[0].time_reserved)
).strftime("%Y-%m-%d %H:%M:%S") ).strftime("%Y-%m-%d %H:%M:%S")
print( 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" f" {reserved_date} ID: {key} Mint: {mint}\n"
) )
print(f"{token}\n") print(f"{token}\n")
@@ -697,9 +737,10 @@ async def wallets(ctx):
if w == ctx.obj["WALLET_NAME"]: if w == ctx.obj["WALLET_NAME"]:
active_wallet = True active_wallet = True
print( print(
f"Wallet: {w}\tBalance: {sum_proofs(wallet.proofs)} sat" f"Wallet: {w}\tBalance:"
f" {wallet.unit.str(sum_proofs(wallet.proofs))}"
" (available: " " (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: except Exception:
pass pass
@@ -737,25 +778,32 @@ async def info(ctx: Context, mint: bool, mnemonic: bool):
if mint: if mint:
for mint_url in mint_list: for mint_url in mint_list:
wallet.url = mint_url wallet.url = mint_url
try:
mint_info: dict = (await wallet._load_mint_info()).dict() mint_info: dict = (await wallet._load_mint_info()).dict()
print("") print("")
print("Mint information:") print("---- Mint information ----")
print("") print("")
print(f"Mint URL: {mint_url}") print(f"Mint URL: {mint_url}")
if mint_info: if mint_info:
print(f"Mint name: {mint_info['name']}") print(f"Mint name: {mint_info['name']}")
if mint_info["description"]: if mint_info.get("description"):
print(f"Description: {mint_info['description']}") print(f"Description: {mint_info['description']}")
if mint_info["description_long"]: if mint_info.get("description_long"):
print(f"Long description: {mint_info['description_long']}") print(f"Long description: {mint_info['description_long']}")
if mint_info["contact"]: if mint_info.get("contact"):
print(f"Contact: {mint_info['contact']}") print(f"Contact: {mint_info['contact']}")
if mint_info["version"]: if mint_info.get("version"):
print(f"Version: {mint_info['version']}") print(f"Version: {mint_info['version']}")
if mint_info["motd"]: if mint_info.get("motd"):
print(f"Message of the day: {mint_info['motd']}") print(f"Message of the day: {mint_info['motd']}")
if mint_info["parameter"]: if mint_info.get("nuts"):
print(f"Parameter: {mint_info['parameter']}") 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: if mnemonic:
assert wallet.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.restore_wallet_from_mnemonic(mnemonic, to=to, batch=batch)
await wallet.load_proofs() await wallet.load_proofs()
wallet.status() print_balance(ctx)
@cli.command("selfpay", help="Refresh tokens.") @cli.command("selfpay", help="Refresh tokens.")
@@ -820,7 +868,7 @@ async def selfpay(ctx: Context, all: bool = False):
# get balance on this mint # get balance on this mint
mint_balance_dict = await wallet.balance_per_minturl() 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 # send balance once to mark as reserved
await wallet.split_to_send(wallet.proofs, mint_balance, None, set_reserved=True) await wallet.split_to_send(wallet.proofs, mint_balance, None, set_reserved=True)
# load all reserved proofs (including the one we just sent) # load all reserved proofs (including the one we just sent)

View File

@@ -4,11 +4,62 @@ import click
from click import Context from click import Context
from loguru import logger from loguru import logger
from ...core.base import Unit
from ...core.settings import settings from ...core.settings import settings
from ...wallet.crud import get_keyset from ...wallet.crud import get_keysets
from ...wallet.wallet import Wallet as Wallet 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): 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. Helper function that asks the user for an input to select which mint they want to load.
@@ -56,18 +107,18 @@ async def get_mint_wallet(ctx: Context, force_select: bool = False):
return mint_wallet 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. Helper function that prints the balances for each mint URL that we have tokens from.
""" """
# get balances per mint # 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 # 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()] keysets = [k for k, v in wallet.balance_per_keyset().items()]
for k in keysets: for k in keysets:
ks = await get_keyset(id=str(k), db=wallet.db) keysets_local = await get_keysets(id=str(k), db=wallet.db)
if ks and ks.mint_url != wallet.url: for kl in keysets_local:
if kl and kl.mint_url != wallet.url:
show_mints = True show_mints = True
# or we have a balance on more than one mint # or we have a balance on more than one 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(f"You have balances in {len(mint_balances)} mints:")
print("") print("")
for i, (k, v) in enumerate(mint_balances.items()): for i, (k, v) in enumerate(mint_balances.items()):
unit = Unit[str(v["unit"])]
print( print(
f"Mint {i+1}: Balance: {v['available']} sat (pending:" f"Mint {i+1}: Balance: {unit.str(int(v['available']))} (pending:"
f" {v['balance']-v['available']} sat) URL: {k}" f" {unit.str(int(v['balance'])-int(v['available']))}) URL: {k}"
) )
print("") print("")
@@ -93,7 +145,7 @@ async def verify_mint(mint_wallet: Wallet, url: str):
# dummy Wallet to check the database later # dummy Wallet to check the database later
# mint_wallet = Wallet(url, os.path.join(settings.cashu_dir, ctx.obj["WALLET_NAME"])) # 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 # 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: if mint_keysets is None:
# we encountered a new mint and ask for a user confirmation # we encountered a new mint and ask for a user confirmation
print("") print("")
@@ -107,4 +159,4 @@ async def verify_mint(mint_wallet: Wallet, url: str):
default=True, default=True,
) )
else: else:
logger.debug(f"We know keyset {mint_keysets.id} already") logger.debug(f"We know mint {url} already")

View File

@@ -167,8 +167,8 @@ async def store_keyset(
await (conn or db).execute( # type: ignore await (conn or db).execute( # type: ignore
""" """
INSERT INTO keysets INSERT INTO keysets
(id, mint_url, valid_from, valid_to, first_seen, active, public_keys) (id, mint_url, valid_from, valid_to, first_seen, active, public_keys, unit)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
keyset.id, keyset.id,
@@ -176,18 +176,19 @@ async def store_keyset(
keyset.valid_from or int(time.time()), keyset.valid_from or int(time.time()),
keyset.valid_to or int(time.time()), keyset.valid_to or int(time.time()),
keyset.first_seen or int(time.time()), keyset.first_seen or int(time.time()),
True, keyset.active,
keyset.serialize(), keyset.serialize(),
keyset.unit.name,
), ),
) )
async def get_keyset( async def get_keysets(
id: str = "", id: str = "",
mint_url: str = "", mint_url: str = "",
db: Optional[Database] = None, db: Optional[Database] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[WalletKeyset]: ) -> List[WalletKeyset]:
clauses = [] clauses = []
values: List[Any] = [] values: List[Any] = []
clauses.append("active = ?") clauses.append("active = ?")
@@ -202,14 +203,18 @@ async def get_keyset(
if clauses: if clauses:
where = f"WHERE {' AND '.join(clauses)}" where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone( # type: ignore row = await (conn or db).fetchall( # type: ignore
f""" f"""
SELECT * from keysets SELECT * from keysets
{where} {where}
""", """,
tuple(values), 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( async def store_lightning_invoice(

View File

@@ -10,7 +10,7 @@ from ..core.helpers import sum_proofs
from ..core.migrations import migrate_databases from ..core.migrations import migrate_databases
from ..core.settings import settings from ..core.settings import settings
from ..wallet import migrations from ..wallet import migrations
from ..wallet.crud import get_keyset from ..wallet.crud import get_keysets
from ..wallet.wallet import Wallet from ..wallet.wallet import Wallet
@@ -47,15 +47,16 @@ async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3):
mint_wallet = await Wallet.with_db( mint_wallet = await Wallet.with_db(
t.mint, os.path.join(settings.cashu_dir, wallet.name) t.mint, os.path.join(settings.cashu_dir, wallet.name)
) )
keysets = mint_wallet._get_proofs_keysets(t.proofs) keyset_ids = mint_wallet._get_proofs_keysets(t.proofs)
logger.debug(f"Keysets in tokens: {keysets}") logger.trace(f"Keysets in tokens: {keyset_ids}")
# loop over all keysets # loop over all keysets
for keyset in set(keysets): for keyset_id in set(keyset_ids):
await mint_wallet.load_mint() await mint_wallet.load_mint(keyset_id)
mint_wallet.unit = mint_wallet.keysets[keyset_id].unit
# redeem proofs of this keyset # 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) _, _ = 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): def serialize_TokenV2_to_TokenV3(tokenv2: TokenV2):
@@ -138,21 +139,21 @@ async def receive(
keyset_in_token = proofs[0].id keyset_in_token = proofs[0].id
assert keyset_in_token assert keyset_in_token
# we get the keyset from the db # we get the keyset from the db
mint_keysets = await get_keyset(id=keyset_in_token, db=wallet.db) mint_keysets = await get_keysets(id=keyset_in_token, db=wallet.db)
assert mint_keysets, Exception("we don't know this keyset") assert mint_keysets, Exception(f"we don't know this keyset: {keyset_in_token}")
assert mint_keysets.mint_url, Exception("we don't know this mint's URL") mint_keyset = mint_keysets[0]
assert mint_keyset.mint_url, Exception("we don't know this mint's URL")
# now we have the URL # now we have the URL
mint_wallet = await Wallet.with_db( mint_wallet = await Wallet.with_db(
mint_keysets.mint_url, mint_keyset.mint_url,
os.path.join(settings.cashu_dir, wallet.name), os.path.join(settings.cashu_dir, wallet.name),
) )
await mint_wallet.load_mint(keyset_in_token) await mint_wallet.load_mint(keyset_in_token)
_, _ = await mint_wallet.redeem(proofs) _, _ = 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 # reload main wallet so the balance updates
await wallet.load_proofs(reload=True) await wallet.load_proofs(reload=True)
wallet.status()
return wallet.available_balance return wallet.available_balance
@@ -224,5 +225,4 @@ async def send(
) )
print(token) print(token)
wallet.status()
return wallet.available_balance, token return wallet.available_balance, token

View File

@@ -1,5 +1,6 @@
import bolt11 import bolt11
from ...core.base import Amount, SpentState, Unit
from ...core.helpers import sum_promises from ...core.helpers import sum_promises
from ...core.settings import settings from ...core.settings import settings
from ...lightning.base import ( from ...lightning.base import (
@@ -54,25 +55,28 @@ class LightningWallet(Wallet):
Returns: Returns:
bool: True if successful 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" assert total_amount > 0, "amount is not positive"
if self.available_balance < total_amount: if self.available_balance < total_amount:
print("Error: Balance too low.") print("Error: Balance too low.")
return PaymentResponse(ok=False) return PaymentResponse(ok=False)
_, send_proofs = await self.split_to_send(self.proofs, total_amount) _, send_proofs = await self.split_to_send(self.proofs, total_amount)
try: 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: if resp.change:
fees_paid_sat = fee_reserve_sat - sum_promises(resp.change) fees_paid_sat = quote.fee_reserve - sum_promises(resp.change)
else: else:
fees_paid_sat = fee_reserve_sat fees_paid_sat = quote.fee_reserve
invoice_obj = bolt11.decode(pr) invoice_obj = bolt11.decode(pr)
return PaymentResponse( return PaymentResponse(
ok=True, ok=True,
checking_id=invoice_obj.payment_hash, checking_id=invoice_obj.payment_hash,
preimage=resp.preimage, preimage=resp.payment_preimage,
fee_msat=fees_paid_sat * 1000, fee=Amount(Unit.msat, fees_paid_sat),
) )
except Exception as e: except Exception as e:
print("Exception:", e) print("Exception:", e)
@@ -126,19 +130,15 @@ class LightningWallet(Wallet):
if not proofs: if not proofs:
return PaymentStatus(paid=False) # "proofs not fount (in db)" return PaymentStatus(paid=False) # "proofs not fount (in db)"
proofs_states = await self.check_proof_state(proofs) proofs_states = await self.check_proof_state(proofs)
if ( if not proofs_states:
not proofs_states
or not proofs_states.spendable
or not proofs_states.pending
):
return PaymentStatus(paid=False) # "states not fount" 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)" 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 # 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)" 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=False) # "failed (with check)"
return PaymentStatus(paid=None) # "undefined state" return PaymentStatus(paid=None) # "undefined state"
@@ -148,6 +148,4 @@ class LightningWallet(Wallet):
Returns: Returns:
int: balance in satoshis int: balance in satoshis
""" """
return StatusResponse( return StatusResponse(error_message=None, balance=self.available_balance * 1000)
error_message=None, balance_msat=self.available_balance * 1000
)

View File

@@ -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. Columns that store mint and melt id for proofs and invoices.
""" """
async with db.connect() as conn: 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 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 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") 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) # 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") await conn.execute("ALTER TABLE invoices RENAME COLUMN hash TO id")
# add column payment_hash # add column payment_hash
await conn.execute("ALTER TABLE invoices ADD COLUMN payment_hash TEXT") 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'")

View File

@@ -133,12 +133,9 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb):
return outputs return outputs
# if any of the proofs provided require SIG_ALL, we must provide it # if any of the proofs provided require SIG_ALL, we must provide it
if any( if any([
[ P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs
P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL ]):
for p in proofs
]
):
outputs = await self.add_p2pk_witnesses_to_outputs(outputs) outputs = await self.add_p2pk_witnesses_to_outputs(outputs)
return outputs return outputs
@@ -184,9 +181,9 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb):
return proofs return proofs
logger.debug("Spending conditions detected.") logger.debug("Spending conditions detected.")
# P2PK signatures # P2PK signatures
if all( if all([
[Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs] Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs
): ]):
logger.debug("P2PK redemption detected.") logger.debug("P2PK redemption detected.")
proofs = await self.add_p2pk_witnesses_to_proofs(proofs) proofs = await self.add_p2pk_witnesses_to_proofs(proofs)

View File

@@ -1,5 +1,7 @@
from typing import Protocol from typing import Protocol
import httpx
from ..core.crypto.secp import PrivateKey from ..core.crypto.secp import PrivateKey
from ..core.db import Database from ..core.db import Database
@@ -14,3 +16,11 @@ class SupportsDb(Protocol):
class SupportsKeysets(Protocol): class SupportsKeysets(Protocol):
keyset_id: str keyset_id: str
class SupportsHttpxClient(Protocol):
httpx: httpx.AsyncClient
class SupportsMintURL(Protocol):
url: str

View File

@@ -1,5 +1,6 @@
import base64 import base64
import hashlib import hashlib
import os
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from bip32 import BIP32 from bip32 import BIP32
@@ -73,8 +74,8 @@ class WalletSecrets(SupportsDb, SupportsKeysets):
self.seed = mnemo.to_seed(mnemonic_str) self.seed = mnemo.to_seed(mnemonic_str)
self.mnemonic = mnemonic_str self.mnemonic = mnemonic_str
logger.debug(f"Using seed: {self.seed.hex()}") # logger.debug(f"Using seed: {self.seed.hex()}")
logger.debug(f"Using mnemonic: {mnemonic_str}") # logger.debug(f"Using mnemonic: {mnemonic_str}")
# if no mnemonic was in the database, store the new one # if no mnemonic was in the database, store the new one
if ret_db is None: if ret_db is None:
@@ -92,20 +93,21 @@ class WalletSecrets(SupportsDb, SupportsKeysets):
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
async def _generate_secret(self, randombits=128) -> str: async def _generate_secret(self) -> str:
"""Returns base64 encoded deterministic random string. """Returns base64 encoded deterministic random string.
NOTE: This method should probably retire after `deterministic_secrets`. We are NOTE: This method should probably retire after `deterministic_secrets`. We are
deriving secrets from a counter but don't store the respective blinding factor. 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. We won't be able to restore any ecash generated with these secrets.
""" """
secret_counter = await bump_secret_derivation( # secret_counter = await bump_secret_derivation(db=self.db, keyset_id=keyset_id)
db=self.db, keyset_id=self.keyset_id # logger.trace(f"secret_counter: {secret_counter}")
) # s, _, _ = await self.generate_determinstic_secret(secret_counter, keyset_id)
logger.trace(f"secret_counter: {secret_counter}") # # return s.decode("utf-8")
s, _, _ = await self.generate_determinstic_secret(secret_counter) # return hashlib.sha256(s).hexdigest()
# 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( async def generate_determinstic_secret(
self, counter: int self, counter: int
@@ -116,11 +118,20 @@ class WalletSecrets(SupportsDb, SupportsKeysets):
""" """
assert self.bip32, "BIP32 not initialized yet." assert self.bip32, "BIP32 not initialized yet."
# integer keyset id modulo max number of bip32 child keys # integer keyset id modulo max number of bip32 child keys
keyest_id = int.from_bytes(base64.b64decode(self.keyset_id), "big") % ( try:
keyest_id_int = int.from_bytes(bytes.fromhex(self.keyset_id), "big") % (
2**31 - 1 2**31 - 1
) )
logger.trace(f"keyset id: {self.keyset_id} becomes {keyest_id}") except ValueError:
token_derivation_path = f"m/129372'/0'/{keyest_id}'/{counter}'" # 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 # for secret
secret_derivation_path = f"{token_derivation_path}/0" secret_derivation_path = f"{token_derivation_path}/0"
logger.trace(f"secret derivation path: {secret_derivation_path}") logger.trace(f"secret derivation path: {secret_derivation_path}")

File diff suppressed because it is too large Load Diff

View File

@@ -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)

262
poetry.lock generated
View File

@@ -2,24 +2,24 @@
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.0.0" version = "3.7.1"
description = "High level compatibility layer for multiple asynchronous event loop implementations" description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.7"
files = [ files = [
{file = "anyio-4.0.0-py3-none-any.whl", hash = "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f"}, {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"},
{file = "anyio-4.0.0.tar.gz", hash = "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a"}, {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"},
] ]
[package.dependencies] [package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} exceptiongroup = {version = "*", markers = "python_version < \"3.11\""}
idna = ">=2.8" idna = ">=2.8"
sniffio = ">=1.1" sniffio = ">=1.1"
[package.extras] [package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"] doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 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)"] trio = ["trio (<0.22)"]
[[package]] [[package]]
name = "asn1crypto" name = "asn1crypto"
@@ -104,33 +104,29 @@ files = [
[[package]] [[package]]
name = "black" name = "black"
version = "23.9.1" version = "23.11.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"},
{file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"},
{file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"},
{file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"},
{file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"},
{file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"},
{file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"},
{file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"},
{file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"},
{file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"},
{file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"},
{file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"},
{file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"},
{file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"},
{file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"},
{file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"},
{file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"},
{file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"},
{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"},
] ]
[package.dependencies] [package.dependencies]
@@ -169,13 +165,13 @@ secp256k1 = "*"
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2023.7.22" version = "2023.11.17"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
{file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
] ]
[[package]] [[package]]
@@ -402,34 +398,34 @@ toml = ["tomli"]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "41.0.4" version = "41.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"}, {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797"},
{file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"}, {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"}, {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"}, {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"}, {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"}, {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20"},
{file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"}, {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548"},
{file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"}, {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d"},
{file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"}, {file = "cryptography-41.0.5-cp37-abi3-win32.whl", hash = "sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936"},
{file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"}, {file = "cryptography-41.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"}, {file = "cryptography-41.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"}, {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"}, {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"}, {file = "cryptography-41.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"}, {file = "cryptography-41.0.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"}, {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"}, {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"}, {file = "cryptography-41.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"}, {file = "cryptography-41.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"}, {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"}, {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"}, {file = "cryptography-41.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84"},
{file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"}, {file = "cryptography-41.0.5.tar.gz", hash = "sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7"},
] ]
[package.dependencies] [package.dependencies]
@@ -497,13 +493,13 @@ tests = ["dj-database-url", "dj-email-url", "django-cache-url", "pytest"]
[[package]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
version = "1.1.3" version = "1.2.0"
description = "Backport of PEP 654 (exception groups)" description = "Backport of PEP 654 (exception groups)"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
{file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
] ]
[package.extras] [package.extras]
@@ -511,19 +507,20 @@ test = ["pytest (>=6)"]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.103.0" version = "0.104.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "fastapi-0.103.0-py3-none-any.whl", hash = "sha256:61ab72c6c281205dd0cbaccf503e829a37e0be108d965ac223779a8479243665"}, {file = "fastapi-0.104.1-py3-none-any.whl", hash = "sha256:752dc31160cdbd0436bb93bad51560b57e525cbb1d4bbf6f4904ceee75548241"},
{file = "fastapi-0.103.0.tar.gz", hash = "sha256:4166732f5ddf61c33e9fa4664f73780872511e0598d4d5434b1816dc1e6d9421"}, {file = "fastapi-0.104.1.tar.gz", hash = "sha256:e5e4540a7c5e1dcfbbcf5b903c234feddcdcd881f191977a1c5dfd917487e7ae"},
] ]
[package.dependencies] [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" 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" starlette = ">=0.27.0,<0.28.0"
typing-extensions = ">=4.5.0" typing-extensions = ">=4.8.0"
[package.extras] [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)"] 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]] [[package]]
name = "filelock" name = "filelock"
version = "3.12.4" version = "3.13.1"
description = "A platform independent file lock." description = "A platform independent file lock."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"},
{file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"},
] ]
[package.extras] [package.extras]
docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "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)"] 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.7.1)"] typing = ["typing-extensions (>=4.8)"]
[[package]] [[package]]
name = "h11" name = "h11"
@@ -571,40 +568,40 @@ files = [
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "0.18.0" version = "1.0.2"
description = "A minimal low-level HTTP client." description = "A minimal low-level HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
{file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
] ]
[package.dependencies] [package.dependencies]
anyio = ">=3.0,<5.0"
certifi = "*" certifi = "*"
h11 = ">=0.13,<0.15" h11 = ">=0.13,<0.15"
sniffio = "==1.*"
[package.extras] [package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.23.0)"]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.25.1" version = "0.25.2"
description = "The next generation HTTP client." description = "The next generation HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, {file = "httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"},
{file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, {file = "httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8"},
] ]
[package.dependencies] [package.dependencies]
anyio = "*" anyio = "*"
certifi = "*" certifi = "*"
httpcore = "*" httpcore = "==1.*"
idna = "*" idna = "*"
sniffio = "*" sniffio = "*"
socksio = {version = "==1.*", optional = true, markers = "extra == \"socks\""} socksio = {version = "==1.*", optional = true, markers = "extra == \"socks\""}
@@ -617,13 +614,13 @@ socks = ["socksio (==1.*)"]
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.5.30" version = "2.5.32"
description = "File identification library for Python" description = "File identification library for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "identify-2.5.30-py2.py3-none-any.whl", hash = "sha256:afe67f26ae29bab007ec21b03d4114f41316ab9dd15aa8736a167481e108da54"}, {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"},
{file = "identify-2.5.30.tar.gz", hash = "sha256:f302a4256a15c849b91cfcdcec052a8ce914634b2f77ae87dad29cd749f2d88d"}, {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"},
] ]
[package.extras] [package.extras]
@@ -631,13 +628,13 @@ license = ["ukkonen"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.4" version = "3.6"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
files = [ files = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
] ]
[[package]] [[package]]
@@ -721,38 +718,38 @@ files = [
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.6.0" version = "1.7.1"
description = "Optional static typing for Python" description = "Optional static typing for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "mypy-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:091f53ff88cb093dcc33c29eee522c087a438df65eb92acd371161c1f4380ff0"}, {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"},
{file = "mypy-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb7ff4007865833c470a601498ba30462b7374342580e2346bf7884557e40531"}, {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"},
{file = "mypy-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49499cf1e464f533fc45be54d20a6351a312f96ae7892d8e9f1708140e27ce41"}, {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"},
{file = "mypy-1.6.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4c192445899c69f07874dabda7e931b0cc811ea055bf82c1ababf358b9b2a72c"}, {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"},
{file = "mypy-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:3df87094028e52766b0a59a3e46481bb98b27986ed6ded6a6cc35ecc75bb9182"}, {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"},
{file = "mypy-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c8835a07b8442da900db47ccfda76c92c69c3a575872a5b764332c4bacb5a0a"}, {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"},
{file = "mypy-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24f3de8b9e7021cd794ad9dfbf2e9fe3f069ff5e28cb57af6f873ffec1cb0425"}, {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"},
{file = "mypy-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:856bad61ebc7d21dbc019b719e98303dc6256cec6dcc9ebb0b214b81d6901bd8"}, {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"},
{file = "mypy-1.6.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:89513ddfda06b5c8ebd64f026d20a61ef264e89125dc82633f3c34eeb50e7d60"}, {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"},
{file = "mypy-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f8464ed410ada641c29f5de3e6716cbdd4f460b31cf755b2af52f2d5ea79ead"}, {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"},
{file = "mypy-1.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:971104bcb180e4fed0d7bd85504c9036346ab44b7416c75dd93b5c8c6bb7e28f"}, {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"},
{file = "mypy-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab98b8f6fdf669711f3abe83a745f67f50e3cbaea3998b90e8608d2b459fd566"}, {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"},
{file = "mypy-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a69db3018b87b3e6e9dd28970f983ea6c933800c9edf8c503c3135b3274d5ad"}, {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"},
{file = "mypy-1.6.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:dccd850a2e3863891871c9e16c54c742dba5470f5120ffed8152956e9e0a5e13"}, {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"},
{file = "mypy-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:f8598307150b5722854f035d2e70a1ad9cc3c72d392c34fffd8c66d888c90f17"}, {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"},
{file = "mypy-1.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fea451a3125bf0bfe716e5d7ad4b92033c471e4b5b3e154c67525539d14dc15a"}, {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"},
{file = "mypy-1.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e28d7b221898c401494f3b77db3bac78a03ad0a0fff29a950317d87885c655d2"}, {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"},
{file = "mypy-1.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4b7a99275a61aa22256bab5839c35fe8a6887781862471df82afb4b445daae6"}, {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"},
{file = "mypy-1.6.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7469545380dddce5719e3656b80bdfbb217cfe8dbb1438532d6abc754b828fed"}, {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"},
{file = "mypy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:7807a2a61e636af9ca247ba8494031fb060a0a744b9fee7de3a54bed8a753323"}, {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"},
{file = "mypy-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2dad072e01764823d4b2f06bc7365bb1d4b6c2f38c4d42fade3c8d45b0b4b67"}, {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"},
{file = "mypy-1.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b19006055dde8a5425baa5f3b57a19fa79df621606540493e5e893500148c72f"}, {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"},
{file = "mypy-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eba8a7a71f0071f55227a8057468b8d2eb5bf578c8502c7f01abaec8141b2f"}, {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"},
{file = "mypy-1.6.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e0db37ac4ebb2fee7702767dfc1b773c7365731c22787cb99f507285014fcaf"}, {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"},
{file = "mypy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:c69051274762cccd13498b568ed2430f8d22baa4b179911ad0c1577d336ed849"}, {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"},
{file = "mypy-1.6.0-py3-none-any.whl", hash = "sha256:9e1589ca150a51d9d00bb839bfeca2f7a04f32cd62fad87a847bc0818e15d7dc"}, {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"},
{file = "mypy-1.6.0.tar.gz", hash = "sha256:4f3d27537abde1be6d5f2c96c29a454da333a2a271ae7d5bc7110e6d4b7beb3f"}, {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"},
] ]
[package.dependencies] [package.dependencies]
@@ -763,6 +760,7 @@ typing-extensions = ">=4.1.0"
[package.extras] [package.extras]
dmypy = ["psutil (>=4.0)"] dmypy = ["psutil (>=4.0)"]
install-types = ["pip"] install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"] reports = ["lxml"]
[[package]] [[package]]
@@ -792,13 +790,13 @@ setuptools = "*"
[[package]] [[package]]
name = "outcome" name = "outcome"
version = "1.2.0" version = "1.3.0.post0"
description = "Capture the outcome of Python function calls." description = "Capture the outcome of Python function calls."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "outcome-1.2.0-py2.py3-none-any.whl", hash = "sha256:c4ab89a56575d6d38a05aa16daeaa333109c1f96167aba8901ab18b6b5e0f7f5"}, {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"},
{file = "outcome-1.2.0.tar.gz", hash = "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672"}, {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"},
] ]
[package.dependencies] [package.dependencies]
@@ -828,13 +826,13 @@ files = [
[[package]] [[package]]
name = "platformdirs" 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\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"},
{file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"},
] ]
[package.extras] [package.extras]
@@ -1134,13 +1132,13 @@ types = ["typing-extensions"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.4.2" version = "7.4.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
{file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
] ]
[package.dependencies] [package.dependencies]
@@ -1517,19 +1515,19 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.24.5" version = "20.24.7"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, {file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"},
{file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, {file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"},
] ]
[package.dependencies] [package.dependencies]
distlib = ">=0.3.7,<1" distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4" filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<4" platformdirs = ">=3.9.1,<5"
[package.extras] [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)"] 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]] [[package]]
name = "wheel" name = "wheel"
version = "0.41.2" version = "0.41.3"
description = "A built-package format for Python" description = "A built-package format for Python"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "wheel-0.41.2-py3-none-any.whl", hash = "sha256:75909db2664838d015e3d9139004ee16711748a52c8f336b52882266540215d8"}, {file = "wheel-0.41.3-py3-none-any.whl", hash = "sha256:488609bc63a29322326e05560731bf7bfea8e48ad646e1f5e40d366607de0942"},
{file = "wheel-0.41.2.tar.gz", hash = "sha256:0c5ac5ff2afb79ac23ab82bab027a0be7b5dbcf2e54dc50efe4bf507de1f7985"}, {file = "wheel-0.41.3.tar.gz", hash = "sha256:4d4987ce51a49370ea65c0bfd2234e8ce80a12780820d9dc462597a6e60d0841"},
] ]
[package.extras] [package.extras]
@@ -1600,4 +1598,4 @@ pgsql = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.8.1" python-versions = "^3.8.1"
content-hash = "b2c312fd906aa18a26712039f700322c2c20889a95e1cd9af787df54d700b2ca" content-hash = "f7aa2919aca77aa4d1dfcba18c6fc9694a2cc1d5cfd60e7ec991a615251fa86e"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "cashu" name = "cashu"
version = "0.14.0" version = "0.15.0"
description = "Ecash wallet and mint" description = "Ecash wallet and mint"
authors = ["calle <callebtc@protonmail.com>"] authors = ["calle <callebtc@protonmail.com>"]
license = "MIT" license = "MIT"
@@ -11,7 +11,7 @@ SQLAlchemy = "^1.3.24"
click = "^8.1.7" click = "^8.1.7"
pydantic = "^1.10.2" pydantic = "^1.10.2"
bech32 = "^1.2.0" bech32 = "^1.2.0"
fastapi = "0.103.0" fastapi = "^0.104.1"
environs = "^9.5.0" environs = "^9.5.0"
uvicorn = "0.23.2" uvicorn = "0.23.2"
loguru = "^0.7.0" loguru = "^0.7.0"
@@ -31,13 +31,15 @@ httpx = {extras = ["socks"], version = "^0.25.1"}
bip32 = "^3.4" bip32 = "^3.4"
mnemonic = "^0.20" mnemonic = "^0.20"
bolt11 = "^2.0.5" bolt11 = "^2.0.5"
black = "23.11.0"
pre-commit = "^3.5.0"
[tool.poetry.extras] [tool.poetry.extras]
pgsql = ["psycopg2-binary"] pgsql = ["psycopg2-binary"]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
mypy = "^1.5.1" mypy = "^1.5.1"
black = "^23.7.0" black = "^23.11.0"
pytest-asyncio = "^0.21.1" pytest-asyncio = "^0.21.1"
pytest-cov = "^4.0.0" pytest-cov = "^4.0.0"
pytest = "^7.4.0" pytest = "^7.4.0"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]}
setuptools.setup( setuptools.setup(
name="cashu", name="cashu",
version="0.14.1", version="0.15.0",
description="Ecash wallet and mint for Bitcoin Lightning", description="Ecash wallet and mint for Bitcoin Lightning",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",

View File

@@ -1,3 +1,4 @@
import asyncio
import multiprocessing import multiprocessing
import os import os
import shutil import shutil
@@ -9,35 +10,50 @@ import pytest_asyncio
import uvicorn import uvicorn
from uvicorn import Config, Server from uvicorn import Config, Server
from cashu.core.base import Method, Unit
from cashu.core.db import Database from cashu.core.db import Database
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.lightning.fake import FakeWallet
from cashu.mint import migrations as migrations_mint 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 from cashu.mint.ledger import Ledger
SERVER_PORT = 3337 SERVER_PORT = 3337
SERVER_ENDPOINT = f"http://localhost:{SERVER_PORT}" SERVER_ENDPOINT = f"http://localhost:{SERVER_PORT}"
settings.debug = True settings.debug = False
settings.cashu_dir = "./test_data/" settings.cashu_dir = "./test_data/"
settings.mint_host = "localhost" settings.mint_host = "localhost"
settings.mint_port = SERVER_PORT settings.mint_port = SERVER_PORT
settings.mint_host = "0.0.0.0" settings.mint_host = "0.0.0.0"
settings.mint_listen_port = SERVER_PORT settings.mint_listen_port = SERVER_PORT
settings.mint_url = SERVER_ENDPOINT settings.mint_url = SERVER_ENDPOINT
settings.lightning = True
settings.tor = False settings.tor = False
settings.wallet_unit = "sat"
settings.mint_lightning_backend = settings.mint_lightning_backend or "FakeWallet" 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_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_private_key = "TEST_PRIVATE_KEY"
settings.mint_max_balance = 0 settings.mint_max_balance = 0
assert "test" in settings.cashu_dir
shutil.rmtree(settings.cashu_dir, ignore_errors=True) shutil.rmtree(settings.cashu_dir, ignore_errors=True)
Path(settings.cashu_dir).mkdir(parents=True, exist_ok=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): class UvicornServer(multiprocessing.Process):
def __init__(self, config: Config): def __init__(self, config: Config):
@@ -52,33 +68,7 @@ class UvicornServer(multiprocessing.Process):
self.server.run() self.server.run()
@pytest_asyncio.fixture(scope="function") # # This fixture is used for tests that require API access to the mint
async def ledger():
async def start_mint_init(ledger: Ledger):
await migrate_databases(ledger.db, migrations_mint)
if settings.mint_cache_secrets:
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")
if os.path.exists(db_file):
os.remove(db_file)
ledger = Ledger(
db=Database(database_name, settings.mint_database),
seed=settings.mint_private_key,
derivation_path=settings.mint_derivation_path,
lightning=FakeWallet(),
crud=LedgerCrud(),
)
await start_mint_init(ledger)
yield ledger
@pytest.fixture(autouse=True, scope="session") @pytest.fixture(autouse=True, scope="session")
def mint(): def mint():
config = uvicorn.Config( config = uvicorn.Config(
@@ -92,3 +82,33 @@ def mint():
time.sleep(1) time.sleep(1)
yield server yield server
server.stop() 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):
await migrate_databases(ledger.db, migrations_mint)
if settings.mint_cache_secrets:
await ledger.load_used_proofs()
await ledger.init_keysets()
if not settings.mint_database.startswith("postgres"):
# clear sqlite database
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("mint", settings.mint_database),
seed=settings.mint_private_key,
derivation_path=settings.mint_derivation_path,
backends=backends,
crud=LedgerCrudSqlite(),
)
await start_mint_init(ledger)
yield ledger
print("teardown")

View File

@@ -29,7 +29,7 @@ wallet_class = getattr(wallets_module, settings.mint_lightning_backend)
WALLET = wallet_class() WALLET = wallet_class()
is_fake: bool = WALLET.__class__.__name__ == "FakeWallet" is_fake: bool = WALLET.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake is_regtest: bool = not is_fake
is_deprecated_api_only = settings.debug_mint_only_deprecated
docker_lightning_cli = [ docker_lightning_cli = [
"docker", "docker",

View File

@@ -1,4 +1,6 @@
import asyncio import asyncio
import base64
import json
from typing import Tuple from typing import Tuple
import pytest import pytest
@@ -27,8 +29,9 @@ def get_bolt11_and_invoice_id_from_invoice_command(output: str) -> Tuple[str, st
async def init_wallet(): async def init_wallet():
settings.debug = False
wallet = await Wallet.with_db( wallet = await Wallet.with_db(
url=settings.mint_host, url=settings.mint_url,
db="test_data/test_cli_wallet", db="test_data/test_cli_wallet",
name="wallet", name="wallet",
) )
@@ -56,7 +59,7 @@ def test_info_with_mint(cli_prefix):
[*cli_prefix, "info", "--mint"], [*cli_prefix, "info", "--mint"],
) )
assert result.exception is None assert result.exception is None
print("INFO -M") print("INFO --MINT")
print(result.output) print(result.output)
assert "Mint name" in result.output assert "Mint name" in result.output
assert result.exit_code == 0 assert result.exit_code == 0
@@ -69,7 +72,7 @@ def test_info_with_mnemonic(cli_prefix):
[*cli_prefix, "info", "--mnemonic"], [*cli_prefix, "info", "--mnemonic"],
) )
assert result.exception is None assert result.exception is None
print("INFO -M") print("INFO --MNEMONIC")
print(result.output) print(result.output)
assert "Mnemonic" in result.output assert "Mnemonic" in result.output
assert result.exit_code == 0 assert result.exit_code == 0
@@ -177,7 +180,7 @@ def test_send(mint, cli_prefix):
[*cli_prefix, "send", "10"], [*cli_prefix, "send", "10"],
) )
assert result.exception is None assert result.exception is None
print(result.output) print("test_send", result.output)
token_str = result.output.split("\n")[0] token_str = result.output.split("\n")[0]
assert "cashuA" in token_str, "output does not have a token" assert "cashuA" in token_str, "output does not have a token"
token = TokenV3.deserialize(token_str) token = TokenV3.deserialize(token_str)
@@ -191,7 +194,7 @@ def test_send_with_dleq(mint, cli_prefix):
[*cli_prefix, "send", "10", "--dleq"], [*cli_prefix, "send", "10", "--dleq"],
) )
assert result.exception is None assert result.exception is None
print(result.output) print("test_send_with_dleq", result.output)
token_str = result.output.split("\n")[0] token_str = result.output.split("\n")[0]
assert "cashuA" in token_str, "output does not have a token" assert "cashuA" in token_str, "output does not have a token"
token = TokenV3.deserialize(token_str) token = TokenV3.deserialize(token_str)
@@ -205,7 +208,7 @@ def test_send_legacy(mint, cli_prefix):
[*cli_prefix, "send", "10", "--legacy"], [*cli_prefix, "send", "10", "--legacy"],
) )
assert result.exception is None assert result.exception is None
print(result.output) print("test_send_legacy", result.output)
# this is the legacy token in the output # this is the legacy token in the output
token_str = result.output.split("\n")[4] token_str = result.output.split("\n")[4]
assert token_str.startswith("eyJwcm9v"), "output is not as expected" 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 assert result.exception is None
print("SEND") print("SEND")
print(result.output) print("test_send_without_split", result.output)
assert "cashuA" in result.output, "output does not have a token" 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): def test_receive_tokenv3(mint, cli_prefix):
runner = CliRunner() runner = CliRunner()
token = ( token = "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjAwOWExZjI5MzI1M2U0MWUiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICI0NzlkY2E0MzUzNzU4MTM4N2Q1ODllMDU1MGY0Y2Q2MjFmNjE0MDM1MGY5M2Q4ZmI1OTA2YjJlMGRiNmRjYmI3IiwgIkMiOiAiMDM1MGQ0ZmI0YzdiYTMzNDRjMWRjYWU1ZDExZjNlNTIzZGVkOThmNGY4ODdkNTQwZmYyMDRmNmVlOWJjMjkyZjQ1In0sIHsiaWQiOiAiMDA5YTFmMjkzMjUzZTQxZSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogIjZjNjAzNDgwOGQyNDY5N2IyN2YxZTEyMDllNjdjNjVjNmE2MmM2Zjc3NGI4NWVjMGQ5Y2Y3MjE0M2U0NWZmMDEiLCAiQyI6ICIwMjZkNDlhYTE0MmFlNjM1NWViZTJjZGQzYjFhOTdmMjE1MDk2NTlkMDE3YWU0N2FjNDY3OGE4NWVkY2E4MGMxYmQifV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzNyJ9XX0=" # noqa
"cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIld6TEF2VW53SDlRaFYwQU1rMy1oYWciLC"
"AiQyI6ICIwMmZlMzUxYjAyN2FlMGY1ZDkyN2U2ZjFjMTljMjNjNTc3NzRhZTI2M2UyOGExN2E2MTUxNjY1ZjU3NWNhNjMyNWMifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW"
"1vdW50IjogOCwgInNlY3JldCI6ICJDamFTeTcyR2dVOGwzMGV6bE5zZnVBIiwgIkMiOiAiMDNjMzM0OTJlM2ZlNjI4NzFhMWEzMDhiNWUyYjVhZjBkNWI1Mjk5YzI0YmVkNDI2Zj"
"Q1YzZmNDg5N2QzZjc4NGQ5In1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzcifV19"
)
result = runner.invoke( result = runner.invoke(
cli, 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 # 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 # already and have the mint URL in the db
runner = CliRunner() runner = CliRunner()
token = ( token_dict = {
"cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIi1oM0ZXMFFoX1FYLW9ac1V2c0RuNlEiLC" "token": [
"AiQyI6ICIwMzY5Mzc4MzdlYjg5ZWI4NjMyNWYwOWUyOTIxMWQxYTI4OTRlMzQ2YmM1YzQwZTZhMThlNTk5ZmVjNjEwOGRmMGIifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW" {
"1vdW50IjogOCwgInNlY3JldCI6ICI3d0VhNUgzZGhSRGRNZl94c1k3c3JnIiwgIkMiOiAiMDJiZmZkM2NlZDkxNjUyMzcxMDg2NjQxMzJiMjgxYjBhZjY1ZTNlZWVkNTY3MmFkZj" "proofs": [
"M0Y2VhNzE5ODhhZWM1NWI1In1dfV19" {
) "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( result = runner.invoke(
cli, cli,
[ [
@@ -273,18 +288,37 @@ def test_receive_tokenv3_no_mint(mint, cli_prefix):
], ],
) )
assert result.exception is None assert result.exception is None
print("RECEIVE")
print(result.output) print(result.output)
def test_receive_tokenv2(mint, cli_prefix): def test_receive_tokenv2(mint, cli_prefix):
runner = CliRunner() runner = CliRunner()
token = ( token_dict = {
"eyJwcm9vZnMiOiBbeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICJhUmREbzlFdW9yZUVfOW90enRNVVpnIiwgIkMiOiAiMDNhMzY5ZmUy" "proofs": [
"N2IxYmVmOTg4MzA3NDQyN2RjMzc1NmU0NThlMmMwYjQ1NWMwYmVmZGM4ZjVmNTA3YmM5MGQxNmU3In0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3VudCI6IDgsICJzZWNy" {
"ZXQiOiAiTEZQbFp6Ui1MWHFfYXFDMGhUeDQyZyIsICJDIjogIjAzNGNiYzQxYWY0ODIxMGFmNjVmYjVjOWIzOTNkMjhmMmQ5ZDZhOWE5MzI2YmI3MzQ2YzVkZmRmMTU5MDk1MzI2" "id": "009a1f293253e41e",
"YyJ9XSwgIm1pbnRzIjogW3sidXJsIjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzNyIsICJpZHMiOiBbIjFjQ05JQVoyWC93MSJdfV19" "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( result = runner.invoke(
cli, cli,
[*cli_prefix, "receive", token], [*cli_prefix, "receive", token],
@@ -296,11 +330,25 @@ def test_receive_tokenv2(mint, cli_prefix):
def test_receive_tokenv1(mint, cli_prefix): def test_receive_tokenv1(mint, cli_prefix):
runner = CliRunner() runner = CliRunner()
token = ( token_dict = [
"W3siaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3VudCI6IDIsICJzZWNyZXQiOiAiRnVsc2dzMktQV1FMcUlLX200SzgwQSIsICJDIjogIjAzNTc4OThlYzlhMjIxN2VhYWIx" {
"ZDc3YmM1Mzc2OTUwMjJlMjU2YTljMmMwNjc0ZDJlM2FiM2JiNGI0ZDMzMWZiMSJ9LCB7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogInJlRDBD" "id": "009a1f293253e41e",
"azVNS2xBTUQ0dWk2OEtfbEEiLCAiQyI6ICIwMjNkODNkNDE0MDU0NWQ1NTg4NjUyMzU5YjJhMjFhODljODY1ZGIzMzAyZTkzMTZkYTM5NjA0YTA2ZDYwYWQzOGYifV0=" "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( result = runner.invoke(
cli, cli,
[*cli_prefix, "receive", token], [*cli_prefix, "receive", token],

View File

@@ -9,7 +9,7 @@ def test_get_output_split():
assert amount_split(13) == [1, 4, 8] assert amount_split(13) == [1, 4, 8]
def test_tokenv3_get_amount(): def test_tokenv3_deserialize_get_attributes():
token_str = ( token_str = (
"cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci"
"LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW"
@@ -18,16 +18,6 @@ def test_tokenv3_get_amount():
) )
token = TokenV3.deserialize(token_str) token = TokenV3.deserialize(token_str)
assert token.get_amount() == 10 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 assert len(token.get_proofs()) == 2
@@ -117,6 +107,43 @@ def test_tokenv3_deserialize_with_memo():
assert token.memo == "Test 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(): def test_calculate_number_of_blank_outputs():
# Example from NUT-08 specification. # Example from NUT-08 specification.
fee_reserve_sat = 1000 fee_reserve_sat = 1000

View File

@@ -2,11 +2,12 @@ from typing import List
import pytest 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.crypto.b_dhke import step1_alice
from cashu.core.helpers import calculate_number_of_blank_outputs from cashu.core.helpers import calculate_number_of_blank_outputs
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from tests.helpers import pay_if_regtest
async def assert_err(f, msg): 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
assert ( assert (
ledger.keyset.public_keys[1].serialize().hex() ledger.keyset.public_keys[1].serialize().hex()
== "03190ebc0c3e2726a5349904f572a2853ea021b0128b269b8b6906501d262edaa8" == "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
) )
assert ( assert (
ledger.keyset.public_keys[2 ** (settings.max_order - 1)].serialize().hex() 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
assert ( assert (
ledger.keyset.private_keys[1].serialize() ledger.keyset.private_keys[1].serialize()
== "67de62e1bf8b5ccf88dbad6768b7d13fa0f41433b0a89caf915039505f2e00a7" == "8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d"
) )
assert ( assert (
ledger.keyset.private_keys[2 ** (settings.max_order - 1)].serialize() ledger.keyset.private_keys[2 ** (settings.max_order - 1)].serialize()
== "3b1340c703b02028a11025302d2d9e68d2a6dd721ab1a2770f0942d15eacb8d0" == "b0477644cb3d82ffcc170bc0a76e0409727232e87c5ae51d64a259936228c7be"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_keysets(ledger: Ledger): async def test_keysets(ledger: Ledger):
assert len(ledger.keysets.keysets) assert len(ledger.keysets)
assert len(ledger.keysets.get_ids()) assert len(list(ledger.keysets.keys()))
assert ledger.keyset.id == "1cCNIAZ2X/w1" 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 @pytest.mark.asyncio
@@ -66,33 +78,37 @@ async def test_get_keyset(ledger: Ledger):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mint(ledger: Ledger): 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 = [ blinded_messages_mock = [
BlindedMessage( BlindedMessage(
amount=8, amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", 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 len(promises)
assert promises[0].amount == 8 assert promises[0].amount == 8
assert ( assert (
promises[0].C_ promises[0].C_
== "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15" == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mint_invalid_blinded_message(ledger: Ledger): 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 = [ blinded_messages_mock_invalid_key = [
BlindedMessage( BlindedMessage(
amount=8, amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bff02d7fa2365379b0840afe249a7a9d71237", B_="02634a2c2b34bec9e8a4aba4361f6bff02d7fa2365379b0840afe249a7a9d71237",
id="009a1f293253e41e",
) )
] ]
await assert_err( 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", "invalid public key",
) )
@@ -103,14 +119,16 @@ async def test_generate_promises(ledger: Ledger):
BlindedMessage( BlindedMessage(
amount=8, amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
id="009a1f293253e41e",
) )
] ]
promises = await ledger._generate_promises(blinded_messages_mock) promises = await ledger._generate_promises(blinded_messages_mock)
assert ( assert (
promises[0].C_ promises[0].C_
== "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15" == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e"
) )
assert promises[0].amount == 8 assert promises[0].amount == 8
assert promises[0].id == "009a1f293253e41e"
# DLEQ proof present # DLEQ proof present
assert promises[0].dleq assert promises[0].dleq
@@ -118,6 +136,55 @@ async def test_generate_promises(ledger: Ledger):
assert promises[0].dleq.e 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_
== "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e"
)
assert promises[0].amount == 8
assert promises[0].id == "eGnEWtdJ0PIM"
# 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_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 @pytest.mark.asyncio
async def test_generate_change_promises(ledger: Ledger): async def test_generate_change_promises(ledger: Ledger):
# Example slightly adapted from NUT-08 because we want to ensure the dynamic change # 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 invoice_amount = 100_000
fee_reserve = 2_000 fee_reserve = 2_000
total_provided = invoice_amount + fee_reserve 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_promises = 7 # Amounts = [4, 8, 32, 64, 256, 512, 1024]
expected_returned_fees = 1900 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) n_blank_outputs = calculate_number_of_blank_outputs(fee_reserve)
blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)] blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)]
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( 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 assert len(promises) == expected_returned_promises
@@ -151,7 +223,7 @@ async def test_generate_change_promises_legacy_wallet(ledger: Ledger):
invoice_amount = 100_000 invoice_amount = 100_000
fee_reserve = 2_000 fee_reserve = 2_000
total_provided = invoice_amount + fee_reserve 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_promises = 4 # Amounts = [64, 256, 512, 1024]
expected_returned_fees = 1856 expected_returned_fees = 1856
@@ -159,11 +231,16 @@ async def test_generate_change_promises_legacy_wallet(ledger: Ledger):
n_blank_outputs = 4 n_blank_outputs = 4
blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)] blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)]
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( 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 assert len(promises) == expected_returned_promises
@@ -193,9 +270,9 @@ async def test_get_balance(ledger: Ledger):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_maximum_balance(ledger: Ledger): async def test_maximum_balance(ledger: Ledger):
settings.mint_max_balance = 1000 settings.mint_max_balance = 1000
invoice, id = await ledger.request_mint(8) await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat"))
await assert_err( await assert_err(
ledger.request_mint(8000), ledger.mint_quote(PostMintQuoteRequest(amount=8000, unit="sat")),
"Mint has reached maximum balance.", "Mint has reached maximum balance.",
) )
settings.mint_max_balance = 0 settings.mint_max_balance = 0

View File

@@ -1,71 +1,371 @@
import bolt11
import httpx import httpx
import pytest 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" 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 @pytest.mark.asyncio
async def test_info(ledger): @pytest.mark.skipif(
response = httpx.get(f"{BASE_URL}/info") 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 response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.pubkey
assert response.json()["pubkey"] == ledger.pubkey.serialize().hex() assert response.json()["pubkey"] == ledger.pubkey.serialize().hex()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_keys(ledger): @pytest.mark.skipif(
response = httpx.get(f"{BASE_URL}/keys") 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.status_code == 200, f"{response.url} {response.status_code}"
assert response.json() == { assert ledger.keyset.public_keys
str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items() 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 @pytest.mark.asyncio
async def test_api_keysets(ledger): @pytest.mark.skipif(
response = httpx.get(f"{BASE_URL}/keysets") 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.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 @pytest.mark.asyncio
async def test_api_keyset_keys(ledger): @pytest.mark.skipif(
response = httpx.get( settings.debug_mint_only_deprecated,
f"{BASE_URL}/keys/{'1cCNIAZ2X/w1'.replace('/', '_').replace('+', '-')}" 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 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
@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}" assert response.status_code == 200, f"{response.url} {response.status_code}"
assert response.json() == { result = response.json()
str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items() assert result["quote"]
} assert result["request"]
invoice = bolt11.decode(result["request"])
assert invoice.amount_msat == 100 * 1000
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_mint_validation(ledger): @pytest.mark.skipif(
response = httpx.get(f"{BASE_URL}/mint?amount=-21") settings.debug_mint_only_deprecated,
assert "detail" in response.json() reason="settings.debug_mint_only_deprecated is set",
response = httpx.get(f"{BASE_URL}/mint?amount=0") )
assert "detail" in response.json() async def test_mint(ledger: Ledger, wallet: Wallet):
response = httpx.get(f"{BASE_URL}/mint?amount=2100000000000001") invoice = await wallet.request_mint(64)
assert "detail" in response.json() pay_if_regtest(invoice.bolt11)
response = httpx.get(f"{BASE_URL}/mint?amount=1") quote_id = invoice.id
assert "detail" not in response.json() 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]
@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)
response = httpx.post( response = httpx.post(
f"{BASE_URL}/check", 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
@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}/v1/checkstate",
json=payload.dict(), json=payload.dict(),
) )
assert response.status_code == 200, f"{response.url} {response.status_code}" assert response.status_code == 200, f"{response.url} {response.status_code}"
states = CheckSpendableResponse.parse_obj(response.json()) response = PostCheckStateResponse.parse_obj(response.json())
assert states.spendable assert response
assert len(states.spendable) == 2 assert len(response.states) == 2
assert states.pending assert response.states[0].state == SpentState.unspent
assert len(states.pending) == 2

View File

@@ -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

View File

@@ -1,11 +1,13 @@
import pytest import pytest
import pytest_asyncio 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.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet1
from tests.conftest import SERVER_ENDPOINT 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): async def assert_err(f, msg):
@@ -20,36 +22,120 @@ async def assert_err(f, msg):
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet1(mint): async def wallet1(ledger: Ledger):
wallet1 = await Wallet1.with_db( wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT, url=SERVER_ENDPOINT,
db="test_data/wallet1", db="test_data/wallet1",
name="wallet1", name="wallet1",
) )
await wallet1.load_mint() await wallet1.load_mint()
wallet1.status()
yield wallet1 yield wallet1
@pytest.mark.asyncio @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 # mint twice so we have enough to pay the second invoice back
invoice = await wallet1.request_mint(64) invoice = await wallet1.request_mint(128)
pay_if_regtest(invoice.bolt11) await wallet1.mint(128, id=invoice.id)
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)
assert wallet1.balance == 128 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 not melt_quote.paid
assert melt_fees == fee_reserve_sat 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) 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")
)
await ledger.melt(send_proofs, invoice2.bolt11, outputs=None) 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"
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 @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 # make sure we can still spend our tokens
keep_proofs, send_proofs = await wallet1.split(inputs, 10) keep_proofs, send_proofs = await wallet1.split(inputs, 10)
print(keep_proofs, send_proofs)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_split_twice_with_same_outputs(wallet1: Wallet, ledger: Ledger): async def test_split_twice_with_same_outputs(wallet1: Wallet, ledger: Ledger):
invoice = await wallet1.request_mint(128) invoice = await wallet1.request_mint(128)
pay_if_regtest(invoice.bolt11) 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] inputs1 = wallet1.proofs[:1]
inputs2 = 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) len(output_amounts)
) )
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) 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 # now try to mint with the same outputs again
invoice2 = await wallet1.request_mint(128) invoice2 = await wallet1.request_mint(128)
pay_if_regtest(invoice2.bolt11) pay_if_regtest(invoice2.bolt11)
await assert_err( await assert_err(
ledger.mint(outputs, id=invoice2.id), ledger.mint(outputs=outputs, quote_id=invoice2.id),
"outputs have already been signed before.", "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 # we use the outputs once for minting
invoice2 = await wallet1.request_mint(128) invoice2 = await wallet1.request_mint(128)
pay_if_regtest(invoice2.bolt11) 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 # 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( 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.", "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 @pytest.mark.asyncio
async def test_check_proof_state(wallet1: Wallet, ledger: Ledger): async def test_check_proof_state(wallet1: Wallet, ledger: Ledger):
invoice = await wallet1.request_mint(64) 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) keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, 10)
spendable, pending = await ledger.check_proof_state(proofs=send_proofs) proof_states = await ledger.check_proofs_state(
assert sum(spendable) == len(send_proofs) secrets=[p.secret for p in send_proofs]
assert sum(pending) == 0 )
assert all([p.state.value == "UNSPENT" for p in proof_states])

View File

@@ -1,6 +1,4 @@
import copy import copy
import shutil
from pathlib import Path
from typing import List, Union from typing import List, Union
import pytest import pytest
@@ -10,12 +8,12 @@ from cashu.core.base import Proof
from cashu.core.errors import CashuError, KeysetNotFoundError from cashu.core.errors import CashuError, KeysetNotFoundError
from cashu.core.helpers import sum_proofs from cashu.core.helpers import sum_proofs
from cashu.core.settings import settings 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
from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2 from cashu.wallet.wallet import Wallet as Wallet2
from tests.conftest import SERVER_ENDPOINT 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]): async def assert_err(f, msg: Union[str, CashuError]):
@@ -56,47 +54,32 @@ async def wallet1(mint):
name="wallet1", name="wallet1",
) )
await wallet1.load_mint() await wallet1.load_mint()
wallet1.status()
yield wallet1 yield wallet1
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet2(mint): async def wallet2():
wallet2 = await Wallet2.with_db( wallet2 = await Wallet2.with_db(
url=SERVER_ENDPOINT, url=SERVER_ENDPOINT,
db="test_data/wallet2", db="test_data/wallet2",
name="wallet2", name="wallet2",
) )
await wallet2.load_mint() await wallet2.load_mint()
wallet2.status()
yield wallet2 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 @pytest.mark.asyncio
async def test_get_keys(wallet1: Wallet): async def test_get_keys(wallet1: Wallet):
assert wallet1.keysets[wallet1.keyset_id].public_keys assert wallet1.keysets[wallet1.keyset_id].public_keys
assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order 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 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 isinstance(keyset.id, str)
assert len(keyset.id) > 0 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 wallet1.keysets[wallet1.keyset_id].public_keys
assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order 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 # 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 # gets the keys of a specific keyset
assert keys1.id is not None assert keyset.id is not None
assert keys1.public_keys is not None assert keyset.public_keys is not None
keys2 = await wallet1._get_keys_of_keyset(wallet1.url, keys1.id) keys2 = await wallet1._get_keys_of_keyset(keyset.id)
assert keys2.public_keys is not None 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 @pytest.mark.asyncio
@@ -130,32 +114,39 @@ async def test_get_keyset_from_db(wallet1: Wallet):
assert keyset1.id == keyset2.id assert keyset1.id == keyset2.id
# load it directly from the db # load it directly from the db
keyset3 = await get_keyset(db=wallet1.db, id=keyset1.id) keysets_local = await get_keysets(db=wallet1.db, id=keyset1.id)
assert keyset3 assert keysets_local[0]
keyset3 = keysets_local[0]
assert keyset1.public_keys == keyset3.public_keys assert keyset1.public_keys == keyset3.public_keys
assert keyset1.id == keyset3.id assert keyset1.id == keyset3.id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_info(wallet1: Wallet): async def test_get_info(wallet1: Wallet):
info = await wallet1._get_info(wallet1.url) info = await wallet1._get_info()
assert info.name assert info.name
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_nonexistent_keyset(wallet1: Wallet): async def test_get_nonexistent_keyset(wallet1: Wallet):
await assert_err( await assert_err(
wallet1._get_keys_of_keyset(wallet1.url, "nonexistent"), wallet1._get_keys_of_keyset("nonexistent"),
KeysetNotFoundError(), KeysetNotFoundError(),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_keyset_ids(wallet1: Wallet): async def test_get_keyset_ids(wallet1: Wallet):
keyset = await wallet1._get_keyset_ids(wallet1.url) keysets = await wallet1._get_keyset_ids()
assert isinstance(keyset, list) assert isinstance(keysets, list)
assert len(keyset) > 0 assert len(keysets) > 0
assert keyset[-1] == wallet1.keyset_id 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 @pytest.mark.asyncio
@@ -181,9 +172,9 @@ async def test_mint(wallet1: Wallet):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mint_amounts(wallet1: Wallet): async def test_mint_amounts(wallet1: Wallet):
"""Mint predefined amounts""" """Mint predefined amounts"""
invoice = await wallet1.request_mint(64)
pay_if_regtest(invoice.bolt11)
amts = [1, 1, 1, 2, 2, 4, 16] 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) await wallet1.mint(amount=sum(amts), split=amts, id=invoice.id)
assert wallet1.balance == 27 assert wallet1.balance == 27
assert wallet1.proof_amounts == amts assert wallet1.proof_amounts == amts
@@ -192,9 +183,11 @@ async def test_mint_amounts(wallet1: Wallet):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mint_amounts_wrong_sum(wallet1: Wallet): async def test_mint_amounts_wrong_sum(wallet1: Wallet):
"""Mint predefined amounts""" """Mint predefined amounts"""
amts = [1, 1, 1, 2, 2, 4, 16] amts = [1, 1, 1, 2, 2, 4, 16]
invoice = await wallet1.request_mint(sum(amts))
await assert_err( 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", "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): async def test_mint_amounts_wrong_order(wallet1: Wallet):
"""Mint amount that is not part in 2^n""" """Mint amount that is not part in 2^n"""
amts = [1, 2, 3] amts = [1, 2, 3]
invoice = await wallet1.request_mint(sum(amts))
await assert_err( 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}.", f"Can only mint amounts with 2^n up to {2**settings.max_order}.",
) )
@@ -257,33 +251,45 @@ async def test_split_more_than_balance(wallet1: Wallet):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_melt(wallet1: Wallet): async def test_melt(wallet1: Wallet):
# mint twice so we have enough to pay the second invoice back # mint twice so we have enough to pay the second invoice back
invoice = await wallet1.request_mint(64) topup_invoice = await wallet1.request_mint(128)
pay_if_regtest(invoice.bolt11) pay_if_regtest(topup_invoice.bolt11)
await wallet1.mint(64, id=invoice.id) await wallet1.mint(128, id=topup_invoice.id)
invoice = await wallet1.request_mint(64)
pay_if_regtest(invoice.bolt11)
await wallet1.mint(64, id=invoice.id)
assert wallet1.balance == 128 assert wallet1.balance == 128
total_amount, fee_reserve_sat = await wallet1.get_pay_amount_with_fees( invoice_payment_request = ""
invoice.bolt11 invoice_payment_hash = ""
)
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)
if is_regtest: if is_regtest:
invoice_dict = get_real_invoice(64) invoice_dict = get_real_invoice(64)
invoice_to_pay = invoice_dict["payment_request"]
invoice_payment_hash = str(invoice_dict["r_hash"]) 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( 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,
) )
if is_regtest:
assert melt_response.change, "No change returned" assert melt_response.change, "No change returned"
assert len(melt_response.change) == 1, "More than one 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 # NOTE: we assume that we will get a token back from the same keyset as the ones we melted
@@ -292,7 +298,7 @@ async def test_melt(wallet1: Wallet):
assert melt_response.change[0].id == send_proofs[0].id, "Wrong keyset returned" 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 # 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( invoice_db = await get_lightning_invoice(
db=wallet1.db, payment_hash=invoice_payment_hash, out=True 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" 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 # 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" assert wallet1.balance == 64, "Wrong balance"
@@ -368,23 +374,23 @@ async def test_send_and_redeem(wallet1: Wallet, wallet2: Wallet):
@pytest.mark.asyncio @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!""" """Try to invalidate proofs that have not been spent yet. Should not work!"""
invoice = await wallet1.request_mint(64) invoice = await wallet1.request_mint(64)
pay_if_regtest(invoice.bolt11) pay_if_regtest(invoice.bolt11)
await wallet1.mint(64, id=invoice.id) await wallet1.mint(64, id=invoice.id)
await wallet1.invalidate(wallet1.proofs) await wallet1.invalidate(wallet1.proofs)
assert wallet1.balance == 64 assert wallet1.balance == 0
@pytest.mark.asyncio @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.""" """Try to invalidate proofs that have not been spent yet but force no check."""
invoice = await wallet1.request_mint(64) invoice = await wallet1.request_mint(64)
pay_if_regtest(invoice.bolt11) pay_if_regtest(invoice.bolt11)
await wallet1.mint(64, id=invoice.id) await wallet1.mint(64, id=invoice.id)
await wallet1.invalidate(wallet1.proofs, check_spendable=False) await wallet1.invalidate(wallet1.proofs, check_spendable=True)
assert wallet1.balance == 0 assert wallet1.balance == 64
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -405,5 +411,21 @@ async def test_token_state(wallet1: Wallet):
await wallet1.mint(64, id=invoice.id) await wallet1.mint(64, id=invoice.id)
assert wallet1.balance == 64 assert wallet1.balance == 64
resp = await wallet1.check_proof_state(wallet1.proofs) resp = await wallet1.check_proof_state(wallet1.proofs)
assert resp.dict()["spendable"] assert resp.states[0].state.value == "UNSPENT"
assert resp.dict()["pending"]
@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(),
)

View File

@@ -12,14 +12,13 @@ from tests.helpers import is_regtest
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet(mint): async def wallet():
wallet = await Wallet.with_db( wallet = await Wallet.with_db(
url=SERVER_ENDPOINT, url=SERVER_ENDPOINT,
db="test_data/wallet", db="test_data/wallet",
name="wallet", name="wallet",
) )
await wallet.load_mint() await wallet.load_mint()
wallet.status()
yield wallet yield wallet

View File

@@ -35,25 +35,23 @@ def assert_amt(proofs: List[Proof], expected: int):
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet1(mint): async def wallet1():
wallet1 = await Wallet1.with_db( wallet1 = await Wallet1.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1" SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1"
) )
await migrate_databases(wallet1.db, migrations) await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint() await wallet1.load_mint()
wallet1.status()
yield wallet1 yield wallet1
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet2(mint): async def wallet2():
wallet2 = await Wallet2.with_db( wallet2 = await Wallet2.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2" SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2"
) )
await migrate_databases(wallet2.db, migrations) await migrate_databases(wallet2.db, migrations)
wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True) wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet2.load_mint() await wallet2.load_mint()
wallet2.status()
yield wallet2 yield wallet2

View File

@@ -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

View File

@@ -1,12 +1,13 @@
import asyncio import asyncio
import copy import copy
import json
import secrets import secrets
from typing import List from typing import List
import pytest import pytest
import pytest_asyncio 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.crypto.secp import PrivateKey, PublicKey
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import SigFlags from cashu.core.p2pk import SigFlags
@@ -16,7 +17,7 @@ from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2 from cashu.wallet.wallet import Wallet as Wallet2
from tests.conftest import SERVER_ENDPOINT 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): async def assert_err(f, msg):
@@ -36,25 +37,23 @@ def assert_amt(proofs: List[Proof], expected: int):
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet1(mint): async def wallet1():
wallet1 = await Wallet1.with_db( wallet1 = await Wallet1.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1" SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1"
) )
await migrate_databases(wallet1.db, migrations) await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint() await wallet1.load_mint()
wallet1.status()
yield wallet1 yield wallet1
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet2(mint): async def wallet2():
wallet2 = await Wallet2.with_db( wallet2 = await Wallet2.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2" SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2"
) )
await migrate_databases(wallet2.db, migrations) await migrate_databases(wallet2.db, migrations)
wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True) wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet2.load_mint() await wallet2.load_mint()
wallet2.status()
yield wallet2 yield wallet2
@@ -80,6 +79,16 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet):
) )
await wallet2.redeem(send_proofs) 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 @pytest.mark.asyncio
async def test_p2pk_sig_all(wallet1: Wallet, wallet2: Wallet): 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( secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime locktime_seconds=2, # locktime
tags=Tags( tags=Tags([
[["refund", pubkey_wallet2, pubkey_wallet1]] ["refund", pubkey_wallet2, pubkey_wallet1]
), # multiple refund pubkeys ]), # multiple refund pubkeys
) # sender side ) # sender side
_, send_proofs = await wallet1.split_to_send( _, send_proofs = await wallet1.split_to_send(
wallet1.proofs, 8, secret_lock=secret_lock wallet1.proofs, 8, secret_lock=secret_lock
@@ -379,9 +388,9 @@ async def test_p2pk_multisig_with_wrong_first_private_key(
def test_tags(): def test_tags():
tags = Tags( tags = Tags([
[["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]] ["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]
) ])
assert tags.get_tag("key1") == "value1" assert tags.get_tag("key1") == "value1"
assert tags["key1"] == "value1" assert tags["key1"] == "value1"
assert tags.get_tag("key2") == "value2" assert tags.get_tag("key2") == "value2"

View File

@@ -8,6 +8,7 @@ import pytest_asyncio
from cashu.core.base import Proof from cashu.core.base import Proof
from cashu.core.crypto.secp import PrivateKey from cashu.core.crypto.secp import PrivateKey
from cashu.core.errors import CashuError from cashu.core.errors import CashuError
from cashu.core.settings import settings
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2 from cashu.wallet.wallet import Wallet as Wallet2
@@ -46,31 +47,29 @@ async def reset_wallet_db(wallet: Wallet):
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet1(mint): async def wallet1():
wallet1 = await Wallet1.with_db( wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT, url=SERVER_ENDPOINT,
db="test_data/wallet1", db="test_data/wallet1",
name="wallet1", name="wallet1",
) )
await wallet1.load_mint() await wallet1.load_mint()
wallet1.status()
yield wallet1 yield wallet1
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet2(mint): async def wallet2():
wallet2 = await Wallet2.with_db( wallet2 = await Wallet2.with_db(
url=SERVER_ENDPOINT, url=SERVER_ENDPOINT,
db="test_data/wallet2", db="test_data/wallet2",
name="wallet2", name="wallet2",
) )
await wallet2.load_mint() await wallet2.load_mint()
wallet2.status()
yield wallet2 yield wallet2
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def wallet3(mint): async def wallet3():
dirpath = Path("test_data/wallet3") dirpath = Path("test_data/wallet3")
if dirpath.exists() and dirpath.is_dir(): if dirpath.exists() and dirpath.is_dir():
shutil.rmtree(dirpath) 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")
await wallet3.db.execute("DELETE FROM proofs_used") await wallet3.db.execute("DELETE FROM proofs_used")
await wallet3.load_mint() await wallet3.load_mint()
wallet3.status()
yield wallet3 yield wallet3
@pytest.mark.asyncio @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): async def test_bump_secret_derivation(wallet3: Wallet):
await wallet3._init_private_key( await wallet3._init_private_key(
"half depart obvious quality work element tank gorilla view sugar picture" "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) secrets1, rs1, derivation_paths1 = await wallet3.generate_n_secrets(5)
secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(0, 4) 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 secrets1 == secrets2
assert [r.private_key for r in rs1] == [r.private_key for r in rs2] assert [r.private_key for r in rs1] == [r.private_key for r in rs2]
assert derivation_paths1 == derivation_paths2 assert derivation_paths1 == derivation_paths2
for s in secrets1:
print('"' + s + '",')
assert secrets1 == [ assert secrets1 == [
"9d32fc57e6fa2942d05ee475d28ba6a56839b8cb8a3f174b05ed0ed9d3a420f6", "485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae",
"1c0f2c32e7438e7cc992612049e9dfcdbffd454ea460901f24cc429921437802", "8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270",
"327c606b761af03cbe26fa13c4b34a6183b868c52cda059fe57fdddcb4e1e1e7", "bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8",
"53476919560398b56c0fdc5dd92cf8628b1e06de6f2652b0f7d6e8ac319de3b7", "59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf",
"b2f5d632229378a716be6752fc79ac8c2b43323b820859a7956f2dfe5432b7b4", "576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0",
] ]
for d in derivation_paths1:
print('"' + d + '",')
assert derivation_paths1 == [ assert derivation_paths1 == [
"m/129372'/0'/2004500376'/0'", "m/129372'/0'/864559728'/0'",
"m/129372'/0'/2004500376'/1'", "m/129372'/0'/864559728'/1'",
"m/129372'/0'/2004500376'/2'", "m/129372'/0'/864559728'/2'",
"m/129372'/0'/2004500376'/3'", "m/129372'/0'/864559728'/3'",
"m/129372'/0'/2004500376'/4'", "m/129372'/0'/864559728'/4'",
] ]
@@ -191,7 +197,7 @@ async def test_restore_wallet_after_split_to_send(wallet3: Wallet):
assert wallet3.balance == 0 assert wallet3.balance == 0
await wallet3.restore_promises_from_to(0, 100) await wallet3.restore_promises_from_to(0, 100)
assert wallet3.balance == 64 * 2 assert wallet3.balance == 64 * 2
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64 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 assert wallet3.balance == 0
await wallet3.restore_promises_from_to(0, 100) await wallet3.restore_promises_from_to(0, 100)
assert wallet3.balance == 64 + 2 * 32 assert wallet3.balance == 64 + 2 * 32
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 32 assert wallet3.balance == 32
@@ -257,7 +263,7 @@ async def test_restore_wallet_after_send_and_self_receive(wallet3: Wallet):
assert wallet3.balance == 0 assert wallet3.balance == 0
await wallet3.restore_promises_from_to(0, 100) await wallet3.restore_promises_from_to(0, 100)
assert wallet3.balance == 64 + 2 * 32 + 32 assert wallet3.balance == 64 + 2 * 32 + 32
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64 assert wallet3.balance == 64
@@ -290,7 +296,7 @@ async def test_restore_wallet_after_send_twice(
await wallet3.restore_promises_from_to(0, 10) await wallet3.restore_promises_from_to(0, 10)
box.add(wallet3.proofs) box.add(wallet3.proofs)
assert wallet3.balance == 5 assert wallet3.balance == 5
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 2 assert wallet3.balance == 2
# again # again
@@ -310,7 +316,7 @@ async def test_restore_wallet_after_send_twice(
await wallet3.restore_promises_from_to(0, 15) await wallet3.restore_promises_from_to(0, 15)
box.add(wallet3.proofs) box.add(wallet3.proofs)
assert wallet3.balance == 7 assert wallet3.balance == 7
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 2 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) await wallet3.restore_promises_from_to(0, 20)
box.add(wallet3.proofs) box.add(wallet3.proofs)
assert wallet3.balance == 138 assert wallet3.balance == 138
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64 assert wallet3.balance == 64
# again # again
@@ -362,5 +368,5 @@ async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value(
assert wallet3.balance == 0 assert wallet3.balance == 0
await wallet3.restore_promises_from_to(0, 50) await wallet3.restore_promises_from_to(0, 50)
assert wallet3.balance == 182 assert wallet3.balance == 182
await wallet3.invalidate(wallet3.proofs) await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64 assert wallet3.balance == 64