mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 02:24:20 +01:00
Fix/tokenv2mints (#98)
* adjust tokenv2 and make it backwards compatible * fix dict to list * use pydantic object and not the dtoken * make format * fix typo in _meltrequest_include_fields * reorder functions * fixes and tests working * bump version to 0.8.3
This commit is contained in:
@@ -115,7 +115,7 @@ cashu info
|
||||
|
||||
Returns:
|
||||
```bash
|
||||
Version: 0.8.2
|
||||
Version: 0.8.3
|
||||
Debug: False
|
||||
Cashu dir: /home/user/.cashu
|
||||
Wallet: wallet
|
||||
|
||||
@@ -10,28 +10,42 @@ from cashu.core.secp import PrivateKey, PublicKey
|
||||
|
||||
|
||||
class P2SHScript(BaseModel):
|
||||
"""
|
||||
Describes spending condition of a Proof
|
||||
"""
|
||||
|
||||
script: str
|
||||
signature: str
|
||||
address: Union[str, None] = None
|
||||
|
||||
|
||||
class Proof(BaseModel):
|
||||
"""
|
||||
Value token
|
||||
"""
|
||||
|
||||
id: Union[
|
||||
None, str
|
||||
] = "" # NOTE: None for backwards compatibility for old clients that do not include the keyset id < 0.3
|
||||
amount: int = 0
|
||||
secret: str = ""
|
||||
C: str = ""
|
||||
script: Union[P2SHScript, None] = None
|
||||
reserved: Union[None, bool] = False # whether this proof is reserved for sending
|
||||
send_id: Union[None, str] = "" # unique ID of send attempt
|
||||
secret: str = "" # secret or message to be blinded and signed
|
||||
C: str = "" # signature on secret, unblinded by wallet
|
||||
script: Union[P2SHScript, None] = None # P2SH spending condition
|
||||
reserved: Union[
|
||||
None, bool
|
||||
] = False # whether this proof is reserved for sending, used for coin management in the wallet
|
||||
send_id: Union[
|
||||
None, str
|
||||
] = "" # unique ID of send attempt, used for grouping pending tokens in the wallet
|
||||
time_created: Union[None, str] = ""
|
||||
time_reserved: Union[None, str] = ""
|
||||
|
||||
def to_dict(self):
|
||||
# dictionary without the fields that don't need to be send to Carol
|
||||
return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C)
|
||||
|
||||
def to_dict_no_secret(self):
|
||||
# dictionary but without the secret itself
|
||||
return dict(id=self.id, amount=self.amount, C=self.C)
|
||||
|
||||
def __getitem__(self, key):
|
||||
@@ -47,14 +61,22 @@ class Proofs(BaseModel):
|
||||
|
||||
|
||||
class BlindedMessage(BaseModel):
|
||||
"""
|
||||
Blinded message or blinded secret or "output" which is to be signed by the mint
|
||||
"""
|
||||
|
||||
amount: int
|
||||
B_: str
|
||||
B_: str # Hex-encoded blinded message
|
||||
|
||||
|
||||
class BlindedSignature(BaseModel):
|
||||
"""
|
||||
Blinded signature or "promise" which is the signature on a `BlindedMessage`
|
||||
"""
|
||||
|
||||
id: Union[str, None] = None
|
||||
amount: int
|
||||
C_: str
|
||||
C_: str # Hex-encoded signature
|
||||
|
||||
|
||||
class BlindedMessages(BaseModel):
|
||||
@@ -114,7 +136,7 @@ class GetMintResponse(BaseModel):
|
||||
# ------- API: MELT -------
|
||||
|
||||
|
||||
class MeltRequest(BaseModel):
|
||||
class PostMeltRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
invoice: str
|
||||
|
||||
@@ -127,7 +149,7 @@ class GetMeltResponse(BaseModel):
|
||||
# ------- API: SPLIT -------
|
||||
|
||||
|
||||
class SplitRequest(BaseModel):
|
||||
class PostSplitRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
amount: int
|
||||
outputs: List[BlindedMessage]
|
||||
@@ -141,11 +163,11 @@ class PostSplitResponse(BaseModel):
|
||||
# ------- API: CHECK -------
|
||||
|
||||
|
||||
class CheckRequest(BaseModel):
|
||||
class GetCheckSpendableRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
|
||||
|
||||
class CheckFeesRequest(BaseModel):
|
||||
class GetCheckFeesRequest(BaseModel):
|
||||
pr: str
|
||||
|
||||
|
||||
@@ -269,21 +291,37 @@ class MintKeysets:
|
||||
|
||||
|
||||
class TokenV1(BaseModel):
|
||||
"""
|
||||
A (legacy) Cashu token that includes proofs. This can only be received if the receiver knows the mint associated with the
|
||||
keyset ids of the proofs.
|
||||
"""
|
||||
|
||||
# NOTE: not used in Pydantic validation
|
||||
__root__: List[Proof]
|
||||
|
||||
|
||||
class TokenMintV2(BaseModel):
|
||||
url: str
|
||||
ks: List[str]
|
||||
class TokenV2Mint(BaseModel):
|
||||
"""
|
||||
Object that describes how to reach the mints associated with the proofs in a TokenV2 object.
|
||||
"""
|
||||
|
||||
url: str # mint URL
|
||||
ids: List[str] # List of keyset id's that are from this mint
|
||||
|
||||
|
||||
class TokenV2(BaseModel):
|
||||
"""
|
||||
A Cashu token that includes proofs and their respective mints. Can include proofs from multiple different mints and keysets.
|
||||
"""
|
||||
|
||||
proofs: List[Proof]
|
||||
mints: Optional[Dict[str, TokenMintV2]] = None
|
||||
mints: Optional[List[TokenV2Mint]] = None
|
||||
|
||||
def to_dict(self):
|
||||
return dict(
|
||||
proofs=[p.to_dict() for p in self.proofs],
|
||||
mints={k: v.dict() for k, v in self.mints.items()}, # type: ignore
|
||||
)
|
||||
if self.mints:
|
||||
return dict(
|
||||
proofs=[p.to_dict() for p in self.proofs],
|
||||
mints=[m.dict() for m in self.mints],
|
||||
)
|
||||
else:
|
||||
return dict(proofs=[p.to_dict() for p in self.proofs])
|
||||
|
||||
@@ -57,4 +57,4 @@ NOSTR_PRIVATE_KEY = env.str("NOSTR_PRIVATE_KEY", default=None)
|
||||
NOSTR_RELAYS = env.list("NOSTR_RELAYS", default=["wss://nostr-pub.wellorder.net"])
|
||||
|
||||
MAX_ORDER = 64
|
||||
VERSION = "0.8.2"
|
||||
VERSION = "0.8.3"
|
||||
|
||||
@@ -6,18 +6,18 @@ from secp256k1 import PublicKey
|
||||
from cashu.core.base import (
|
||||
BlindedMessage,
|
||||
BlindedSignature,
|
||||
CheckFeesRequest,
|
||||
CheckFeesResponse,
|
||||
CheckRequest,
|
||||
GetCheckFeesRequest,
|
||||
GetCheckSpendableRequest,
|
||||
GetMeltResponse,
|
||||
GetMintResponse,
|
||||
KeysetsResponse,
|
||||
KeysResponse,
|
||||
MeltRequest,
|
||||
PostMeltRequest,
|
||||
PostMintRequest,
|
||||
PostMintResponse,
|
||||
PostSplitRequest,
|
||||
PostSplitResponse,
|
||||
SplitRequest,
|
||||
)
|
||||
from cashu.core.errors import CashuError
|
||||
from cashu.mint.startup import ledger
|
||||
@@ -25,20 +25,28 @@ from cashu.mint.startup import ledger
|
||||
router: APIRouter = APIRouter()
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
@router.get(
|
||||
"/keys",
|
||||
name="Mint public keys",
|
||||
summary="Get the public keys of the newest mint keyset",
|
||||
)
|
||||
async def keys() -> KeysResponse:
|
||||
"""Get the public keys of the mint of the newest keyset"""
|
||||
"""This endpoint returns a dictionary of all supported token values of the mint and their associated public key."""
|
||||
keyset = ledger.get_keyset()
|
||||
keys = KeysResponse.parse_obj(keyset)
|
||||
return keys
|
||||
|
||||
|
||||
@router.get("/keys/{idBase64Urlsafe}")
|
||||
@router.get(
|
||||
"/keys/{idBase64Urlsafe}",
|
||||
name="Keyset public keys",
|
||||
summary="Public keys of a specific keyset",
|
||||
)
|
||||
async def keyset_keys(idBase64Urlsafe: str) -> KeysResponse:
|
||||
"""
|
||||
Get the public keys of the mint of a specific keyset id.
|
||||
The id is encoded in idBase64Urlsafe and needs to be converted back to
|
||||
normal base64 before it can be processed.
|
||||
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).
|
||||
"""
|
||||
id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
|
||||
keyset = ledger.get_keyset(keyset_id=id)
|
||||
@@ -46,14 +54,16 @@ async def keyset_keys(idBase64Urlsafe: str) -> KeysResponse:
|
||||
return keys
|
||||
|
||||
|
||||
@router.get("/keysets")
|
||||
@router.get(
|
||||
"/keysets", name="Active keysets", summary="Get all active keyset id of the mind"
|
||||
)
|
||||
async def keysets() -> KeysetsResponse:
|
||||
"""Get all active keyset ids of the mint"""
|
||||
"""This endpoint returns a list of keysets that the mint currently supports and will accept tokens from."""
|
||||
keysets = KeysetsResponse(keysets=ledger.keysets.get_ids())
|
||||
return keysets
|
||||
|
||||
|
||||
@router.get("/mint")
|
||||
@router.get("/mint", name="Request mint", summary="Request minting of new tokens")
|
||||
async def request_mint(amount: int = 0) -> GetMintResponse:
|
||||
"""
|
||||
Request minting of new tokens. The mint responds with a Lightning invoice.
|
||||
@@ -67,7 +77,11 @@ async def request_mint(amount: int = 0) -> GetMintResponse:
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/mint")
|
||||
@router.post(
|
||||
"/mint",
|
||||
name="Mint tokens",
|
||||
summary="Mint tokens in exchange for a Bitcoin paymemt that the user has made",
|
||||
)
|
||||
async def mint(
|
||||
payload: PostMintRequest,
|
||||
payment_hash: Union[str, None] = None,
|
||||
@@ -85,8 +99,12 @@ async def mint(
|
||||
return CashuError(code=0, error=str(exc))
|
||||
|
||||
|
||||
@router.post("/melt")
|
||||
async def melt(payload: MeltRequest) -> GetMeltResponse:
|
||||
@router.post(
|
||||
"/melt",
|
||||
name="Melt tokens",
|
||||
summary="Melt tokens for a Bitcoin payment that the mint will make for the user in exchange",
|
||||
)
|
||||
async def melt(payload: PostMeltRequest) -> GetMeltResponse:
|
||||
"""
|
||||
Requests tokens to be destroyed and sent out via Lightning.
|
||||
"""
|
||||
@@ -95,30 +113,41 @@ async def melt(payload: MeltRequest) -> GetMeltResponse:
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
async def check_spendable(payload: CheckRequest) -> Dict[int, bool]:
|
||||
@router.post(
|
||||
"/check",
|
||||
name="Check spendable",
|
||||
summary="Check whether a proof has already been spent",
|
||||
)
|
||||
async def check_spendable(payload: GetCheckSpendableRequest) -> Dict[int, bool]:
|
||||
"""Check whether a secret has been spent already or not."""
|
||||
return await ledger.check_spendable(payload.proofs)
|
||||
|
||||
|
||||
@router.post("/checkfees")
|
||||
async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse:
|
||||
@router.post(
|
||||
"/checkfees",
|
||||
name="Check fees",
|
||||
summary="Check fee reserve for a Lightning payment",
|
||||
)
|
||||
async def check_fees(payload: GetCheckFeesRequest) -> CheckFeesResponse:
|
||||
"""
|
||||
Responds with the fees necessary to pay a Lightning invoice.
|
||||
Used by wallets for figuring out the fees they need to supply.
|
||||
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).
|
||||
"""
|
||||
fees_msat = await ledger.check_fees(payload.pr)
|
||||
return CheckFeesResponse(fee=fees_msat // 1000)
|
||||
|
||||
|
||||
@router.post("/split")
|
||||
@router.post("/split", name="Split", summary="Split proofs at a specified amount")
|
||||
async def split(
|
||||
payload: SplitRequest,
|
||||
payload: PostSplitRequest,
|
||||
) -> Union[CashuError, PostSplitResponse]:
|
||||
"""
|
||||
Requetst a set of tokens with amount "total" to be split into two
|
||||
newly minted sets with amount "split" and "total-split".
|
||||
|
||||
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.
|
||||
"""
|
||||
assert payload.outputs, Exception("no outputs provided.")
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,6 @@ import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from itertools import groupby
|
||||
@@ -19,7 +18,7 @@ from typing import Dict, List
|
||||
import click
|
||||
from loguru import logger
|
||||
|
||||
from cashu.core.base import Proof
|
||||
from cashu.core.base import Proof, TokenV2
|
||||
from cashu.core.helpers import sum_proofs
|
||||
from cashu.core.migrations import migrate_databases
|
||||
from cashu.core.settings import (
|
||||
@@ -51,7 +50,7 @@ from cashu.wallet.wallet import Wallet as Wallet
|
||||
from .clihelpers import (
|
||||
get_mint_wallet,
|
||||
print_mint_balances,
|
||||
proofs_to_token,
|
||||
proofs_to_serialized_tokenv2,
|
||||
redeem_multimint,
|
||||
token_from_lnbits_link,
|
||||
verify_mints,
|
||||
@@ -379,7 +378,7 @@ async def receive(ctx, token: str, lock: str):
|
||||
proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))]
|
||||
|
||||
# we take the proofs parsed from the old format token and produce a new format token with it
|
||||
token = await proofs_to_token(wallet, proofs, url)
|
||||
token = await proofs_to_serialized_tokenv2(wallet, proofs, url)
|
||||
except:
|
||||
pass
|
||||
|
||||
@@ -392,17 +391,24 @@ async def receive(ctx, token: str, lock: str):
|
||||
if "tokens" in dtoken:
|
||||
dtoken["proofs"] = dtoken.pop("tokens")
|
||||
|
||||
assert "proofs" in dtoken, Exception("no proofs in token")
|
||||
includes_mint_info: bool = "mints" in dtoken and dtoken.get("mints") is not None
|
||||
# backwards compatibility wallet to wallet < 0.8.3: V2 tokens got rid of the "MINT_NAME" key in "mints" and renamed "ks" to "ids"
|
||||
if "mints" in dtoken and isinstance(dtoken["mints"], dict):
|
||||
dtoken["mints"] = list(dtoken["mints"].values())
|
||||
for m in dtoken["mints"]:
|
||||
m["ids"] = m.pop("ks")
|
||||
|
||||
tokenObj = TokenV2.parse_obj(dtoken)
|
||||
assert len(tokenObj.proofs), Exception("no proofs in token")
|
||||
includes_mint_info: bool = tokenObj.mints is not None and len(tokenObj.mints) > 0
|
||||
|
||||
# if there is a `mints` field in the token
|
||||
# we check whether the token has mints that we don't know yet
|
||||
# and ask the user if they want to trust the new mitns
|
||||
if includes_mint_info:
|
||||
# we ask the user to confirm any new mints the tokens may include
|
||||
await verify_mints(ctx, dtoken)
|
||||
await verify_mints(ctx, tokenObj)
|
||||
# redeem tokens with new wallet instances
|
||||
await redeem_multimint(ctx, dtoken, script, signature)
|
||||
await redeem_multimint(ctx, tokenObj, script, signature)
|
||||
# reload main wallet so the balance updates
|
||||
await wallet.load_proofs()
|
||||
else:
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
|
||||
from cashu.core.base import Proof, TokenMintV2, TokenV2, WalletKeyset
|
||||
from cashu.core.base import Proof, TokenV2, TokenV2Mint, WalletKeyset
|
||||
from cashu.core.settings import CASHU_DIR, MINT_URL
|
||||
from cashu.wallet.crud import get_keyset
|
||||
from cashu.wallet.wallet import Wallet as Wallet
|
||||
|
||||
|
||||
async def verify_mints(ctx, dtoken):
|
||||
async def verify_mints(ctx, token: TokenV2):
|
||||
"""
|
||||
A helper function that iterates through all mints in the token and if it has
|
||||
not been encountered before, asks the user to confirm.
|
||||
|
||||
It will instantiate a Wallet with each keyset and check whether the mint supports it.
|
||||
It will then get the keys for that keyset from the mint and check whether the keyset id is correct.
|
||||
"""
|
||||
|
||||
if token.mints is None:
|
||||
return
|
||||
logger.debug(f"Verifying mints")
|
||||
trust_token_mints = True
|
||||
for mint_id in dtoken.get("mints"):
|
||||
for keyset in set(dtoken["mints"][mint_id]["ks"]):
|
||||
mint_url = dtoken["mints"][mint_id]["url"]
|
||||
for mint in token.mints:
|
||||
for keyset in set(mint.ids):
|
||||
# init a temporary wallet object
|
||||
keyset_wallet = Wallet(
|
||||
mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"])
|
||||
mint.url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"])
|
||||
)
|
||||
# make sure that this mint supports this keyset
|
||||
mint_keysets = await keyset_wallet._get_keyset_ids(mint_url)
|
||||
mint_keysets = await keyset_wallet._get_keyset_ids(mint.url)
|
||||
assert keyset in mint_keysets["keysets"], "mint does not have this keyset."
|
||||
|
||||
# we validate the keyset id by fetching the keys from the mint
|
||||
mint_keyset = await keyset_wallet._get_keyset(mint_url, keyset)
|
||||
# we validate the keyset id by fetching the keys from the mint and computing the id locally
|
||||
mint_keyset = await keyset_wallet._get_keyset(mint.url, keyset)
|
||||
assert keyset == mint_keyset.id, Exception("keyset not valid.")
|
||||
|
||||
# we check the db whether we know this mint already and ask the user if not
|
||||
mint_keysets = await get_keyset(mint_url=mint_url, db=keyset_wallet.db)
|
||||
mint_keysets = await get_keyset(mint_url=mint.url, db=keyset_wallet.db)
|
||||
if mint_keysets is None:
|
||||
# we encountered a new mint and ask for a user confirmation
|
||||
trust_token_mints = False
|
||||
print("")
|
||||
print("Warning: Tokens are from a mint you don't know yet.")
|
||||
print("\n")
|
||||
print(f"Mint URL: {mint_url}")
|
||||
print(f"Mint URL: {mint.url}")
|
||||
print(f"Mint keyset: {keyset}")
|
||||
print("\n")
|
||||
click.confirm(
|
||||
@@ -43,34 +55,43 @@ async def verify_mints(ctx, dtoken):
|
||||
default=True,
|
||||
)
|
||||
trust_token_mints = True
|
||||
|
||||
else:
|
||||
logger.debug(f"We know keyset {mint_keysets.id} already")
|
||||
assert trust_token_mints, Exception("Aborted!")
|
||||
|
||||
|
||||
async def redeem_multimint(ctx, dtoken, script, signature):
|
||||
async def redeem_multimint(ctx, token: TokenV2, script, signature):
|
||||
"""
|
||||
Helper function to iterate thruogh a token with multiple mints and redeem them from
|
||||
these mints one keyset at a time.
|
||||
"""
|
||||
# we get the mint information in the token and load the keys of each mint
|
||||
# we then redeem the tokens for each keyset individually
|
||||
for mint_id in dtoken.get("mints"):
|
||||
for keyset in set(dtoken["mints"][mint_id]["ks"]):
|
||||
mint_url = dtoken["mints"][mint_id]["url"]
|
||||
if token.mints is None:
|
||||
return
|
||||
|
||||
for mint in token.mints:
|
||||
for keyset in set(mint.ids):
|
||||
logger.debug(f"Redeeming tokens from keyset {keyset}")
|
||||
# init a temporary wallet object
|
||||
keyset_wallet = Wallet(
|
||||
mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"])
|
||||
mint.url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"])
|
||||
)
|
||||
|
||||
# load the keys
|
||||
await keyset_wallet.load_mint(keyset_id=keyset)
|
||||
|
||||
# redeem proofs of this keyset
|
||||
redeem_proofs = [
|
||||
Proof(**p) for p in dtoken["proofs"] if Proof(**p).id == keyset
|
||||
]
|
||||
redeem_proofs = [p for p in token.proofs if p.id == keyset]
|
||||
_, _ = await keyset_wallet.redeem(
|
||||
redeem_proofs, scnd_script=script, scnd_siganture=signature
|
||||
)
|
||||
|
||||
|
||||
async def print_mint_balances(ctx, wallet, show_mints=False):
|
||||
"""
|
||||
Helper function that prints the balances for each mint URL that we have tokens from.
|
||||
"""
|
||||
# get balances per mint
|
||||
mint_balances = await wallet.balance_per_minturl()
|
||||
|
||||
@@ -94,6 +115,10 @@ async def print_mint_balances(ctx, wallet, show_mints=False):
|
||||
|
||||
|
||||
async def get_mint_wallet(ctx):
|
||||
"""
|
||||
Helper function that asks the user for an input to select which mint they want to load.
|
||||
Useful for selecting the mint that the user wants to send tokens from.
|
||||
"""
|
||||
wallet: Wallet = ctx.obj["WALLET"]
|
||||
await wallet.load_mint()
|
||||
|
||||
@@ -147,17 +172,16 @@ def token_from_lnbits_link(link):
|
||||
return link, ""
|
||||
|
||||
|
||||
async def proofs_to_token(wallet, proofs, url: str):
|
||||
async def proofs_to_serialized_tokenv2(wallet, proofs: List[Proof], url: str):
|
||||
"""
|
||||
Ingests proofs and
|
||||
Ingests list of proofs and produces a serialized TokenV2
|
||||
"""
|
||||
# and add url and keyset id to token
|
||||
token: TokenV2 = await wallet._make_token(proofs, include_mints=False)
|
||||
token.mints = {}
|
||||
token.mints = []
|
||||
|
||||
# get keysets of proofs
|
||||
keysets = list(set([p.id for p in proofs]))
|
||||
assert keysets is not None, "no keysets"
|
||||
keysets = list(set([p.id for p in proofs if p.id is not None]))
|
||||
|
||||
# check whether we know the mint urls for these proofs
|
||||
for k in keysets:
|
||||
@@ -168,6 +192,6 @@ async def proofs_to_token(wallet, proofs, url: str):
|
||||
input(f"Enter mint URL (press enter for default {MINT_URL}): ") or MINT_URL
|
||||
)
|
||||
|
||||
token.mints[url] = TokenMintV2(url=url, ks=keysets) # type: ignore
|
||||
token.mints.append(TokenV2Mint(url=url, ids=keysets))
|
||||
token_serialized = await wallet._serialize_token_base64(token)
|
||||
return token_serialized
|
||||
|
||||
@@ -15,20 +15,20 @@ import cashu.core.bolt11 as bolt11
|
||||
from cashu.core.base import (
|
||||
BlindedMessage,
|
||||
BlindedSignature,
|
||||
CheckFeesRequest,
|
||||
CheckRequest,
|
||||
GetCheckFeesRequest,
|
||||
GetCheckSpendableRequest,
|
||||
GetMintResponse,
|
||||
Invoice,
|
||||
KeysetsResponse,
|
||||
MeltRequest,
|
||||
P2SHScript,
|
||||
PostMeltRequest,
|
||||
PostMintRequest,
|
||||
PostMintResponse,
|
||||
PostMintResponseLegacy,
|
||||
PostSplitRequest,
|
||||
Proof,
|
||||
SplitRequest,
|
||||
TokenMintV2,
|
||||
TokenV2,
|
||||
TokenV2Mint,
|
||||
WalletKeyset,
|
||||
)
|
||||
from cashu.core.bolt11 import Invoice as InvoiceBolt11
|
||||
@@ -302,7 +302,7 @@ class LedgerAPI:
|
||||
), "number of secrets does not match number of outputs"
|
||||
await self._check_used_secrets(secrets)
|
||||
outputs, rs = self._construct_outputs(amounts, secrets)
|
||||
split_payload = SplitRequest(proofs=proofs, amount=amount, outputs=outputs)
|
||||
split_payload = PostSplitRequest(proofs=proofs, amount=amount, outputs=outputs)
|
||||
|
||||
# construct payload
|
||||
def _splitrequest_include_fields(proofs):
|
||||
@@ -339,7 +339,7 @@ class LedgerAPI:
|
||||
"""
|
||||
Cheks whether the secrets in proofs are already spent or not and returns a list of booleans.
|
||||
"""
|
||||
payload = CheckRequest(proofs=proofs)
|
||||
payload = GetCheckSpendableRequest(proofs=proofs)
|
||||
|
||||
def _check_spendable_include_fields(proofs):
|
||||
"""strips away fields from the model that aren't necessary for the /split"""
|
||||
@@ -359,7 +359,7 @@ class LedgerAPI:
|
||||
|
||||
async def check_fees(self, payment_request: str):
|
||||
"""Checks whether the Lightning payment is internal."""
|
||||
payload = CheckFeesRequest(pr=payment_request)
|
||||
payload = GetCheckFeesRequest(pr=payment_request)
|
||||
self.s = self._set_requests()
|
||||
resp = self.s.post(
|
||||
self.url + "/checkfees",
|
||||
@@ -374,9 +374,9 @@ class LedgerAPI:
|
||||
"""
|
||||
Accepts proofs and a lightning invoice to pay in exchange.
|
||||
"""
|
||||
payload = MeltRequest(proofs=proofs, invoice=invoice)
|
||||
payload = PostMeltRequest(proofs=proofs, invoice=invoice)
|
||||
|
||||
def _meltequest_include_fields(proofs):
|
||||
def _meltrequest_include_fields(proofs):
|
||||
"""strips away fields from the model that aren't necessary for the /melt"""
|
||||
proofs_include = {"id", "amount", "secret", "C", "script"}
|
||||
return {
|
||||
@@ -388,7 +388,7 @@ class LedgerAPI:
|
||||
self.s = self._set_requests()
|
||||
resp = self.s.post(
|
||||
self.url + "/melt",
|
||||
json=payload.dict(include=_meltequest_include_fields(proofs)), # type: ignore
|
||||
json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return_dict = resp.json()
|
||||
@@ -404,6 +404,9 @@ class Wallet(LedgerAPI):
|
||||
self.db = Database("wallet", db)
|
||||
self.proofs: List[Proof] = []
|
||||
self.name = name
|
||||
logger.debug(f"Wallet initalized with mint URL {url}")
|
||||
|
||||
# ---------- API ----------
|
||||
|
||||
async def load_mint(self, keyset_id: str = ""):
|
||||
await super()._load_mint(keyset_id)
|
||||
@@ -411,28 +414,6 @@ class Wallet(LedgerAPI):
|
||||
async def load_proofs(self):
|
||||
self.proofs = await get_proofs(db=self.db)
|
||||
|
||||
async def _store_proofs(self, proofs):
|
||||
for proof in proofs:
|
||||
await store_proof(proof, db=self.db)
|
||||
|
||||
@staticmethod
|
||||
def _get_proofs_per_keyset(proofs: List[Proof]):
|
||||
return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)}
|
||||
|
||||
async def _get_proofs_per_minturl(self, proofs: List[Proof]):
|
||||
ret = {}
|
||||
for id in set([p.id for p in proofs]):
|
||||
if id is None:
|
||||
continue
|
||||
keyset_crud = await get_keyset(id=id, db=self.db)
|
||||
assert keyset_crud is not None, "keyset not found"
|
||||
keyset: WalletKeyset = keyset_crud
|
||||
if keyset.mint_url not in ret:
|
||||
ret[keyset.mint_url] = [p for p in proofs if p.id == id]
|
||||
else:
|
||||
ret[keyset.mint_url].extend([p for p in proofs if p.id == id])
|
||||
return ret
|
||||
|
||||
async def request_mint(self, amount):
|
||||
invoice = super().request_mint(amount)
|
||||
invoice.time_created = int(time.time())
|
||||
@@ -502,6 +483,33 @@ class Wallet(LedgerAPI):
|
||||
raise Exception("could not pay invoice.")
|
||||
return status["paid"]
|
||||
|
||||
async def check_spendable(self, proofs):
|
||||
return await super().check_spendable(proofs)
|
||||
|
||||
# ---------- TOKEN MECHANIS ----------
|
||||
|
||||
async def _store_proofs(self, proofs):
|
||||
for proof in proofs:
|
||||
await store_proof(proof, db=self.db)
|
||||
|
||||
@staticmethod
|
||||
def _get_proofs_per_keyset(proofs: List[Proof]):
|
||||
return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)}
|
||||
|
||||
async def _get_proofs_per_minturl(self, proofs: List[Proof]):
|
||||
ret = {}
|
||||
for id in set([p.id for p in proofs]):
|
||||
if id is None:
|
||||
continue
|
||||
keyset_crud = await get_keyset(id=id, db=self.db)
|
||||
assert keyset_crud is not None, "keyset not found"
|
||||
keyset: WalletKeyset = keyset_crud
|
||||
if keyset.mint_url not in ret:
|
||||
ret[keyset.mint_url] = [p for p in proofs if p.id == id]
|
||||
else:
|
||||
ret[keyset.mint_url].extend([p for p in proofs if p.id == id])
|
||||
return ret
|
||||
|
||||
async def _make_token(self, proofs: List[Proof], include_mints=True):
|
||||
"""
|
||||
Takes list of proofs and produces a TokenV2 by looking up
|
||||
@@ -509,34 +517,33 @@ class Wallet(LedgerAPI):
|
||||
"""
|
||||
# build token
|
||||
token = TokenV2(proofs=proofs)
|
||||
|
||||
# add mint information to the token, if requested
|
||||
if include_mints:
|
||||
# dummy object to hold information about the mint
|
||||
mints: Dict[str, TokenMintV2] = dict()
|
||||
# iterate through all proofs and add their keyset to `mints`
|
||||
mints: Dict[str, TokenV2Mint] = {}
|
||||
# dummy object to hold all keyset id's we need to fetch from the db later
|
||||
keysets: List[str] = []
|
||||
# iterate through all proofs and remember their keyset ids for the next step
|
||||
for proof in proofs:
|
||||
if proof.id:
|
||||
# load the keyset from the db
|
||||
keyset = await get_keyset(id=proof.id, db=self.db)
|
||||
if keyset and keyset.mint_url and keyset.id:
|
||||
# TODO: replace this with a mint pubkey
|
||||
placeholder_mint_id = keyset.mint_url
|
||||
if placeholder_mint_id not in mints:
|
||||
# mint information
|
||||
id = TokenMintV2(
|
||||
url=keyset.mint_url,
|
||||
ks=[keyset.id],
|
||||
)
|
||||
mints[placeholder_mint_id] = id
|
||||
else:
|
||||
# if a mint has multiple keysets, append to the existing list
|
||||
if keyset.id not in mints[placeholder_mint_id].ks:
|
||||
mints[placeholder_mint_id].ks.append(keyset.id)
|
||||
|
||||
keysets.append(proof.id)
|
||||
# iterate through unique keyset ids
|
||||
for id in set(keysets):
|
||||
# load the keyset from the db
|
||||
keyset = await get_keyset(id=id, db=self.db)
|
||||
if keyset and keyset.mint_url and keyset.id:
|
||||
# we group all mints according to URL
|
||||
if keyset.mint_url not in mints:
|
||||
mints[keyset.mint_url] = TokenV2Mint(
|
||||
url=keyset.mint_url,
|
||||
ids=[keyset.id],
|
||||
)
|
||||
else:
|
||||
# if a mint URL has multiple keysets, append to the already existing list
|
||||
mints[keyset.mint_url].ids.append(keyset.id)
|
||||
if len(mints) > 0:
|
||||
# add dummy object to token
|
||||
token.mints = mints
|
||||
# add mints grouped by url to the token
|
||||
token.mints = list(mints.values())
|
||||
return token
|
||||
|
||||
async def _serialize_token_base64(self, token: TokenV2):
|
||||
@@ -589,6 +596,30 @@ class Wallet(LedgerAPI):
|
||||
send_proofs.append(sorted_proofs[len(send_proofs)])
|
||||
return send_proofs
|
||||
|
||||
async def set_reserved(self, proofs: List[Proof], reserved: bool):
|
||||
"""Mark a proof as reserved to avoid reuse or delete marking."""
|
||||
uuid_str = str(uuid.uuid1())
|
||||
for proof in proofs:
|
||||
proof.reserved = True
|
||||
await update_proof_reserved(
|
||||
proof, reserved=reserved, send_id=uuid_str, db=self.db
|
||||
)
|
||||
|
||||
async def invalidate(self, proofs):
|
||||
"""Invalidates all spendable tokens supplied in proofs."""
|
||||
spendables = await self.check_spendable(proofs)
|
||||
invalidated_proofs = []
|
||||
for idx, spendable in spendables.items():
|
||||
if not spendable:
|
||||
invalidated_proofs.append(proofs[int(idx)])
|
||||
await invalidate_proof(proofs[int(idx)], db=self.db)
|
||||
invalidate_secrets = [p["secret"] for p in invalidated_proofs]
|
||||
self.proofs = list(
|
||||
filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs)
|
||||
)
|
||||
|
||||
# ---------- TRANSACTION HELPERS ----------
|
||||
|
||||
async def get_pay_amount_with_fees(self, invoice: str):
|
||||
"""
|
||||
Decodes the amount from a Lightning invoice and returns the
|
||||
@@ -628,30 +659,7 @@ class Wallet(LedgerAPI):
|
||||
await self.set_reserved(send_proofs, reserved=True)
|
||||
return keep_proofs, send_proofs
|
||||
|
||||
async def set_reserved(self, proofs: List[Proof], reserved: bool):
|
||||
"""Mark a proof as reserved to avoid reuse or delete marking."""
|
||||
uuid_str = str(uuid.uuid1())
|
||||
for proof in proofs:
|
||||
proof.reserved = True
|
||||
await update_proof_reserved(
|
||||
proof, reserved=reserved, send_id=uuid_str, db=self.db
|
||||
)
|
||||
|
||||
async def check_spendable(self, proofs):
|
||||
return await super().check_spendable(proofs)
|
||||
|
||||
async def invalidate(self, proofs):
|
||||
"""Invalidates all spendable tokens supplied in proofs."""
|
||||
spendables = await self.check_spendable(proofs)
|
||||
invalidated_proofs = []
|
||||
for idx, spendable in spendables.items():
|
||||
if not spendable:
|
||||
invalidated_proofs.append(proofs[int(idx)])
|
||||
await invalidate_proof(proofs[int(idx)], db=self.db)
|
||||
invalidate_secrets = [p["secret"] for p in invalidated_proofs]
|
||||
self.proofs = list(
|
||||
filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs)
|
||||
)
|
||||
# ---------- P2SH ----------
|
||||
|
||||
async def create_p2sh_lock(self):
|
||||
alice_privkey = step0_carol_privkey()
|
||||
@@ -668,6 +676,8 @@ class Wallet(LedgerAPI):
|
||||
await store_p2sh(p2shScript, db=self.db)
|
||||
return p2shScript
|
||||
|
||||
# ---------- BALANCE CHECKS ----------
|
||||
|
||||
@property
|
||||
def balance(self):
|
||||
return sum_proofs(self.proofs)
|
||||
|
||||
@@ -113,7 +113,7 @@ W3siaWQiOiAiRFNBbDludnZ5ZnZhIiwgImFtb3VudCI6IDgsICJzZWNyZXQiOiAiRGJSS0l5YTBldGR3
|
||||
|
||||
#### 0.2.2 - V2 tokens
|
||||
|
||||
This token format includes information about the mint as well. The field `proofs` is like a V1 token. Additionally, the field `mints` can include a list of multiple mints from which the `proofs` are from. The `url` field is the URL of the mint. `ks` is a list of the keyset IDs belonging to this mint. All keyset IDs of the `proofs` must be present here to allow a wallet to map each proof to a mint.
|
||||
This token format includes information about the mint as well. The field `proofs` is like a V1 token. Additionally, the field `mints` can include an array (list) of multiple mints from which the `proofs` are from. The `url` field is the URL of the mint. `ids` is a list of the keyset IDs belonging to this mint. It is important that all keyset IDs of the `proofs` must be present here to allow a wallet to map each proof to a mint.
|
||||
|
||||
##### Example JSON:
|
||||
|
||||
@@ -123,29 +123,30 @@ This token format includes information about the mint as well. The field `proofs
|
||||
{
|
||||
"id": "DSAl9nvvyfva",
|
||||
"amount": 2,
|
||||
"secret": "bdYCbHGONundLeYvv1P5dQ",
|
||||
"C": "02e6117fb1b1633a8c1657ed34ab25ecf8d4974091179c4773ec59f85f4e3991cf"
|
||||
"secret": "EhpennC9qB3iFlW8FZ_pZw",
|
||||
"C": "02c020067db727d586bc3183aecf97fcb800c3f4cc4759f69c626c9db5d8f5b5d4"
|
||||
},
|
||||
{
|
||||
"id": "DSAl9nvvyfva",
|
||||
"amount": 8,
|
||||
"secret": "KxyUPt5Mur_-RV8pCECJ6A",
|
||||
"C": "03b9dcdb7f195e07218b95b7c2dadc8289159fc44047439830f765b8c50bfb6bda"
|
||||
"secret": "TmS6Cv0YT5PU_5ATVKnukw",
|
||||
"C": "02ac910bef28cbe5d7325415d5c263026f15f9b967a079ca9779ab6e5c2db133a7"
|
||||
}
|
||||
],
|
||||
"mints": {
|
||||
"MINT_NAME": {
|
||||
"url": "http://server.host:3339",
|
||||
"ks": ["DSAl9nvvyfva"]
|
||||
"mints": [
|
||||
{
|
||||
"url": "https://8333.space:3338",
|
||||
"ids": ["DSAl9nvvyfva"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
When serialized, this becomes:
|
||||
|
||||
```
|
||||
eyJwcm9vZnMiOlt7ImlkIjoiRFNBbDludnZ5ZnZhIiwiYW1vdW50IjoyLCJzZWNyZXQiOiJiZFlDYkhHT051bmRMZVl2djFQNWRRIiwiQyI6IjAyZTYxMTdmYjFiMTYzM2E4YzE2NTdlZDM0YWIyNWVjZjhkNDk3NDA5MTE3OWM0NzczZWM1OWY4NWY0ZTM5OTFjZiJ9LHsiaWQiOiJEU0FsOW52dnlmdmEiLCJhbW91bnQiOjgsInNlY3JldCI6Ikt4eVVQdDVNdXJfLVJWOHBDRUNKNkEiLCJDIjoiMDNiOWRjZGI3ZjE5NWUwNzIxOGI5NWI3YzJkYWRjODI4OTE1OWZjNDQwNDc0Mzk4MzBmNzY1YjhjNTBiZmI2YmRhIn1dLCJtaW50cyI6eyJNSU5UX05BTUUiOnsidXJsIjoiaHR0cDovL3NlcnZlci5ob3N0OjMzMzkiLCJrcyI6WyJEU0FsOW52dnlmdmEiXX19fQ==
|
||||
eyJwcm9vZnMiOlt7ImlkIjoiRFNBbDludnZ5ZnZhIiwiYW1vdW50IjoyLCJzZWNyZXQiOiJFaHBlbm5DOXFCM2lGbFc4RlpfcFp3IiwiQyI6IjAyYzAyMDA2N2RiNzI3ZDU4NmJjMzE4M2FlY2Y5N2ZjYjgwMGMzZjRjYzQ3NTlmNjljNjI2YzlkYjVkOGY1YjVkNCJ9LHsiaWQiOiJEU0FsOW52dnlmdmEiLCJhbW91bnQiOjgsInNlY3JldCI6IlRtUzZDdjBZVDVQVV81QVRWS251a3ciLCJDIjoiMDJhYzkxMGJlZjI4Y2JlNWQ3MzI1NDE1ZDVjMjYzMDI2ZjE1ZjliOTY3YTA3OWNhOTc3OWFiNmU1YzJkYjEzM2E3In1dLCJtaW50cyI6W3sidXJsIjoiaHR0cHM6Ly84MzMzLnNwYWNlOjMzMzgiLCJpZHMiOlsiRFNBbDludnZ5ZnZhIl19XX0=
|
||||
```
|
||||
|
||||
[00]: 00.md
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Melting tokens is the opposite of minting them (see #4): the wallet `Alice` sends `Proofs` to the mint `Bob` together with a bolt11 Lightning invoice that `Alice` wants to be paid. To melt tokens, `Alice` sends a `POST /melt` request with a JSON body to the mint. The `Proofs` included in the request will be burned by the mint and the mint will pay the invoice in exchange.
|
||||
|
||||
`Alice`'s request **MUST** include a `MeltRequest` ([TODO: Link MeltRequest]) JSON body with `Proofs` that have at least the amount of the invoice to be paid.
|
||||
`Alice`'s request **MUST** include a `PostMeltRequest` ([TODO: Link PostMeltRequest]) JSON body with `Proofs` that have at least the amount of the invoice to be paid.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -12,7 +12,7 @@ Melting tokens is the opposite of minting them (see #4): the wallet `Alice` send
|
||||
POST https://mint.host:3338/melt
|
||||
```
|
||||
|
||||
With the data being of the form `MeltRequest`:
|
||||
With the data being of the form `PostMeltRequest`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ Another case of how split can be useful becomes apparent if we follow up the exa
|
||||
POST https://mint.host:3338/split
|
||||
```
|
||||
|
||||
With the data being of the form `SplitRequest`:
|
||||
With the data being of the form `PostSplitRequest`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -92,12 +92,12 @@ Here we describe how `Carol` can redeem new tokens from `Bob` that she previousl
|
||||
Note that the following steps can also be performed by `Alice` herself if she wants to cancel the pending token transfer and claim them for herself.
|
||||
|
||||
- `Carol` constructs a list of `<blinded_message>`'s each with the same amount as the list list of tokens that she received. This can be done by the same procedure as during the minting of new tokens in Section 2 [*TODO: update ref*] or during sending in Section 3 [*TODO: update ref*] since the splitting into amounts is deterministic.
|
||||
- `Carol` performs the same steps as `Alice` when she split the tokens before sending it to her and calls the endpoint `POIT /split` with the JSON `SplitRequests` as the body of the request.
|
||||
- `Carol` performs the same steps as `Alice` when she split the tokens before sending it to her and calls the endpoint `POIT /split` with the JSON `PostSplitRequests` as the body of the request.
|
||||
|
||||
## 5 - Burn sent tokens
|
||||
Here we describe how `Alice` checks with the mint whether the tokens she sent `Carol` have been redeemed so she can safely delete them from her database. This step is optional but highly recommended so `Alice` can properly account for the tokens and adjust her balance accordingly.
|
||||
- `Alice` loads all `<send_proofs>` with `pending=True` from her database and might group them by the `send_id`.
|
||||
- `Alice` constructs a JSON of the form `{"proofs" : [{"amount" : <amount>, "secret" : s, "C" : Z}, ...]}` from these (grouped) tokens. [*TODO: this object is called CheckRequest*]
|
||||
- `Alice` constructs a JSON of the form `{"proofs" : [{"amount" : <amount>, "secret" : s, "C" : Z}, ...]}` from these (grouped) tokens. [*TODO: this object is called GetCheckSpendableRequest*]
|
||||
- `Alice` sends them to the mint `Bob` via the endpoint `POST /check` with the JSON as the body of the request.
|
||||
- `Alice` receives a JSON of the form `{"1" : <spendable : bool>, "2" : ...}` where `"1"` is the index of the proof she sent to the mint before and `<spendable>` is a boolean that is `True` if the token has not been claimed yet by `Carol` and `False` if it has already been claimed.
|
||||
- If `<spendable>` is `False`, `Alice` removes the proof [*NOTE: consistent name?*] from her list of spendable proofs.
|
||||
@@ -109,7 +109,7 @@ Here we describe how `Alice` can request from `Bob` to make a Lightning payment
|
||||
- `Alice` asks `Bob` for the Lightning fee via `GET /checkfee` with the body `CheckFeeRequest` being the json `{pr : <invoice>}`
|
||||
- `Alice` receives the `CheckFeeResponse` in the form of the json `{"fee" : <fee>}` resulting in `<total> = <invoice_amount> + <fee>`.
|
||||
- `Alice` now performs the same set of instructions as in Step 3.1 and 3.2 and splits her spendable tokens into a set `<keep_proofs>` that she keeps and and a set `<send_proofs>` with a sum of at least `<total>` that she can send for making the Lightning payment.
|
||||
- `Alice` constructs the JSON `MeltRequest` of the form `{"proofs" : <List[Proof]>, "invoice" : <invoice>}` [*NOTE: Maybe use notation List[Proof] everywhere. Used MeltRequest here, maybe define each payload at the beginning of each section.*]
|
||||
- `Alice` constructs the JSON `PostMeltRequest` of the form `{"proofs" : <List[Proof]>, "invoice" : <invoice>}` [*NOTE: Maybe use notation List[Proof] everywhere. Used PostMeltRequest here, maybe define each payload at the beginning of each section.*]
|
||||
- `Alice` requests a payment from `Bob` via the endpoint `POST /melt` with the JSON as the body of the request.
|
||||
- `Alice` receives a JSON of the form `{"paid" : <status:bool>}` with `<status>` being `True` if the payment was successful and `False` otherwise.
|
||||
- If `<status> == True`, `Alice` removes `<send_proofs>` from her database of spendable tokens [*NOTE: called it tokens again*]
|
||||
@@ -121,5 +121,5 @@ Here we describe how `Alice` can request from `Bob` to make a Lightning payment
|
||||
|
||||
# Todo:
|
||||
- Call subsections 1. and 1.2 etc so they can be referenced
|
||||
- Define objets like `BlindedMessages` and `SplitRequests` once when they appear and reuse them.
|
||||
- Define objets like `BlindedMessages` and `PostSplitRequests` once when they appear and reuse them.
|
||||
- Clarify whether a `TOKEN` is a single Proof or a list of Proofs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "cashu"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
description = "Ecash wallet and mint."
|
||||
authors = ["calle <callebtc@protonmail.com>"]
|
||||
license = "MIT"
|
||||
|
||||
2
setup.py
2
setup.py
@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]}
|
||||
|
||||
setuptools.setup(
|
||||
name="cashu",
|
||||
version="0.8.2",
|
||||
version="0.8.3",
|
||||
description="Ecash wallet and mint for Bitcoin Lightning",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
|
||||
Reference in New Issue
Block a user