mirror of
https://github.com/aljazceru/nutshell.git
synced 2026-02-06 17:24:20 +01:00
31
.github/workflows/formatting.yml
vendored
Normal file
31
.github/workflows/formatting.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: formatting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
poetry:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.2.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install packages
|
||||
run: poetry install --with dev
|
||||
- name: Check black
|
||||
run: poetry run black --check .
|
||||
- name: Check isort
|
||||
run: poetry run isort --profile black --check-only .
|
||||
38
.github/workflows/tests.yml
vendored
Normal file
38
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
poetry:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.2.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install --with dev
|
||||
- name: Run mint
|
||||
env:
|
||||
LIGHTNING: False
|
||||
MINT_SERVER_HOST: 0.0.0.0
|
||||
MINT_SERVER_PORT: 3338
|
||||
run: |
|
||||
nohup poetry run mint &
|
||||
- name: Run tests
|
||||
env:
|
||||
LIGHTNING: False
|
||||
MINT_HOST: localhost
|
||||
MINT_PORT: 3338
|
||||
run: |
|
||||
poetry run pytest tests
|
||||
@@ -4,6 +4,11 @@ from typing import List
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CashuError(BaseModel):
|
||||
code = "000"
|
||||
error = "CashuError"
|
||||
|
||||
|
||||
class P2SHScript(BaseModel):
|
||||
script: str
|
||||
signature: str
|
||||
@@ -106,21 +111,47 @@ class BlindedSignature(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class MintPayloads(BaseModel):
|
||||
class MintRequest(BaseModel):
|
||||
blinded_messages: List[BlindedMessage] = []
|
||||
|
||||
|
||||
class SplitPayload(BaseModel):
|
||||
class GetMintResponse(BaseModel):
|
||||
pr: str
|
||||
hash: str
|
||||
|
||||
|
||||
class GetMeltResponse(BaseModel):
|
||||
paid: str
|
||||
preimage: str
|
||||
|
||||
|
||||
class SplitRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
amount: int
|
||||
output_data: MintPayloads
|
||||
output_data: MintRequest = None # backwards compatibility with clients < v0.2.1
|
||||
outputs: MintRequest = None
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self.backwards_compatibility_v021()
|
||||
|
||||
def backwards_compatibility_v021(self):
|
||||
# before v0.2.1: output_data, after: outputs
|
||||
if self.output_data:
|
||||
self.outputs = self.output_data
|
||||
self.output_data = None
|
||||
|
||||
|
||||
class CheckPayload(BaseModel):
|
||||
class PostSplitResponse(BaseModel):
|
||||
fst: List[BlindedSignature]
|
||||
snd: List[BlindedSignature]
|
||||
|
||||
|
||||
class CheckRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
|
||||
|
||||
class MeltPayload(BaseModel):
|
||||
class MeltRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
amount: int
|
||||
invoice: str
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import asyncio
|
||||
from functools import partial, wraps
|
||||
|
||||
from cashu.core.settings import (LIGHTNING_FEE_PERCENT,
|
||||
LIGHTNING_RESERVE_FEE_MIN)
|
||||
from cashu.core.settings import LIGHTNING_FEE_PERCENT, LIGHTNING_RESERVE_FEE_MIN
|
||||
|
||||
|
||||
def async_wrap(func):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from environs import Env # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ import requests
|
||||
|
||||
from cashu.core.settings import LNBITS_ENDPOINT, LNBITS_KEY
|
||||
|
||||
from .base import (InvoiceResponse, PaymentResponse, PaymentStatus,
|
||||
StatusResponse, Wallet)
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class LNbitsWallet(Wallet):
|
||||
|
||||
@@ -124,24 +124,20 @@ class Ledger:
|
||||
), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]}!={txin_p2sh_address}."
|
||||
return valid
|
||||
|
||||
def _verify_outputs(
|
||||
self, total: int, amount: int, output_data: List[BlindedMessage]
|
||||
):
|
||||
def _verify_outputs(self, total: int, amount: int, outputs: List[BlindedMessage]):
|
||||
"""Verifies the expected split was correctly computed"""
|
||||
fst_amt, snd_amt = total - amount, amount # we have two amounts to split to
|
||||
fst_outputs = amount_split(fst_amt)
|
||||
snd_outputs = amount_split(snd_amt)
|
||||
expected = fst_outputs + snd_outputs
|
||||
given = [o.amount for o in output_data]
|
||||
frst_amt, scnd_amt = total - amount, amount # we have two amounts to split to
|
||||
frst_outputs = amount_split(frst_amt)
|
||||
scnd_outputs = amount_split(scnd_amt)
|
||||
expected = frst_outputs + scnd_outputs
|
||||
given = [o.amount for o in outputs]
|
||||
return given == expected
|
||||
|
||||
def _verify_no_duplicates(
|
||||
self, proofs: List[Proof], output_data: List[BlindedMessage]
|
||||
):
|
||||
def _verify_no_duplicates(self, proofs: List[Proof], outputs: List[BlindedMessage]):
|
||||
secrets = [p.secret for p in proofs]
|
||||
if len(secrets) != len(list(set(secrets))):
|
||||
return False
|
||||
B_s = [od.B_ for od in output_data]
|
||||
B_s = [od.B_ for od in outputs]
|
||||
if len(B_s) != len(list(set(B_s))):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -3,8 +3,16 @@ from typing import Union
|
||||
from fastapi import APIRouter
|
||||
from secp256k1 import PublicKey
|
||||
|
||||
from cashu.core.base import (CheckPayload, MeltPayload, MintPayloads,
|
||||
SplitPayload)
|
||||
from cashu.core.base import (
|
||||
CashuError,
|
||||
CheckRequest,
|
||||
GetMeltResponse,
|
||||
GetMintResponse,
|
||||
MeltRequest,
|
||||
MintRequest,
|
||||
PostSplitResponse,
|
||||
SplitRequest,
|
||||
)
|
||||
from cashu.mint import ledger
|
||||
|
||||
router: APIRouter = APIRouter()
|
||||
@@ -18,30 +26,24 @@ def keys():
|
||||
|
||||
@router.get("/mint")
|
||||
async def request_mint(amount: int = 0):
|
||||
"""Request minting of tokens. Server 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.
|
||||
|
||||
Call `POST /mint` after paying the invoice.
|
||||
"""
|
||||
payment_request, payment_hash = await ledger.request_mint(amount)
|
||||
print(f"Lightning invoice: {payment_request}")
|
||||
return {"pr": payment_request, "hash": payment_hash}
|
||||
resp = GetMintResponse(pr=payment_request, hash=payment_hash)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/mint")
|
||||
async def mint(payloads: MintPayloads, payment_hash: Union[str, None] = None):
|
||||
async def mint(payloads: MintRequest, payment_hash: Union[str, None] = None):
|
||||
"""
|
||||
Requests the minting of tokens belonging to a paid payment request.
|
||||
|
||||
Parameters:
|
||||
pr: payment_request of the Lightning paid invoice.
|
||||
|
||||
Body (JSON):
|
||||
payloads: contains a list of blinded messages waiting to be signed.
|
||||
|
||||
NOTE:
|
||||
- This needs to be replaced by the preimage otherwise someone knowing
|
||||
the payment_request can request the tokens instead of the rightful
|
||||
owner.
|
||||
- The blinded message should ideally be provided to the server *before* payment
|
||||
in the GET /mint endpoint so that the server knows to sign only these tokens
|
||||
when the invoice is paid.
|
||||
Call this endpoint after `GET /mint`.
|
||||
"""
|
||||
amounts = []
|
||||
B_s = []
|
||||
@@ -52,37 +54,39 @@ async def mint(payloads: MintPayloads, payment_hash: Union[str, None] = None):
|
||||
promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash)
|
||||
return promises
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
return CashuError(error=str(exc))
|
||||
|
||||
|
||||
@router.post("/melt")
|
||||
async def melt(payload: MeltPayload):
|
||||
async def melt(payload: MeltRequest):
|
||||
"""
|
||||
Requests tokens to be destroyed and sent out via Lightning.
|
||||
"""
|
||||
ok, preimage = await ledger.melt(payload.proofs, payload.amount, payload.invoice)
|
||||
return {"paid": ok, "preimage": preimage}
|
||||
resp = GetMeltResponse(paid=ok, preimage=preimage)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
async def check_spendable(payload: CheckPayload):
|
||||
async def check_spendable(payload: CheckRequest):
|
||||
return await ledger.check_spendable(payload.proofs)
|
||||
|
||||
|
||||
@router.post("/split")
|
||||
async def split(payload: SplitPayload):
|
||||
async def split(payload: SplitRequest):
|
||||
"""
|
||||
Requetst a set of tokens with amount "total" to be split into two
|
||||
newly minted sets with amount "split" and "total-split".
|
||||
"""
|
||||
proofs = payload.proofs
|
||||
amount = payload.amount
|
||||
output_data = payload.output_data.blinded_messages
|
||||
outputs = payload.outputs.blinded_messages
|
||||
try:
|
||||
split_return = await ledger.split(proofs, amount, output_data)
|
||||
split_return = await ledger.split(proofs, amount, outputs)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
return CashuError(error=str(exc))
|
||||
if not split_return:
|
||||
return {"error": "there was a problem with the split."}
|
||||
fst_promises, snd_promises = split_return
|
||||
return {"fst": fst_promises, "snd": snd_promises}
|
||||
frst_promises, scnd_promises = split_return
|
||||
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
|
||||
return resp
|
||||
|
||||
@@ -6,8 +6,8 @@ import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from itertools import groupby
|
||||
from operator import itemgetter
|
||||
@@ -20,7 +20,7 @@ from cashu.core.base import Proof
|
||||
from cashu.core.bolt11 import Invoice
|
||||
from cashu.core.helpers import fee_reserve
|
||||
from cashu.core.migrations import migrate_databases
|
||||
from cashu.core.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL, VERSION, ENV_FILE
|
||||
from cashu.core.settings import CASHU_DIR, DEBUG, ENV_FILE, LIGHTNING, MINT_URL, VERSION
|
||||
from cashu.wallet import migrations
|
||||
from cashu.wallet.crud import get_reserved_proofs, get_unused_locks
|
||||
from cashu.wallet.wallet import Wallet as Wallet
|
||||
@@ -195,7 +195,7 @@ async def receive(ctx, coin: str, lock: str):
|
||||
else:
|
||||
script, signature = None, None
|
||||
proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin))]
|
||||
_, _ = await wallet.redeem(proofs, snd_script=script, snd_siganture=signature)
|
||||
_, _ = await wallet.redeem(proofs, scnd_script=script, scnd_siganture=signature)
|
||||
wallet.status()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from typing import Optional, List, Any
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from cashu.core.base import Proof, P2SHScript
|
||||
from cashu.core.base import P2SHScript, Proof
|
||||
from cashu.core.db import Connection, Database
|
||||
|
||||
|
||||
|
||||
@@ -11,20 +11,20 @@ import cashu.core.b_dhke as b_dhke
|
||||
from cashu.core.base import (
|
||||
BlindedMessage,
|
||||
BlindedSignature,
|
||||
CheckPayload,
|
||||
MeltPayload,
|
||||
MintPayloads,
|
||||
CheckRequest,
|
||||
MeltRequest,
|
||||
MintRequest,
|
||||
P2SHScript,
|
||||
Proof,
|
||||
SplitPayload,
|
||||
SplitRequest,
|
||||
)
|
||||
from cashu.core.db import Database
|
||||
from cashu.core.script import (
|
||||
step0_carol_privkey,
|
||||
step0_carol_checksig_redeemscrip,
|
||||
step0_carol_privkey,
|
||||
step1_carol_create_p2sh_address,
|
||||
step2_carol_sign_tx,
|
||||
)
|
||||
from cashu.core.db import Database
|
||||
from cashu.core.secp import PublicKey
|
||||
from cashu.core.settings import DEBUG
|
||||
from cashu.core.split import amount_split
|
||||
@@ -32,9 +32,9 @@ from cashu.wallet.crud import (
|
||||
get_proofs,
|
||||
invalidate_proof,
|
||||
secret_used,
|
||||
store_p2sh,
|
||||
store_proof,
|
||||
update_proof_reserved,
|
||||
store_p2sh,
|
||||
)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class LedgerAPI:
|
||||
assert len(amounts) == len(
|
||||
secrets
|
||||
), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}"
|
||||
payloads: MintPayloads = MintPayloads()
|
||||
payloads: MintRequest = MintRequest()
|
||||
rs = []
|
||||
for secret, amount in zip(secrets, amounts):
|
||||
B_, r = b_dhke.step1_alice(secret)
|
||||
@@ -142,39 +142,39 @@ class LedgerAPI:
|
||||
promises = [BlindedSignature.from_dict(p) for p in promises_list]
|
||||
return self._construct_proofs(promises, secrets, rs)
|
||||
|
||||
async def split(self, proofs, amount, snd_secret: str = None):
|
||||
async def split(self, proofs, amount, scnd_secret: str = None):
|
||||
"""Consume proofs and create new promises based on amount split.
|
||||
If snd_secret is None, random secrets will be generated for the tokens to keep (fst_outputs)
|
||||
and the promises to send (snd_outputs).
|
||||
If scnd_secret is None, random secrets will be generated for the tokens to keep (frst_outputs)
|
||||
and the promises to send (scnd_outputs).
|
||||
|
||||
If snd_secret is provided, the wallet will create blinded secrets with those to attach a
|
||||
If scnd_secret is provided, the wallet will create blinded secrets with those to attach a
|
||||
predefined spending condition to the tokens they want to send."""
|
||||
|
||||
total = sum([p["amount"] for p in proofs])
|
||||
fst_amt, snd_amt = total - amount, amount
|
||||
fst_outputs = amount_split(fst_amt)
|
||||
snd_outputs = amount_split(snd_amt)
|
||||
frst_amt, scnd_amt = total - amount, amount
|
||||
frst_outputs = amount_split(frst_amt)
|
||||
scnd_outputs = amount_split(scnd_amt)
|
||||
|
||||
amounts = fst_outputs + snd_outputs
|
||||
if snd_secret is None:
|
||||
amounts = frst_outputs + scnd_outputs
|
||||
if scnd_secret is None:
|
||||
secrets = [self._generate_secret() for _ in range(len(amounts))]
|
||||
else:
|
||||
snd_secrets = self.generate_secrets(snd_secret, len(snd_outputs))
|
||||
logger.debug(f"Creating proofs with custom secrets: {snd_secrets}")
|
||||
assert len(snd_secrets) == len(
|
||||
snd_outputs
|
||||
), "number of snd_secrets does not match number of ouptus."
|
||||
scnd_secrets = self.generate_secrets(scnd_secret, len(scnd_outputs))
|
||||
logger.debug(f"Creating proofs with custom secrets: {scnd_secrets}")
|
||||
assert len(scnd_secrets) == len(
|
||||
scnd_outputs
|
||||
), "number of scnd_secrets does not match number of ouptus."
|
||||
# append predefined secrets (to send) to random secrets (to keep)
|
||||
secrets = [
|
||||
self._generate_secret() for s in range(len(fst_outputs))
|
||||
] + snd_secrets
|
||||
self._generate_secret() for s in range(len(frst_outputs))
|
||||
] + scnd_secrets
|
||||
|
||||
assert len(secrets) == len(
|
||||
amounts
|
||||
), "number of secrets does not match number of outputs"
|
||||
await self._check_used_secrets(secrets)
|
||||
payloads, rs = self._construct_outputs(amounts, secrets)
|
||||
split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads)
|
||||
split_payload = SplitRequest(proofs=proofs, amount=amount, outputs=payloads)
|
||||
resp = requests.post(
|
||||
self.url + "/split",
|
||||
json=split_payload.dict(),
|
||||
@@ -192,17 +192,17 @@ class LedgerAPI:
|
||||
promises_fst = [BlindedSignature.from_dict(p) for p in promises_dict["fst"]]
|
||||
promises_snd = [BlindedSignature.from_dict(p) for p in promises_dict["snd"]]
|
||||
# Construct proofs from promises (i.e., unblind signatures)
|
||||
fst_proofs = self._construct_proofs(
|
||||
frst_proofs = self._construct_proofs(
|
||||
promises_fst, secrets[: len(promises_fst)], rs[: len(promises_fst)]
|
||||
)
|
||||
snd_proofs = self._construct_proofs(
|
||||
scnd_proofs = self._construct_proofs(
|
||||
promises_snd, secrets[len(promises_fst) :], rs[len(promises_fst) :]
|
||||
)
|
||||
|
||||
return fst_proofs, snd_proofs
|
||||
return frst_proofs, scnd_proofs
|
||||
|
||||
async def check_spendable(self, proofs: List[Proof]):
|
||||
payload = CheckPayload(proofs=proofs)
|
||||
payload = CheckRequest(proofs=proofs)
|
||||
return_dict = requests.post(
|
||||
self.url + "/check",
|
||||
json=payload.dict(),
|
||||
@@ -211,7 +211,7 @@ class LedgerAPI:
|
||||
return return_dict
|
||||
|
||||
async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str):
|
||||
payload = MeltPayload(proofs=proofs, amount=amount, invoice=invoice)
|
||||
payload = MeltRequest(proofs=proofs, amount=amount, invoice=invoice)
|
||||
return_dict = requests.post(
|
||||
self.url + "/melt",
|
||||
json=payload.dict(),
|
||||
@@ -253,35 +253,35 @@ class Wallet(LedgerAPI):
|
||||
async def redeem(
|
||||
self,
|
||||
proofs: List[Proof],
|
||||
snd_script: str = None,
|
||||
snd_siganture: str = None,
|
||||
scnd_script: str = None,
|
||||
scnd_siganture: str = None,
|
||||
):
|
||||
if snd_script and snd_siganture:
|
||||
logger.debug(f"Unlock script: {snd_script}")
|
||||
if scnd_script and scnd_siganture:
|
||||
logger.debug(f"Unlock script: {scnd_script}")
|
||||
# attach unlock scripts to proofs
|
||||
for p in proofs:
|
||||
p.script = P2SHScript(script=snd_script, signature=snd_siganture)
|
||||
p.script = P2SHScript(script=scnd_script, signature=scnd_siganture)
|
||||
return await self.split(proofs, sum(p["amount"] for p in proofs))
|
||||
|
||||
async def split(
|
||||
self,
|
||||
proofs: List[Proof],
|
||||
amount: int,
|
||||
snd_secret: str = None,
|
||||
scnd_secret: str = None,
|
||||
):
|
||||
assert len(proofs) > 0, ValueError("no proofs provided.")
|
||||
fst_proofs, snd_proofs = await super().split(proofs, amount, snd_secret)
|
||||
if len(fst_proofs) == 0 and len(snd_proofs) == 0:
|
||||
frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret)
|
||||
if len(frst_proofs) == 0 and len(scnd_proofs) == 0:
|
||||
raise Exception("received no splits.")
|
||||
used_secrets = [p["secret"] for p in proofs]
|
||||
self.proofs = list(
|
||||
filter(lambda p: p["secret"] not in used_secrets, self.proofs)
|
||||
)
|
||||
self.proofs += fst_proofs + snd_proofs
|
||||
await self._store_proofs(fst_proofs + snd_proofs)
|
||||
self.proofs += frst_proofs + scnd_proofs
|
||||
await self._store_proofs(frst_proofs + scnd_proofs)
|
||||
for proof in proofs:
|
||||
await invalidate_proof(proof, db=self.db)
|
||||
return fst_proofs, snd_proofs
|
||||
return frst_proofs, scnd_proofs
|
||||
|
||||
async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str):
|
||||
"""Pays a lightning invoice"""
|
||||
@@ -303,14 +303,14 @@ class Wallet(LedgerAPI):
|
||||
).decode()
|
||||
return token
|
||||
|
||||
async def split_to_send(self, proofs: List[Proof], amount, snd_secret: str = None):
|
||||
async def split_to_send(self, proofs: List[Proof], amount, scnd_secret: str = None):
|
||||
"""Like self.split but only considers non-reserved tokens."""
|
||||
if snd_secret:
|
||||
logger.debug(f"Spending conditions: {snd_secret}")
|
||||
if scnd_secret:
|
||||
logger.debug(f"Spending conditions: {scnd_secret}")
|
||||
if len([p for p in proofs if not p.reserved]) <= 0:
|
||||
raise Exception("balance too low.")
|
||||
return await self.split(
|
||||
[p for p in proofs if not p.reserved], amount, snd_secret
|
||||
[p for p in proofs if not p.reserved], amount, scnd_secret
|
||||
)
|
||||
|
||||
async def set_reserved(self, proofs: List[Proof], reserved: bool):
|
||||
|
||||
124
docs/specs/cashu_client_spec.md
Normal file
124
docs/specs/cashu_client_spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Notation
|
||||
|
||||
Sending user: `Alice`
|
||||
Receivung user: `Carol`
|
||||
Mint: `Bob`
|
||||
|
||||
## Bob (mint)
|
||||
- `k` private key of mint (one for each amount)
|
||||
- `K` public key of mint
|
||||
- `Q` promise (blinded signature)
|
||||
|
||||
## Alice (user)
|
||||
- `x` random string (secret message), corresponds to point `Y` on curve
|
||||
- `r` private key (blinding factor)
|
||||
- `T` blinded message
|
||||
- `Z` proof (unblinded signature)
|
||||
|
||||
# Blind Diffie-Hellmann key exchange (BDH)
|
||||
- Mint `Bob` publishes `K = kG`
|
||||
- `Alice` picks secret `x` and computes `Y = hash_to_point(x)`
|
||||
- `Alice` sends to `Bob`: `T = Y + rG` with `r` being a random nonce
|
||||
- `Bob` sends back to `Alice` blinded key: `Q = kT` (these two steps are the DH key exchange)
|
||||
- `Alice` can calculate the unblinded key as `Q - rK = kY + krG - krG = kY = Z`
|
||||
- Alice can take the pair `(x, Z)` as a token and can send it to `Carol`.
|
||||
- `Carol` can send `(x, Z)` to `Bob` who then checks that `k*hash_to_point(x) == Z`, and if so treats it as a valid spend of a token, adding `x` to the list of spent secrets.
|
||||
|
||||
# Cashu client protocol
|
||||
|
||||
## 1 - Request public keys from mint
|
||||
|
||||
`Alice` receives public keys from mint `Bob` via `GET /keys` and stores them in a key-value store like a dictionary. Keys are received as a JSON of the form `{<amount_1> : <mint_pubkey_1>, <amount_2> : ...}` for each `<amount_i>` of the amounts the mint `Bob` supports. [NOTE: `mint_pubkey` should be consistent with the notation above.]
|
||||
|
||||
## 2 - Mint tokens
|
||||
|
||||
### Step 1: `Alice` requests mint
|
||||
- `Alice` requests the minting of tokens of value `amount : int` via `GET /mint?amount=<amount>`
|
||||
- `Bob` responds with a JSON `{"pr": <payment_request>, "hash": <payment_hash>}` where `payment_request` is the bolt11 Lightning invoice that `Alice` needs to pay and `payment_hash` is the hash of the invoice necessary for alice to request minting of tokens later. `Alice` stores `payment_hash`. [NOTE: <payment_hash> does not need to be passed by Bob, can be derived from <payment_request>]
|
||||
- `Alice` pays bolt11 invoice `payment_request` using a Bitcoin Lightning wallet.
|
||||
|
||||
### Step 2: Request tokens
|
||||
- To request tokens of value `amount : int`, `Alice` decomposes `amount` into a sum of values of `2^n`, e.g. `13` is `amounts : List[int] = [1, 4, 8]`. This can be easily done by representing `amount` as binary and using each binary digit that is `1` as part of the sum, e.g. `8` would be `1101` wich is `2^0 + 2^2 + 2^3`. In this example, `Alice` will request `N = len(amounts) = 3` tokens.
|
||||
- `Alice` generates a random secret string `x_i` of `128` random bits with `i \in [0,..,N-1]`for each of the `N` requested tokens and encodes them in `base64`. [TODO: remove index i]
|
||||
- `Alice` remembers `x` for the construction of the proof in Step 5.
|
||||
|
||||
### Step 3: Generate blinded message
|
||||
Here we see how `Alice` generates `N` blinded messages `T_i`. The following steps are executed for each of the `N` tokens that `Alice` requests. The index `i` is dropped for simplicity. [TODO: either write everything independent of i or not, don't mix]
|
||||
- `Alice` generates a point `Y` on the elliptic curve from the secret `x` using the deterministic function `Y = hash_to_curve(hash(x : string)) : Point`.
|
||||
- `h = hash(x : string) : string` can be the `SHA256` hash function.
|
||||
- `Y = hash_to_curve(h : string) : Point` verifies that `Y` is an element of the elliptic curve.
|
||||
- `Alice` generates a random nonce `r : int` that is a private key and computes the public key from it using `r*G`.
|
||||
- `Alice` generates the blinded message `T = Y + r*G`
|
||||
- `Alice` remembers `r` for the construction of the proof in Step 5.
|
||||
|
||||
### Step 4: Request tokens
|
||||
- `Alice` constructs JSON `MintRequest = {"blinded_messages" : ["amount" : <amount>, "B_" : <blinded_message>] }` [NOTE: rename "blinded_messages", rename "B_", rename "MintRequest"]
|
||||
- `Alice` requests tokens via `POST /mint?payment_hash=<payment_hash>` with body `MintRequest` [NOTE: rename MintRequest]
|
||||
- `Alice` receives from `Bob` a list of blinded signatures `List[BlindedSignature]`, one for each token, e.g. `[{"amount" : <amount>, "C_" : <blinded_signature>}, ...]` [NOTE: rename C_]
|
||||
- If an error occured, `Alice` receives JSON `{"error" : <error_reason>}}`[TODO: Specify case of error]
|
||||
|
||||
### Step 5: Construct proofs
|
||||
Here, `Alice` construct proofs for each token using the tuple `(blinded_signature, r, s)`. Again, all steps are repeated for each token separately but we show it here for only one token.
|
||||
- `Alice` unblinds `blinded_signature` by subtracting `r*<mint_pubkey>` from it. Note that `<mint_pubkey>` must be according to the `<amount>` of the token. The result is the proof `Z`. [Note: in notation, this is Z = Q - r*K]
|
||||
- `Alice` constructs spendable token as a tuple `(<amount>, Z, s)` and stores it in her database.
|
||||
|
||||
## 3 - Send tokens
|
||||
Here we describe how `Alice` sends tokens to `Carol`.
|
||||
|
||||
### 3.1 – Split tokens to desired amount
|
||||
`Alice` wants to send tokens of total value `<total>` to `Carol` but doesn't necessarily have a set of tokens that sum to `<total>`. Say `Alice` has tokens of the amount `<alice_balance>` which is greater than `<total>` in here database. Note that `<alice_balance>` does not need to include all of `Alice`'s tokens but only at least tokens of a total amount of `<total>`. Therefore, `Alice` sends tokens of amount `<alice_balance>` to `Bob` asks `Bob` to issue two new sets of tokens of value `<total>` and `<alice_balance>-<total>` each.
|
||||
- `Alice` performs a split on the amounts `<total>` and `<alice_balance>-<total>` separately as in 2.2 - Request tokens. [TODO: fix reference]
|
||||
- `Alice` constructs two new sets of blinded messages like in 2.3 - Generate blind messages [TODO: fix reference], one for each of the two amounts `<total>` and `<alice_balance>-<total>`.
|
||||
- `Alice` concatenates both sets of blinded messages into the list `<blinded_messages>` [TODO: list?]
|
||||
- `Alice` constructs a JSON out of multiple tokens from her database that sum to `<alice_balance>` of the form `{"amount" : <total>, "proofs" : [{"amount" : <amount>, "secret" : s, "C" : Z}, ...], "outputs" : ["amount" : <amount>, "B_" : <blinded_message>]}`. The blinded messages in `"outputs"` are the list of concatenated blinded message from the previous step. [TODO: refer to this as BlindMessages or something and reuse in Section 4 and 2]
|
||||
|
||||
### 3.2 - Request new tokens for sending
|
||||
- `Alice` constructs a JSON out of multiple tokens of the form `[{"amount" : <amount>, "secret" : s, "C" : Z}, ...]` and serializes is as a Base64 string `TOKEN` which is then sent to `Carol` as a payment of value `sum(<amount_i>)`. [NOTE: rename C, rewrite sum, find consistency in writing labels, values, TOKEN, in code this is called `Proof`]
|
||||
- `Alice` requests new tokens via `POST /mint` with the JSON as the body of the request.
|
||||
- `Alice` receives a JSON of the form `{"fst" : <signatures_to_keep>}, "snd" : <signatures_to_send>` with both entries being of the type `List[BlindedSignature]`. `Alice` constructs proofs `<keep_proofs>` and `<send_proofs>` from both of these entries like in Step 2.5 [TODO: fix reference].
|
||||
- `Alice` stores the proofs `<keep_proofs>` and `<send_proofs>` in her database and flags `<send_proofs>` as `pending` (for example in a separate column).
|
||||
- `Alice` may also give the set of `<send_proofs>` a unique ID `send_id` so that she can later connect each set of pending tokens with every send attempt.
|
||||
|
||||
### 3.3 - Serialize tokens for sending
|
||||
Here, `Alice` serializes the proofs from the set `<send_proofs>` for sending to `Carol`.
|
||||
- `Alice` constructs a JSON of the form `[{"amount" : <amount>, "secret" : s, "C" : Z}, ...]` from `<send_proofs>` and encodes it as a Base64 string using url-safe Base64 encoder. [NOTE: it probably doesn't need to be url-safe, maybe it shouldn't if this is not widespread or consistent across languages]
|
||||
- `Alice` sends the resulting `TOKEN` as the string `W3siYW1vdW50IjogMiwgInNlY3...` to `Carol`.
|
||||
|
||||
## 4 - Receive new tokens
|
||||
Here we describe how `Carol` can redeem new tokens from `Bob` that she previously received from `Alice`. `Carol` receives tokens as a url-safe [NOTE: remove url-safe?] base64-encoded string `TOKEN` that, when decoded, is a JSON of the form `[{"amount" : <amount>, "secret" : s, "C" : Z}, ...]`. In the following, we will refer to the tuple `(<amount>, Z, s)` as a single token. [NOTE: clarify whether a TOKEN is a single token or a list of tokens] To redeem a token, `Carol` sends it to `Bob` and receives a one of the same value.
|
||||
|
||||
`Carol` essentially performs the same procedure to receive tokens as `Alice` did earlier when she prepared her tokens for sending: She sends constructs new blinded messages and sends them together with the tokens she received in order to receive a newly-issued set of tokens which settles the transaction between `Alice` and `Carol`.
|
||||
|
||||
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 [TODO: rename SplitRequests?]
|
||||
|
||||
## 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` 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.
|
||||
|
||||
## 6 - Pay a Lightning invoice
|
||||
Here we describe how `Alice` can request from `Bob` to make a Lightning payment for her and burn an appropriate amount of tokens in return. `Alice` wants to pay a bolt11 invoice with the amount `<invoice_amount>`. She has to add a predefined fee to the request to account for the possible Lightning fees which results in a request with tokens with the total amount of `<total>`. [NOTE: there is no way to do this dynamically as for now. We simply include a amount-dependent fee with the request and the mint essentially keeps the difference if it can find a cheaper-than-expected route. The mint refuses to pay the invoice if the fees included are not high-enough.]
|
||||
|
||||
- `Alice` wants to pay the bolt11 invoice `<invoice>`.
|
||||
- `Alice` calculates the fees for the Lightning payments upfront with the function `max(<MIN_FEE>, <invoice_amount> * <PROPORTIONAL_FEE>*)` with `<MIN_FEE>` currently being `4` Satoshis and `<PROPORTIONAL_FEE>` being `0.01` (or 1% of `<invoice_amount>`). `Alice` then adds this fee to `<invoice_amount>` and rounds it up to the next higher integer which results in `<amount>`.
|
||||
- `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>` that she can send for making the Lightning payment.
|
||||
- `Alice` constructs the JSON `MeltRequest` of the form `{"proofs" : <List[Proof]>, "amount" : <total>, "invoice" : <invoice>}` [NOTE: Maybe use notation List[Proof] everywhere. Used MeltRequest 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]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Todo:
|
||||
- Call subsections 1. and 1.2 etc so they can be referenced
|
||||
- Define objets like `MintRequest` and `SplitRequests` once when they appear and reuse them.
|
||||
- Clarify whether a `TOKEN` is a single Proof or a list of Proofs
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
from re import S
|
||||
|
||||
import pytest
|
||||
|
||||
from cashu.core.helpers import async_unwrap
|
||||
from cashu.core.migrations import migrate_databases
|
||||
from cashu.wallet import migrations
|
||||
@@ -25,6 +27,7 @@ def assert_amt(proofs, expected):
|
||||
assert [p["amount"] for p in proofs] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def run_test():
|
||||
wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1", "wallet1")
|
||||
await migrate_databases(wallet1.db, migrations)
|
||||
@@ -48,7 +51,7 @@ async def run_test():
|
||||
proofs += await wallet1.mint(63)
|
||||
assert wallet1.balance == 64 + 63
|
||||
|
||||
w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs, 65)
|
||||
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(wallet1.proofs, 65)
|
||||
assert wallet1.balance == 63 + 64
|
||||
wallet1.status()
|
||||
|
||||
@@ -60,12 +63,12 @@ async def run_test():
|
||||
assert wallet1.balance == 63 + 64
|
||||
wallet1.status()
|
||||
|
||||
w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs, 20)
|
||||
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(wallet1.proofs, 20)
|
||||
# we expect 44 and 20 -> [4, 8, 32], [4, 16]
|
||||
print(w1_fst_proofs)
|
||||
print(w1_snd_proofs)
|
||||
# assert [p["amount"] for p in w1_fst_proofs] == [4, 8, 32]
|
||||
assert [p["amount"] for p in w1_snd_proofs] == [4, 16]
|
||||
print(w1_frst_proofs)
|
||||
print(w1_scnd_proofs)
|
||||
# assert [p["amount"] for p in w1_frst_proofs] == [4, 8, 32]
|
||||
assert [p["amount"] for p in w1_scnd_proofs] == [4, 16]
|
||||
assert wallet1.balance == 63 + 64
|
||||
wallet1.status()
|
||||
|
||||
@@ -79,29 +82,29 @@ async def run_test():
|
||||
wallet1.status()
|
||||
|
||||
# Redeem the tokens in wallet2
|
||||
w2_fst_proofs, w2_snd_proofs = await wallet2.redeem(w1_snd_proofs)
|
||||
print(w2_fst_proofs)
|
||||
print(w2_snd_proofs)
|
||||
w2_frst_proofs, w2_scnd_proofs = await wallet2.redeem(w1_scnd_proofs)
|
||||
print(w2_frst_proofs)
|
||||
print(w2_scnd_proofs)
|
||||
assert wallet1.balance == 63 + 64
|
||||
assert wallet2.balance == 20
|
||||
wallet2.status()
|
||||
|
||||
# wallet1 invalidates his proofs
|
||||
await wallet1.invalidate(w1_snd_proofs)
|
||||
await wallet1.invalidate(w1_scnd_proofs)
|
||||
assert wallet1.balance == 63 + 64 - 20
|
||||
wallet1.status()
|
||||
|
||||
w1_fst_proofs2, w1_snd_proofs2 = await wallet1.split(w1_fst_proofs, 5)
|
||||
w1_frst_proofs2, w1_scnd_proofs2 = await wallet1.split(w1_frst_proofs, 5)
|
||||
# we expect 15 and 5 -> [1, 2, 4, 8], [1, 4]
|
||||
print(w1_fst_proofs2)
|
||||
print(w1_snd_proofs2)
|
||||
print(w1_frst_proofs2)
|
||||
print(w1_scnd_proofs2)
|
||||
assert wallet1.balance == 63 + 64 - 20
|
||||
wallet1.status()
|
||||
|
||||
# Error: We try to double-spend and it fails
|
||||
await assert_err(
|
||||
wallet1.split(w1_snd_proofs, 5),
|
||||
f"Mint Error: tokens already spent. Secret: {w1_snd_proofs[0]['secret']}",
|
||||
wallet1.split(w1_scnd_proofs, 5),
|
||||
f"Mint Error: tokens already spent. Secret: {w1_scnd_proofs[0]['secret']}",
|
||||
)
|
||||
|
||||
assert wallet1.balance == 63 + 64 - 20
|
||||
@@ -111,23 +114,23 @@ async def run_test():
|
||||
assert wallet2.proof_amounts() == [4, 16]
|
||||
|
||||
# manipulate the proof amount
|
||||
# w1_fst_proofs2_manipulated = w1_fst_proofs2.copy()
|
||||
# w1_fst_proofs2_manipulated[0]["amount"] = 123
|
||||
# w1_frst_proofs2_manipulated = w1_frst_proofs2.copy()
|
||||
# w1_frst_proofs2_manipulated[0]["amount"] = 123
|
||||
# await assert_err(
|
||||
# wallet1.split(w1_fst_proofs2_manipulated, 20),
|
||||
# wallet1.split(w1_frst_proofs2_manipulated, 20),
|
||||
# "Error: 123",
|
||||
# )
|
||||
|
||||
# try to split an invalid amount
|
||||
await assert_err(
|
||||
wallet1.split(w1_snd_proofs, -500),
|
||||
wallet1.split(w1_scnd_proofs, -500),
|
||||
"Mint Error: invalid split amount: -500",
|
||||
)
|
||||
|
||||
# mint with secrets
|
||||
secret = f"asdasd_{time.time()}"
|
||||
w1_fst_proofs, w1_snd_proofs = await wallet1.split(
|
||||
wallet1.proofs, 65, snd_secret=secret
|
||||
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(
|
||||
wallet1.proofs, 65, scnd_secret=secret
|
||||
)
|
||||
|
||||
# p2sh test
|
||||
@@ -136,15 +139,15 @@ async def run_test():
|
||||
lock = f"P2SH:{txin_p2sh_address}"
|
||||
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock)
|
||||
_, _ = await wallet2.redeem(
|
||||
send_proofs, snd_script=p2shscript.script, snd_siganture=p2shscript.signature
|
||||
send_proofs, scnd_script=p2shscript.script, scnd_siganture=p2shscript.signature
|
||||
)
|
||||
|
||||
# strip away the secrets
|
||||
w1_snd_proofs_manipulated = w1_snd_proofs.copy()
|
||||
for p in w1_snd_proofs_manipulated:
|
||||
w1_scnd_proofs_manipulated = w1_scnd_proofs.copy()
|
||||
for p in w1_scnd_proofs_manipulated:
|
||||
p.secret = ""
|
||||
await assert_err(
|
||||
wallet2.redeem(w1_snd_proofs_manipulated),
|
||||
wallet2.redeem(w1_scnd_proofs_manipulated),
|
||||
"Mint Error: no secret in proof.",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user