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:
calle
2023-01-19 08:35:32 +01:00
committed by GitHub
parent 68b5b54c9b
commit 2dd9fd356f
13 changed files with 285 additions and 177 deletions

View File

@@ -115,7 +115,7 @@ cashu info
Returns: Returns:
```bash ```bash
Version: 0.8.2 Version: 0.8.3
Debug: False Debug: False
Cashu dir: /home/user/.cashu Cashu dir: /home/user/.cashu
Wallet: wallet Wallet: wallet

View File

@@ -10,28 +10,42 @@ from cashu.core.secp import PrivateKey, PublicKey
class P2SHScript(BaseModel): class P2SHScript(BaseModel):
"""
Describes spending condition of a Proof
"""
script: str script: str
signature: str signature: str
address: Union[str, None] = None address: Union[str, None] = None
class Proof(BaseModel): class Proof(BaseModel):
"""
Value token
"""
id: Union[ id: Union[
None, str None, str
] = "" # NOTE: None for backwards compatibility for old clients that do not include the keyset id < 0.3 ] = "" # NOTE: None for backwards compatibility for old clients that do not include the keyset id < 0.3
amount: int = 0 amount: int = 0
secret: str = "" secret: str = "" # secret or message to be blinded and signed
C: str = "" C: str = "" # signature on secret, unblinded by wallet
script: Union[P2SHScript, None] = None script: Union[P2SHScript, None] = None # P2SH spending condition
reserved: Union[None, bool] = False # whether this proof is reserved for sending reserved: Union[
send_id: Union[None, str] = "" # unique ID of send attempt 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_created: Union[None, str] = ""
time_reserved: Union[None, str] = "" time_reserved: Union[None, str] = ""
def to_dict(self): 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) return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C)
def to_dict_no_secret(self): def to_dict_no_secret(self):
# dictionary but without the secret itself
return dict(id=self.id, amount=self.amount, C=self.C) return dict(id=self.id, amount=self.amount, C=self.C)
def __getitem__(self, key): def __getitem__(self, key):
@@ -47,14 +61,22 @@ class Proofs(BaseModel):
class BlindedMessage(BaseModel): class BlindedMessage(BaseModel):
"""
Blinded message or blinded secret or "output" which is to be signed by the mint
"""
amount: int amount: int
B_: str B_: str # Hex-encoded blinded message
class BlindedSignature(BaseModel): class BlindedSignature(BaseModel):
"""
Blinded signature or "promise" which is the signature on a `BlindedMessage`
"""
id: Union[str, None] = None id: Union[str, None] = None
amount: int amount: int
C_: str C_: str # Hex-encoded signature
class BlindedMessages(BaseModel): class BlindedMessages(BaseModel):
@@ -114,7 +136,7 @@ class GetMintResponse(BaseModel):
# ------- API: MELT ------- # ------- API: MELT -------
class MeltRequest(BaseModel): class PostMeltRequest(BaseModel):
proofs: List[Proof] proofs: List[Proof]
invoice: str invoice: str
@@ -127,7 +149,7 @@ class GetMeltResponse(BaseModel):
# ------- API: SPLIT ------- # ------- API: SPLIT -------
class SplitRequest(BaseModel): class PostSplitRequest(BaseModel):
proofs: List[Proof] proofs: List[Proof]
amount: int amount: int
outputs: List[BlindedMessage] outputs: List[BlindedMessage]
@@ -141,11 +163,11 @@ class PostSplitResponse(BaseModel):
# ------- API: CHECK ------- # ------- API: CHECK -------
class CheckRequest(BaseModel): class GetCheckSpendableRequest(BaseModel):
proofs: List[Proof] proofs: List[Proof]
class CheckFeesRequest(BaseModel): class GetCheckFeesRequest(BaseModel):
pr: str pr: str
@@ -269,21 +291,37 @@ class MintKeysets:
class TokenV1(BaseModel): 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 # NOTE: not used in Pydantic validation
__root__: List[Proof] __root__: List[Proof]
class TokenMintV2(BaseModel): class TokenV2Mint(BaseModel):
url: str """
ks: List[str] 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): 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] proofs: List[Proof]
mints: Optional[Dict[str, TokenMintV2]] = None mints: Optional[List[TokenV2Mint]] = None
def to_dict(self): def to_dict(self):
if self.mints:
return dict( return dict(
proofs=[p.to_dict() for p in self.proofs], proofs=[p.to_dict() for p in self.proofs],
mints={k: v.dict() for k, v in self.mints.items()}, # type: ignore mints=[m.dict() for m in self.mints],
) )
else:
return dict(proofs=[p.to_dict() for p in self.proofs])

View File

@@ -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"]) NOSTR_RELAYS = env.list("NOSTR_RELAYS", default=["wss://nostr-pub.wellorder.net"])
MAX_ORDER = 64 MAX_ORDER = 64
VERSION = "0.8.2" VERSION = "0.8.3"

View File

@@ -6,18 +6,18 @@ from secp256k1 import PublicKey
from cashu.core.base import ( from cashu.core.base import (
BlindedMessage, BlindedMessage,
BlindedSignature, BlindedSignature,
CheckFeesRequest,
CheckFeesResponse, CheckFeesResponse,
CheckRequest, GetCheckFeesRequest,
GetCheckSpendableRequest,
GetMeltResponse, GetMeltResponse,
GetMintResponse, GetMintResponse,
KeysetsResponse, KeysetsResponse,
KeysResponse, KeysResponse,
MeltRequest, PostMeltRequest,
PostMintRequest, PostMintRequest,
PostMintResponse, PostMintResponse,
PostSplitRequest,
PostSplitResponse, PostSplitResponse,
SplitRequest,
) )
from cashu.core.errors import CashuError from cashu.core.errors import CashuError
from cashu.mint.startup import ledger from cashu.mint.startup import ledger
@@ -25,20 +25,28 @@ from cashu.mint.startup import ledger
router: APIRouter = APIRouter() 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: 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() keyset = ledger.get_keyset()
keys = KeysResponse.parse_obj(keyset) keys = KeysResponse.parse_obj(keyset)
return keys 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: async def keyset_keys(idBase64Urlsafe: str) -> KeysResponse:
""" """
Get the public keys of the mint of a specific keyset id. Get the public keys of the mint from a specific keyset id.
The id is encoded in idBase64Urlsafe and needs to be converted back to The id is encoded in idBase64Urlsafe (by a wallet) and is converted back to
normal base64 before it can be processed. normal base64 before it can be processed (by the mint).
""" """
id = idBase64Urlsafe.replace("-", "+").replace("_", "/") id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
keyset = ledger.get_keyset(keyset_id=id) keyset = ledger.get_keyset(keyset_id=id)
@@ -46,14 +54,16 @@ async def keyset_keys(idBase64Urlsafe: str) -> KeysResponse:
return keys return keys
@router.get("/keysets") @router.get(
"/keysets", name="Active keysets", summary="Get all active keyset id of the mind"
)
async def keysets() -> KeysetsResponse: 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()) keysets = KeysetsResponse(keysets=ledger.keysets.get_ids())
return keysets 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: async def request_mint(amount: int = 0) -> GetMintResponse:
""" """
Request minting of new tokens. The mint responds with a Lightning invoice. 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 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( async def mint(
payload: PostMintRequest, payload: PostMintRequest,
payment_hash: Union[str, None] = None, payment_hash: Union[str, None] = None,
@@ -85,8 +99,12 @@ async def mint(
return CashuError(code=0, error=str(exc)) return CashuError(code=0, error=str(exc))
@router.post("/melt") @router.post(
async def melt(payload: MeltRequest) -> GetMeltResponse: "/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. Requests tokens to be destroyed and sent out via Lightning.
""" """
@@ -95,30 +113,41 @@ async def melt(payload: MeltRequest) -> GetMeltResponse:
return resp return resp
@router.post("/check") @router.post(
async def check_spendable(payload: CheckRequest) -> Dict[int, bool]: "/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.""" """Check whether a secret has been spent already or not."""
return await ledger.check_spendable(payload.proofs) return await ledger.check_spendable(payload.proofs)
@router.post("/checkfees") @router.post(
async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: "/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. 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). This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
""" """
fees_msat = await ledger.check_fees(payload.pr) fees_msat = await ledger.check_fees(payload.pr)
return CheckFeesResponse(fee=fees_msat // 1000) return CheckFeesResponse(fee=fees_msat // 1000)
@router.post("/split") @router.post("/split", name="Split", summary="Split proofs at a specified amount")
async def split( async def split(
payload: SplitRequest, payload: PostSplitRequest,
) -> Union[CashuError, PostSplitResponse]: ) -> Union[CashuError, PostSplitResponse]:
""" """
Requetst a set of tokens with amount "total" to be split into two Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split". 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.") assert payload.outputs, Exception("no outputs provided.")
try: try:

View File

@@ -7,7 +7,6 @@ import os
import sys import sys
import threading import threading
import time import time
import urllib.parse
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from itertools import groupby from itertools import groupby
@@ -19,7 +18,7 @@ from typing import Dict, List
import click import click
from loguru import logger 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.helpers import sum_proofs
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.settings import ( from cashu.core.settings import (
@@ -51,7 +50,7 @@ from cashu.wallet.wallet import Wallet as Wallet
from .clihelpers import ( from .clihelpers import (
get_mint_wallet, get_mint_wallet,
print_mint_balances, print_mint_balances,
proofs_to_token, proofs_to_serialized_tokenv2,
redeem_multimint, redeem_multimint,
token_from_lnbits_link, token_from_lnbits_link,
verify_mints, 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))] 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 # 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: except:
pass pass
@@ -392,17 +391,24 @@ async def receive(ctx, token: str, lock: str):
if "tokens" in dtoken: if "tokens" in dtoken:
dtoken["proofs"] = dtoken.pop("tokens") dtoken["proofs"] = dtoken.pop("tokens")
assert "proofs" in dtoken, Exception("no proofs in token") # backwards compatibility wallet to wallet < 0.8.3: V2 tokens got rid of the "MINT_NAME" key in "mints" and renamed "ks" to "ids"
includes_mint_info: bool = "mints" in dtoken and dtoken.get("mints") is not None 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 # if there is a `mints` field in the token
# we check whether the token has mints that we don't know yet # 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 # and ask the user if they want to trust the new mitns
if includes_mint_info: if includes_mint_info:
# we ask the user to confirm any new mints the tokens may include # 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 # 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 # reload main wallet so the balance updates
await wallet.load_proofs() await wallet.load_proofs()
else: else:

View File

@@ -1,40 +1,52 @@
import os import os
import urllib.parse import urllib.parse
from typing import List
import click 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.core.settings import CASHU_DIR, MINT_URL
from cashu.wallet.crud import get_keyset from cashu.wallet.crud import get_keyset
from cashu.wallet.wallet import Wallet as Wallet 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 trust_token_mints = True
for mint_id in dtoken.get("mints"): for mint in token.mints:
for keyset in set(dtoken["mints"][mint_id]["ks"]): for keyset in set(mint.ids):
mint_url = dtoken["mints"][mint_id]["url"]
# init a temporary wallet object # init a temporary wallet object
keyset_wallet = Wallet( 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 # 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." assert keyset in mint_keysets["keysets"], "mint does not have this keyset."
# we validate the keyset id by fetching the keys from the mint # 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) mint_keyset = await keyset_wallet._get_keyset(mint.url, keyset)
assert keyset == mint_keyset.id, Exception("keyset not valid.") 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 # 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: 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
trust_token_mints = False trust_token_mints = False
print("") print("")
print("Warning: Tokens are from a mint you don't know yet.") print("Warning: Tokens are from a mint you don't know yet.")
print("\n") print("\n")
print(f"Mint URL: {mint_url}") print(f"Mint URL: {mint.url}")
print(f"Mint keyset: {keyset}") print(f"Mint keyset: {keyset}")
print("\n") print("\n")
click.confirm( click.confirm(
@@ -43,34 +55,43 @@ async def verify_mints(ctx, dtoken):
default=True, default=True,
) )
trust_token_mints = True trust_token_mints = True
else:
logger.debug(f"We know keyset {mint_keysets.id} already")
assert trust_token_mints, Exception("Aborted!") 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 get the mint information in the token and load the keys of each mint
# we then redeem the tokens for each keyset individually # we then redeem the tokens for each keyset individually
for mint_id in dtoken.get("mints"): if token.mints is None:
for keyset in set(dtoken["mints"][mint_id]["ks"]): return
mint_url = dtoken["mints"][mint_id]["url"]
for mint in token.mints:
for keyset in set(mint.ids):
logger.debug(f"Redeeming tokens from keyset {keyset}")
# init a temporary wallet object # init a temporary wallet object
keyset_wallet = Wallet( 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 # load the keys
await keyset_wallet.load_mint(keyset_id=keyset) await keyset_wallet.load_mint(keyset_id=keyset)
# redeem proofs of this keyset # redeem proofs of this keyset
redeem_proofs = [ redeem_proofs = [p for p in token.proofs if p.id == keyset]
Proof(**p) for p in dtoken["proofs"] if Proof(**p).id == keyset
]
_, _ = await keyset_wallet.redeem( _, _ = await keyset_wallet.redeem(
redeem_proofs, scnd_script=script, scnd_siganture=signature redeem_proofs, scnd_script=script, scnd_siganture=signature
) )
async def print_mint_balances(ctx, wallet, show_mints=False): 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 # get balances per mint
mint_balances = await wallet.balance_per_minturl() 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): 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"] wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint() await wallet.load_mint()
@@ -147,17 +172,16 @@ def token_from_lnbits_link(link):
return 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 # and add url and keyset id to token
token: TokenV2 = await wallet._make_token(proofs, include_mints=False) token: TokenV2 = await wallet._make_token(proofs, include_mints=False)
token.mints = {} token.mints = []
# get keysets of proofs # get keysets of proofs
keysets = list(set([p.id for p in proofs])) keysets = list(set([p.id for p in proofs if p.id is not None]))
assert keysets is not None, "no keysets"
# check whether we know the mint urls for these proofs # check whether we know the mint urls for these proofs
for k in keysets: 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 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) token_serialized = await wallet._serialize_token_base64(token)
return token_serialized return token_serialized

View File

@@ -15,20 +15,20 @@ import cashu.core.bolt11 as bolt11
from cashu.core.base import ( from cashu.core.base import (
BlindedMessage, BlindedMessage,
BlindedSignature, BlindedSignature,
CheckFeesRequest, GetCheckFeesRequest,
CheckRequest, GetCheckSpendableRequest,
GetMintResponse, GetMintResponse,
Invoice, Invoice,
KeysetsResponse, KeysetsResponse,
MeltRequest,
P2SHScript, P2SHScript,
PostMeltRequest,
PostMintRequest, PostMintRequest,
PostMintResponse, PostMintResponse,
PostMintResponseLegacy, PostMintResponseLegacy,
PostSplitRequest,
Proof, Proof,
SplitRequest,
TokenMintV2,
TokenV2, TokenV2,
TokenV2Mint,
WalletKeyset, WalletKeyset,
) )
from cashu.core.bolt11 import Invoice as InvoiceBolt11 from cashu.core.bolt11 import Invoice as InvoiceBolt11
@@ -302,7 +302,7 @@ class LedgerAPI:
), "number of secrets does not match number of outputs" ), "number of secrets does not match number of outputs"
await self._check_used_secrets(secrets) await self._check_used_secrets(secrets)
outputs, rs = self._construct_outputs(amounts, 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 # construct payload
def _splitrequest_include_fields(proofs): 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. 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): def _check_spendable_include_fields(proofs):
"""strips away fields from the model that aren't necessary for the /split""" """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): async def check_fees(self, payment_request: str):
"""Checks whether the Lightning payment is internal.""" """Checks whether the Lightning payment is internal."""
payload = CheckFeesRequest(pr=payment_request) payload = GetCheckFeesRequest(pr=payment_request)
self.s = self._set_requests() self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/checkfees", self.url + "/checkfees",
@@ -374,9 +374,9 @@ class LedgerAPI:
""" """
Accepts proofs and a lightning invoice to pay in exchange. 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""" """strips away fields from the model that aren't necessary for the /melt"""
proofs_include = {"id", "amount", "secret", "C", "script"} proofs_include = {"id", "amount", "secret", "C", "script"}
return { return {
@@ -388,7 +388,7 @@ class LedgerAPI:
self.s = self._set_requests() self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/melt", 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() resp.raise_for_status()
return_dict = resp.json() return_dict = resp.json()
@@ -404,6 +404,9 @@ class Wallet(LedgerAPI):
self.db = Database("wallet", db) self.db = Database("wallet", db)
self.proofs: List[Proof] = [] self.proofs: List[Proof] = []
self.name = name self.name = name
logger.debug(f"Wallet initalized with mint URL {url}")
# ---------- API ----------
async def load_mint(self, keyset_id: str = ""): async def load_mint(self, keyset_id: str = ""):
await super()._load_mint(keyset_id) await super()._load_mint(keyset_id)
@@ -411,28 +414,6 @@ class Wallet(LedgerAPI):
async def load_proofs(self): async def load_proofs(self):
self.proofs = await get_proofs(db=self.db) 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): async def request_mint(self, amount):
invoice = super().request_mint(amount) invoice = super().request_mint(amount)
invoice.time_created = int(time.time()) invoice.time_created = int(time.time())
@@ -502,6 +483,33 @@ class Wallet(LedgerAPI):
raise Exception("could not pay invoice.") raise Exception("could not pay invoice.")
return status["paid"] 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): async def _make_token(self, proofs: List[Proof], include_mints=True):
""" """
Takes list of proofs and produces a TokenV2 by looking up Takes list of proofs and produces a TokenV2 by looking up
@@ -509,34 +517,33 @@ class Wallet(LedgerAPI):
""" """
# build token # build token
token = TokenV2(proofs=proofs) token = TokenV2(proofs=proofs)
# add mint information to the token, if requested # add mint information to the token, if requested
if include_mints: if include_mints:
# dummy object to hold information about the mint # dummy object to hold information about the mint
mints: Dict[str, TokenMintV2] = dict() mints: Dict[str, TokenV2Mint] = {}
# iterate through all proofs and add their keyset to `mints` # 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: for proof in proofs:
if proof.id: if proof.id:
keysets.append(proof.id)
# iterate through unique keyset ids
for id in set(keysets):
# load the keyset from the db # load the keyset from the db
keyset = await get_keyset(id=proof.id, db=self.db) keyset = await get_keyset(id=id, db=self.db)
if keyset and keyset.mint_url and keyset.id: if keyset and keyset.mint_url and keyset.id:
# TODO: replace this with a mint pubkey # we group all mints according to URL
placeholder_mint_id = keyset.mint_url if keyset.mint_url not in mints:
if placeholder_mint_id not in mints: mints[keyset.mint_url] = TokenV2Mint(
# mint information
id = TokenMintV2(
url=keyset.mint_url, url=keyset.mint_url,
ks=[keyset.id], ids=[keyset.id],
) )
mints[placeholder_mint_id] = id
else: else:
# if a mint has multiple keysets, append to the existing list # if a mint URL has multiple keysets, append to the already existing list
if keyset.id not in mints[placeholder_mint_id].ks: mints[keyset.mint_url].ids.append(keyset.id)
mints[placeholder_mint_id].ks.append(keyset.id)
if len(mints) > 0: if len(mints) > 0:
# add dummy object to token # add mints grouped by url to the token
token.mints = mints token.mints = list(mints.values())
return token return token
async def _serialize_token_base64(self, token: TokenV2): async def _serialize_token_base64(self, token: TokenV2):
@@ -589,6 +596,30 @@ class Wallet(LedgerAPI):
send_proofs.append(sorted_proofs[len(send_proofs)]) send_proofs.append(sorted_proofs[len(send_proofs)])
return 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): async def get_pay_amount_with_fees(self, invoice: str):
""" """
Decodes the amount from a Lightning invoice and returns the 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) await self.set_reserved(send_proofs, reserved=True)
return keep_proofs, send_proofs return keep_proofs, send_proofs
async def set_reserved(self, proofs: List[Proof], reserved: bool): # ---------- P2SH ----------
"""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)
)
async def create_p2sh_lock(self): async def create_p2sh_lock(self):
alice_privkey = step0_carol_privkey() alice_privkey = step0_carol_privkey()
@@ -668,6 +676,8 @@ class Wallet(LedgerAPI):
await store_p2sh(p2shScript, db=self.db) await store_p2sh(p2shScript, db=self.db)
return p2shScript return p2shScript
# ---------- BALANCE CHECKS ----------
@property @property
def balance(self): def balance(self):
return sum_proofs(self.proofs) return sum_proofs(self.proofs)

View File

@@ -113,7 +113,7 @@ W3siaWQiOiAiRFNBbDludnZ5ZnZhIiwgImFtb3VudCI6IDgsICJzZWNyZXQiOiAiRGJSS0l5YTBldGR3
#### 0.2.2 - V2 tokens #### 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: ##### Example JSON:
@@ -123,29 +123,30 @@ This token format includes information about the mint as well. The field `proofs
{ {
"id": "DSAl9nvvyfva", "id": "DSAl9nvvyfva",
"amount": 2, "amount": 2,
"secret": "bdYCbHGONundLeYvv1P5dQ", "secret": "EhpennC9qB3iFlW8FZ_pZw",
"C": "02e6117fb1b1633a8c1657ed34ab25ecf8d4974091179c4773ec59f85f4e3991cf" "C": "02c020067db727d586bc3183aecf97fcb800c3f4cc4759f69c626c9db5d8f5b5d4"
}, },
{ {
"id": "DSAl9nvvyfva", "id": "DSAl9nvvyfva",
"amount": 8, "amount": 8,
"secret": "KxyUPt5Mur_-RV8pCECJ6A", "secret": "TmS6Cv0YT5PU_5ATVKnukw",
"C": "03b9dcdb7f195e07218b95b7c2dadc8289159fc44047439830f765b8c50bfb6bda" "C": "02ac910bef28cbe5d7325415d5c263026f15f9b967a079ca9779ab6e5c2db133a7"
} }
], ],
"mints": { "mints": [
"MINT_NAME": { {
"url": "http://server.host:3339", "url": "https://8333.space:3338",
"ks": ["DSAl9nvvyfva"] "ids": ["DSAl9nvvyfva"]
}
} }
]
} }
``` ```
When serialized, this becomes: When serialized, this becomes:
``` ```
eyJwcm9vZnMiOlt7ImlkIjoiRFNBbDludnZ5ZnZhIiwiYW1vdW50IjoyLCJzZWNyZXQiOiJiZFlDYkhHT051bmRMZVl2djFQNWRRIiwiQyI6IjAyZTYxMTdmYjFiMTYzM2E4YzE2NTdlZDM0YWIyNWVjZjhkNDk3NDA5MTE3OWM0NzczZWM1OWY4NWY0ZTM5OTFjZiJ9LHsiaWQiOiJEU0FsOW52dnlmdmEiLCJhbW91bnQiOjgsInNlY3JldCI6Ikt4eVVQdDVNdXJfLVJWOHBDRUNKNkEiLCJDIjoiMDNiOWRjZGI3ZjE5NWUwNzIxOGI5NWI3YzJkYWRjODI4OTE1OWZjNDQwNDc0Mzk4MzBmNzY1YjhjNTBiZmI2YmRhIn1dLCJtaW50cyI6eyJNSU5UX05BTUUiOnsidXJsIjoiaHR0cDovL3NlcnZlci5ob3N0OjMzMzkiLCJrcyI6WyJEU0FsOW52dnlmdmEiXX19fQ== eyJwcm9vZnMiOlt7ImlkIjoiRFNBbDludnZ5ZnZhIiwiYW1vdW50IjoyLCJzZWNyZXQiOiJFaHBlbm5DOXFCM2lGbFc4RlpfcFp3IiwiQyI6IjAyYzAyMDA2N2RiNzI3ZDU4NmJjMzE4M2FlY2Y5N2ZjYjgwMGMzZjRjYzQ3NTlmNjljNjI2YzlkYjVkOGY1YjVkNCJ9LHsiaWQiOiJEU0FsOW52dnlmdmEiLCJhbW91bnQiOjgsInNlY3JldCI6IlRtUzZDdjBZVDVQVV81QVRWS251a3ciLCJDIjoiMDJhYzkxMGJlZjI4Y2JlNWQ3MzI1NDE1ZDVjMjYzMDI2ZjE1ZjliOTY3YTA3OWNhOTc3OWFiNmU1YzJkYjEzM2E3In1dLCJtaW50cyI6W3sidXJsIjoiaHR0cHM6Ly84MzMzLnNwYWNlOjMzMzgiLCJpZHMiOlsiRFNBbDludnZ5ZnZhIl19XX0=
``` ```
[00]: 00.md [00]: 00.md

View File

@@ -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. 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 ## 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 POST https://mint.host:3338/melt
``` ```
With the data being of the form `MeltRequest`: With the data being of the form `PostMeltRequest`:
```json ```json
{ {

View File

@@ -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 POST https://mint.host:3338/split
``` ```
With the data being of the form `SplitRequest`: With the data being of the form `PostSplitRequest`:
```json ```json
{ {

View File

@@ -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. 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` 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 ## 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. 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` 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` 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. - `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. - 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` 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` 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` 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` 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. - `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*] - 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: # Todo:
- Call subsections 1. and 1.2 etc so they can be referenced - 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 - Clarify whether a `TOKEN` is a single Proof or a list of Proofs

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "cashu" name = "cashu"
version = "0.8.2" version = "0.8.3"
description = "Ecash wallet and mint." description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"] authors = ["calle <callebtc@protonmail.com>"]
license = "MIT" license = "MIT"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]}
setuptools.setup( setuptools.setup(
name="cashu", name="cashu",
version="0.8.2", version="0.8.3",
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",