Merge pull request #1052 from motorina0/cashu3

Cashu3
This commit is contained in:
Arc
2022-10-12 06:39:24 +01:00
committed by GitHub
25 changed files with 3571 additions and 816 deletions

View File

@@ -1,6 +1,7 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
@@ -10,13 +11,24 @@ db = Database("ext_cashu")
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
cashu_static_files = [
{
"path": "/cashu/static",
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
"name": "cashu_static",
}
]
def cashu_renderer():
return template_renderer(["lnbits/extensions/cashu/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def cashu_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@@ -2,5 +2,6 @@
"name": "Cashu Ecash",
"short_description": "Ecash mints with LN peg in/out",
"icon": "approval",
"contributors": ["arcbtc", "calle"]
"contributors": ["arcbtc", "calle"],
"hidden": false
}

View File

@@ -0,0 +1,7 @@
{
"name": "Cashu Ecash",
"short_description": "Ecash mints with LN peg in/out",
"icon": "approval",
"contributors": ["arcbtc", "calle"],
"hidden": true
}

View File

@@ -0,0 +1,88 @@
# Don't trust me with cryptography.
"""
Implementation of https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406
Alice:
A = a*G
return A
Bob:
Y = hash_to_curve(secret_message)
r = random blinding factor
B'= Y + r*G
return B'
Alice:
C' = a*B'
(= a*Y + a*r*G)
return C'
Bob:
C = C' - r*A
(= C' - a*r*G)
(= a*Y)
return C, secret_message
Alice:
Y = hash_to_curve(secret_message)
C == a*Y
If true, C must have originated from Alice
"""
import hashlib
from secp256k1 import PrivateKey, PublicKey
def hash_to_curve(message: bytes):
"""Generates a point from the message hash and checks if the point lies on the curve.
If it does not, it tries computing again a new x coordinate from the hash of the coordinate."""
point = None
msg_to_hash = message
while point is None:
try:
_hash = hashlib.sha256(msg_to_hash).digest()
point = PublicKey(b"\x02" + _hash, raw=True)
except:
msg_to_hash = _hash
return point
def step1_alice(secret_msg):
secret_msg = secret_msg
Y = hash_to_curve(secret_msg)
r = PrivateKey()
B_ = Y + r.pubkey
return B_, r
def step2_bob(B_, a):
C_ = B_.mult(a)
return C_
def step3_alice(C_, r, A):
C = C_ - A.mult(r)
return C
def verify(a, C, secret_msg):
Y = hash_to_curve(secret_msg)
return C == Y.mult(a)
### Below is a test of a simple positive and negative case
# # Alice's keys
# a = PrivateKey()
# A = a.pubkey
# secret_msg = "test"
# B_, r = step1_alice(secret_msg)
# C_ = step2_bob(B_, a)
# C = step3_alice(C_, r, A)
# print("C:{}, secret_msg:{}".format(C, secret_msg))
# assert verify(a, C, secret_msg)
# assert verify(a, C + C, secret_msg) == False # adding C twice shouldn't pass
# assert verify(a, A, secret_msg) == False # A shouldn't pass
# # Test operations
# b = PrivateKey()
# B = b.pubkey
# assert -A -A + A == -A # neg
# assert B.mult(a) == A.mult(b) # a*B = A*b

View File

@@ -0,0 +1,168 @@
from sqlite3 import Row
from typing import List, Union
from pydantic import BaseModel
class CashuError(BaseException):
code = "000"
error = "CashuError"
class P2SHScript(BaseModel):
script: str
signature: str
address: Union[str, None] = None
@classmethod
def from_row(cls, row: Row):
return cls(
address=row[0],
script=row[1],
signature=row[2],
used=row[3],
)
class Proof(BaseModel):
amount: int
secret: str = ""
C: str
script: Union[P2SHScript, None] = None
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
time_created: str = ""
time_reserved: str = ""
@classmethod
def from_row(cls, row: Row):
return cls(
amount=row[0],
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "amount" in d, "no amount in proof"
return cls(
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret") or "",
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
def to_dict(self):
return dict(amount=self.amount, secret=self.secret, C=self.C)
def to_dict_no_secret(self):
return dict(amount=self.amount, C=self.C)
def __getitem__(self, key):
return self.__getattribute__(key)
def __setitem__(self, key, val):
self.__setattr__(key, val)
class Proofs(BaseModel):
"""TODO: Use this model"""
proofs: List[Proof]
class Invoice(BaseModel):
amount: int
pr: str
hash: str
issued: bool = False
@classmethod
def from_row(cls, row: Row):
return cls(
cashu_id=str(row[0]),
amount=int(row[1]),
pr=str(row[2]),
hash=str(row[3]),
issued=bool(row[4]),
)
class BlindedMessage(BaseModel):
amount: int
B_: str
class BlindedSignature(BaseModel):
amount: int
C_: str
@classmethod
def from_dict(cls, d: dict):
return cls(
amount=d["amount"],
C_=d["C_"],
)
class MintRequest(BaseModel):
blinded_messages: List[BlindedMessage] = []
class GetMintResponse(BaseModel):
pr: str
hash: str
class GetMeltResponse(BaseModel):
paid: Union[bool, None]
preimage: Union[str, None]
class SplitRequest(BaseModel):
proofs: List[Proof]
amount: int
output_data: Union[
MintRequest, None
] = None # backwards compatibility with clients < v0.2.2
outputs: Union[MintRequest, None] = None
def __init__(self, **data):
super().__init__(**data)
self.backwards_compatibility_v021()
def backwards_compatibility_v021(self):
# before v0.2.2: output_data, after: outputs
if self.output_data:
self.outputs = self.output_data
self.output_data = None
class PostSplitResponse(BaseModel):
fst: List[BlindedSignature]
snd: List[BlindedSignature]
class CheckRequest(BaseModel):
proofs: List[Proof]
class CheckFeesRequest(BaseModel):
pr: str
class CheckFeesResponse(BaseModel):
fee: Union[int, None]
class MeltRequest(BaseModel):
proofs: List[Proof]
amount: int = None # deprecated
invoice: str

View File

@@ -0,0 +1,52 @@
from secp256k1 import PrivateKey, PublicKey
# We extend the public key to define some operations on points
# Picked from https://github.com/WTRMQDev/secp256k1-zkp-py/blob/master/secp256k1_zkp/__init__.py
class PublicKeyExt(PublicKey):
def __add__(self, pubkey2):
if isinstance(pubkey2, PublicKey):
new_pub = PublicKey()
new_pub.combine([self.public_key, pubkey2.public_key])
return new_pub
else:
raise TypeError("Cant add pubkey and %s" % pubkey2.__class__)
def __neg__(self):
serialized = self.serialize()
first_byte, remainder = serialized[:1], serialized[1:]
# flip odd/even byte
first_byte = {b"\x03": b"\x02", b"\x02": b"\x03"}[first_byte]
return PublicKey(first_byte + remainder, raw=True)
def __sub__(self, pubkey2):
if isinstance(pubkey2, PublicKey):
return self + (-pubkey2)
else:
raise TypeError("Can't add pubkey and %s" % pubkey2.__class__)
def mult(self, privkey):
if isinstance(privkey, PrivateKey):
return self.tweak_mul(privkey.private_key)
else:
raise TypeError("Can't multiply with non privatekey")
def __eq__(self, pubkey2):
if isinstance(pubkey2, PublicKey):
seq1 = self.to_data()
seq2 = pubkey2.to_data()
return seq1 == seq2
else:
raise TypeError("Can't compare pubkey and %s" % pubkey2.__class__)
def to_data(self):
return [self.public_key.data[i] for i in range(64)]
# Horrible monkeypatching
PublicKey.__add__ = PublicKeyExt.__add__
PublicKey.__neg__ = PublicKeyExt.__neg__
PublicKey.__sub__ = PublicKeyExt.__sub__
PublicKey.mult = PublicKeyExt.mult
PublicKey.__eq__ = PublicKeyExt.__eq__
PublicKey.to_data = PublicKeyExt.to_data

View File

@@ -0,0 +1,8 @@
def amount_split(amount):
"""Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8]."""
bits_amt = bin(amount)[::-1][:-2]
rv = []
for (pos, bit) in enumerate(bits_amt):
if bit == "1":
rv.append(2**pos)
return rv

View File

@@ -1,21 +1,18 @@
import os
import random
from binascii import hexlify, unhexlify
from typing import List, Optional, Union
from embit import bip32, bip39, ec, script
from embit.networks import NETWORKS
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Cashu, Pegs, Proof, Promises
from .core.base import Invoice
from .models import Cashu, Pegs, Promises, Proof
from embit import script
from embit import ec
from embit.networks import NETWORKS
from embit import bip32
from embit import bip39
from binascii import unhexlify, hexlify
import random
from loguru import logger
async def create_cashu(wallet_id: str, data: Cashu) -> Cashu:
cashu_id = urlsafe_short_hash()
@@ -24,7 +21,7 @@ async def create_cashu(wallet_id: str, data: Cashu) -> Cashu:
mnemonic = bip39.mnemonic_from_bytes(entropy)
seed = bip39.mnemonic_to_seed(mnemonic)
root = bip32.HDKey.from_seed(seed, version=NETWORKS["main"]["xprv"])
bip44_xprv = root.derive("m/44h/1h/0h")
bip44_xpub = bip44_xprv.to_public()
@@ -42,7 +39,7 @@ async def create_cashu(wallet_id: str, data: Cashu) -> Cashu:
data.maxsats,
data.coins,
bip44_xprv.to_base58(),
bip44_xpub.to_base58()
bip44_xpub.to_base58(),
),
)
@@ -56,11 +53,16 @@ async def update_cashu_keys(cashu_id, wif: str = None) -> Optional[Cashu]:
mnemonic = bip39.mnemonic_from_bytes(entropy)
seed = bip39.mnemonic_to_seed(mnemonic)
root = bip32.HDKey.from_seed(seed, version=NETWORKS["main"]["xprv"])
bip44_xprv = root.derive("m/44h/1h/0h")
bip44_xpub = bip44_xprv.to_public()
await db.execute("UPDATE cashu.cashu SET prv = ?, pub = ? WHERE id = ?", bip44_xprv.to_base58(), bip44_xpub.to_base58(), cashu_id)
await db.execute(
"UPDATE cashu.cashu SET prv = ?, pub = ? WHERE id = ?",
bip44_xprv.to_base58(),
bip44_xpub.to_base58(),
cashu_id,
)
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
return Cashu(**row) if row else None
@@ -90,54 +92,95 @@ async def delete_cashu(cashu_id) -> None:
###############MINT STUFF#################
##########################################
async def store_promise(
amount: int,
B_: str,
C_: str,
cashu_id
async def store_promises(
amounts: List[int], B_s: List[str], C_s: List[str], cashu_id: str
):
for amount, B_, C_ in zip(amounts, B_s, C_s):
await store_promise(amount, B_, C_, cashu_id)
async def store_promise(amount: int, B_: str, C_: str, cashu_id: str):
promise_id = urlsafe_short_hash()
await (conn or db).execute(
await db.execute(
"""
INSERT INTO cashu.promises
(id, amount, B_b, C_b, cashu_id)
VALUES (?, ?, ?, ?, ?)
""",
(
promise_id,
amount,
str(B_),
str(C_),
cashu_id
),
(promise_id, amount, str(B_), str(C_), cashu_id),
)
async def get_promises(cashu_id) -> Optional[Cashu]:
row = await db.fetchall("SELECT * FROM cashu.promises WHERE cashu_id = ?", (promises_id,))
row = await db.fetchall(
"SELECT * FROM cashu.promises WHERE cashu_id = ?", (cashu_id,)
)
return Promises(**row) if row else None
async def get_proofs_used(cashu_id):
rows = await db.fetchall("SELECT secret from cashu.proofs_used WHERE id = ?", (cashu_id,))
rows = await db.fetchall(
"SELECT secret from cashu.proofs_used WHERE cashu_id = ?", (cashu_id,)
)
return [row[0] for row in rows]
async def invalidate_proof(
proof: Proof,
cashu_id
):
async def invalidate_proof(cashu_id: str, proof: Proof):
invalidate_proof_id = urlsafe_short_hash()
await (conn or db).execute(
await db.execute(
"""
INSERT INTO cashu.proofs_used
(id, amount, C, secret, cashu_id)
VALUES (?, ?, ?, ?, ?)
""",
(invalidate_proof_id, proof.amount, str(proof.C), str(proof.secret), cashu_id),
)
########################################
############ MINT INVOICES #############
########################################
async def store_lightning_invoice(cashu_id: str, invoice: Invoice):
await db.execute(
"""
INSERT INTO cashu.invoices
(cashu_id, amount, pr, hash, issued)
VALUES (?, ?, ?, ?, ?)
""",
(
invalidate_proof_id,
proof.amount,
str(proof.C),
str(proof.secret),
cashu_id
cashu_id,
invoice.amount,
invoice.pr,
invoice.hash,
invoice.issued,
),
)
)
async def get_lightning_invoice(cashu_id: str, hash: str):
row = await db.fetchone(
"""
SELECT * from cashu.invoices
WHERE cashu_id =? AND hash = ?
""",
(
cashu_id,
hash,
),
)
return Invoice.from_row(row)
async def update_lightning_invoice(cashu_id: str, hash: str, issued: bool):
await db.execute(
"UPDATE cashu.invoices SET issued = ? WHERE cashu_id = ? AND hash = ?",
(
issued,
cashu_id,
hash,
),
)

View File

@@ -1,13 +1,15 @@
import hashlib
from typing import List, Set
from .models import BlindedMessage, BlindedSignature, Invoice, Proof
from secp256k1 import PublicKey, PrivateKey
from fastapi import Query
from .crud import get_cashu
from secp256k1 import PrivateKey, PublicKey
from lnbits.core.services import check_transaction_status, create_invoice
from .crud import get_cashu
from .models import BlindedMessage, BlindedSignature, Invoice, Proof
def _derive_keys(master_key: str, cashu_id: str = Query(None)):
"""Deterministic derivation of keys for 2^n values."""
return {
@@ -21,29 +23,34 @@ def _derive_keys(master_key: str, cashu_id: str = Query(None)):
for i in range(MAX_ORDER)
}
def _derive_pubkeys(keys: List[PrivateKey], cashu_id: str = Query(None)):
return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]}
async def _generate_promises(amounts: List[int], B_s: List[str], cashu_id: str = Query(None)):
async def _generate_promises(
amounts: List[int], B_s: List[str], cashu_id: str = Query(None)
):
"""Generates promises that sum to the given amount."""
return [
await self._generate_promise(amount, PublicKey(bytes.fromhex(B_), raw=True))
for (amount, B_) in zip(amounts, B_s)
]
async def _generate_promise(amount: int, B_: PublicKey, cashu_id: str = Query(None)):
"""Generates a promise for given amount and returns a pair (amount, C')."""
secret_key = self.keys[amount] # Get the correct key
C_ = step2_bob(B_, secret_key)
await store_promise(
amount, B_=B_.serialize().hex(), C_=C_.serialize().hex()
)
await store_promise(amount, B_=B_.serialize().hex(), C_=C_.serialize().hex())
return BlindedSignature(amount=amount, C_=C_.serialize().hex())
def _check_spendable(proof: Proof, cashu_id: str = Query(None)):
"""Checks whether the proof was already spent."""
return not proof.secret in self.proofs_used
def _verify_proof(proof: Proof, cashu_id: str = Query(None)):
"""Verifies that the proof of promise was issued by this ledger."""
if not self._check_spendable(proof):
@@ -52,7 +59,13 @@ def _verify_proof(proof: Proof, cashu_id: str = Query(None)):
C = PublicKey(bytes.fromhex(proof.C), raw=True)
return verify(secret_key, C, proof.secret)
def _verify_outputs(total: int, amount: int, output_data: List[BlindedMessage], cashu_id: str = Query(None)):
def _verify_outputs(
total: int,
amount: int,
output_data: List[BlindedMessage],
cashu_id: str = Query(None),
):
"""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)
@@ -61,7 +74,10 @@ def _verify_outputs(total: int, amount: int, output_data: List[BlindedMessage],
given = [o.amount for o in output_data]
return given == expected
def _verify_no_duplicates(proofs: List[Proof], output_data: List[BlindedMessage], cashu_id: str = Query(None)):
def _verify_no_duplicates(
proofs: List[Proof], output_data: List[BlindedMessage], cashu_id: str = Query(None)
):
secrets = [p.secret for p in proofs]
if len(secrets) != len(list(set(secrets))):
return False
@@ -70,6 +86,7 @@ def _verify_no_duplicates(proofs: List[Proof], output_data: List[BlindedMessage]
return False
return True
def _verify_split_amount(amount: int, cashu_id: str = Query(None)):
"""Split amount like output amount can't be negative or too big."""
try:
@@ -78,6 +95,7 @@ def _verify_split_amount(amount: int, cashu_id: str = Query(None)):
# For better error message
raise Exception("invalid split amount: " + str(amount))
def _verify_amount(amount: int, cashu_id: str = Query(None)):
"""Any amount used should be a positive integer not larger than 2^MAX_ORDER."""
valid = isinstance(amount, int) and amount > 0 and amount < 2**MAX_ORDER
@@ -85,12 +103,16 @@ def _verify_amount(amount: int, cashu_id: str = Query(None)):
raise Exception("invalid amount: " + str(amount))
return amount
def _verify_equation_balanced(proofs: List[Proof], outs: List[BlindedMessage], cashu_id: str = Query(None)):
def _verify_equation_balanced(
proofs: List[Proof], outs: List[BlindedMessage], cashu_id: str = Query(None)
):
"""Verify that Σoutputs - Σinputs = 0."""
sum_inputs = sum(self._verify_amount(p.amount) for p in proofs)
sum_outputs = sum(self._verify_amount(p.amount) for p in outs)
assert sum_outputs - sum_inputs == 0
def _get_output_split(amount: int, cashu_id: str):
"""Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8]."""
self._verify_amount(amount)
@@ -101,6 +123,7 @@ def _get_output_split(amount: int, cashu_id: str):
rv.append(2**pos)
return rv
async def _invalidate_proofs(proofs: List[Proof], cashu_id: str = Query(None)):
"""Adds secrets of proofs to the list of knwon secrets and stores them in the db."""
# Mark proofs as used and prepare new promises
@@ -110,10 +133,12 @@ async def _invalidate_proofs(proofs: List[Proof], cashu_id: str = Query(None)):
for p in proofs:
await invalidate_proof(p)
def get_pubkeys(cashu_id: str = Query(None)):
"""Returns public keys for possible amounts."""
return {a: p.serialize().hex() for a, p in self.pub_keys.items()}
async def request_mint(amount, cashu_id: str = Query(None)):
cashu = await get_cashu(cashu_id)
if not cashu:
@@ -125,9 +150,7 @@ async def request_mint(amount, cashu_id: str = Query(None)):
amount=amount,
memo=cashu.name,
unhashed_description=cashu.name.encode("utf-8"),
extra={
"tag": "Cashu"
},
extra={"tag": "Cashu"},
)
invoice = Invoice(
@@ -137,15 +160,23 @@ async def request_mint(amount, cashu_id: str = Query(None)):
raise Exception(f"Could not create Lightning invoice.")
return payment_request, payment_hash
async def mint(B_s: List[PublicKey], amounts: List[int], payment_hash: str = Query(None), cashu_id: str = Query(None)):
async def mint(
B_s: List[PublicKey],
amounts: List[int],
payment_hash: str = Query(None),
cashu_id: str = Query(None),
):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
"""Mints a promise for coins for B_."""
# check if lightning invoice was paid
if payment_hash:
if not await check_transaction_status(wallet_id=cashu.wallet, payment_hash=payment_hash):
if payment_hash:
if not await check_transaction_status(
wallet_id=cashu.wallet, payment_hash=payment_hash
):
raise Exception("Lightning invoice not paid yet.")
for amount in amounts:
@@ -157,11 +188,14 @@ async def mint(B_s: List[PublicKey], amounts: List[int], payment_hash: str = Que
]
return promises
async def melt(proofs: List[Proof], amount: int, invoice: str, cashu_id: str = Query(None)):
async def melt(
proofs: List[Proof], amount: int, invoice: str, cashu_id: str = Query(None)
):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
raise Exception(f"Could not find Cashu")
"""Invalidates proofs and pays a Lightning invoice."""
# if not LIGHTNING:
total = sum([p["amount"] for p in proofs])
@@ -181,6 +215,7 @@ async def melt(proofs: List[Proof], amount: int, invoice: str, cashu_id: str = Q
await self._invalidate_proofs(proofs)
return status, payment_hash
async def check_spendable(proofs: List[Proof], cashu_id: str = Query(None)):
cashu = await get_cashu(cashu_id)
if not cashu:
@@ -189,7 +224,13 @@ async def check_spendable(proofs: List[Proof], cashu_id: str = Query(None)):
"""Checks if all provided proofs are valid and still spendable (i.e. have not been spent)."""
return {i: self._check_spendable(p) for i, p in enumerate(proofs)}
async def split(proofs: List[Proof], amount: int, output_data: List[BlindedMessage], cashu_id: str = Query(None)):
async def split(
proofs: List[Proof],
amount: int,
output_data: List[BlindedMessage],
cashu_id: str = Query(None),
):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
@@ -226,18 +267,19 @@ async def split(proofs: List[Proof], amount: int, output_data: List[BlindedMessa
async def fee_reserve(amount_msat: int, cashu_id: str = Query(None)):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
raise Exception(f"Could not find Cashu")
"""Function for calculating the Lightning fee reserve"""
return max(
int(LIGHTNING_RESERVE_FEE_MIN), int(amount_msat * LIGHTNING_FEE_PERCENT / 100.0)
)
async def amount_split(amount, cashu_id: str):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
raise Exception(f"Could not find Cashu")
"""Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8]."""
bits_amt = bin(amount)[::-1][:-2]
rv = []
@@ -246,11 +288,12 @@ async def amount_split(amount, cashu_id: str):
rv.append(2**pos)
return rv
async def hash_to_point(secret_msg, cashu_id: str = Query(None)):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
raise Exception(f"Could not find Cashu")
"""Generates x coordinate from the message hash and checks if the point lies on the curve.
If it does not, it tries computing again a new x coordinate from the hash of the coordinate."""
point = None
@@ -280,10 +323,11 @@ async def step1_alice(secret_msg, cashu_id: str = Query(None)):
B_ = Y + r.pubkey
return B_, r
async def step2_bob(B_, a, cashu_id: str = Query(None)):
cashu = await get_cashu(cashu_id)
if not cashu:
raise Exception(f"Could not find Cashu")
raise Exception(f"Could not find Cashu")
C_ = B_.mult(a)
return C_

View File

@@ -43,6 +43,7 @@ async def m001_initial(db):
B_b TEXT NOT NULL,
C_b TEXT NOT NULL,
cashu_id TEXT NOT NULL
UNIQUE (B_b)
);
"""
)
@@ -60,4 +61,19 @@ async def m001_initial(db):
cashu_id TEXT NOT NULL
);
"""
)
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS cashu.invoices (
cashu_id TEXT NOT NULL,
amount INTEGER NOT NULL,
pr TEXT NOT NULL,
hash TEXT NOT NULL,
issued BOOL NOT NULL,
UNIQUE (hash)
);
"""
)

View File

@@ -0,0 +1,155 @@
import math
from typing import List, Set
from lnbits import bolt11
from lnbits.core.services import check_transaction_status, fee_reserve, pay_invoice
from lnbits.wallets.base import PaymentStatus
from .core.b_dhke import step2_bob
from .core.base import BlindedMessage, BlindedSignature, Proof
from .core.secp import PublicKey
from .core.split import amount_split
from .crud import get_proofs_used, invalidate_proof
from .mint_helper import (
derive_keys,
derive_pubkeys,
verify_equation_balanced,
verify_no_duplicates,
verify_outputs,
verify_proof,
verify_secret_criteria,
verify_split_amount,
)
from .models import Cashu
# todo: extract const
MAX_ORDER = 64
def get_pubkeys(xpriv: str):
"""Returns public keys for possible amounts."""
keys = derive_keys(xpriv)
pub_keys = derive_pubkeys(keys)
return {a: p.serialize().hex() for a, p in pub_keys.items()}
async def generate_promises(
master_prvkey: str, amounts: List[int], B_s: List[PublicKey]
):
"""Mints a promise for coins for B_."""
for amount in amounts:
if amount not in [2**i for i in range(MAX_ORDER)]:
raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.")
promises = [
await generate_promise(master_prvkey, amount, B_)
for B_, amount in zip(B_s, amounts)
]
return promises
async def generate_promise(master_prvkey: str, amount: int, B_: PublicKey):
"""Generates a promise for given amount and returns a pair (amount, C')."""
secret_key = derive_keys(master_prvkey)[amount] # Get the correct key
C_ = step2_bob(B_, secret_key)
return BlindedSignature(amount=amount, C_=C_.serialize().hex())
async def melt(cashu: Cashu, proofs: List[Proof], invoice: str):
"""Invalidates proofs and pays a Lightning invoice."""
# Verify proofs
proofs_used: Set[str] = set(await get_proofs_used(cashu.id))
for p in proofs:
await verify_proof(cashu.prvkey, proofs_used, p)
total_provided = sum([p["amount"] for p in proofs])
invoice_obj = bolt11.decode(invoice)
amount = math.ceil(invoice_obj.amount_msat / 1000)
fees_msat = await check_fees(cashu.wallet, invoice_obj)
assert total_provided >= amount + fees_msat / 1000, Exception(
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
)
await pay_invoice(
wallet_id=cashu.wallet,
payment_request=invoice,
description=f"pay cashu invoice",
extra={"tag": "cashu", "cahsu_name": cashu.name},
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, invoice_obj.payment_hash
)
if status.paid == True:
await invalidate_proofs(cashu.id, proofs)
return status.paid, status.preimage
return False, ""
async def check_fees(wallet_id: str, decoded_invoice):
"""Returns the fees (in msat) required to pay this pr."""
amount = math.ceil(decoded_invoice.amount_msat / 1000)
status: PaymentStatus = await check_transaction_status(
wallet_id, decoded_invoice.payment_hash
)
fees_msat = fee_reserve(amount * 1000) if status.paid != True else 0
return fees_msat
async def split(
cashu: Cashu, proofs: List[Proof], amount: int, outputs: List[BlindedMessage]
):
"""Consumes proofs and prepares new promises based on the amount split."""
total = sum([p.amount for p in proofs])
# verify that amount is kosher
verify_split_amount(amount)
# verify overspending attempt
if amount > total:
raise Exception(
f"split amount ({amount}) is higher than the total sum ({total})."
)
# Verify secret criteria
if not all([verify_secret_criteria(p) for p in proofs]):
raise Exception("secrets do not match criteria.")
# verify that only unique proofs and outputs were used
if not verify_no_duplicates(proofs, outputs):
raise Exception("duplicate proofs or promises.")
# verify that outputs have the correct amount
if not verify_outputs(total, amount, outputs): # ?
raise Exception("split of promises is not as expected.")
# Verify proofs
# Verify proofs
proofs_used: Set[str] = set(await get_proofs_used(cashu.id))
for p in proofs:
await verify_proof(cashu.prvkey, proofs_used, p)
# Mark proofs as used and prepare new promises
await invalidate_proofs(cashu.id, proofs)
outs_fst = amount_split(total - amount)
outs_snd = amount_split(amount)
B_fst = [
PublicKey(bytes.fromhex(od.B_), raw=True) for od in outputs[: len(outs_fst)]
]
B_snd = [
PublicKey(bytes.fromhex(od.B_), raw=True) for od in outputs[len(outs_fst) :]
]
# PublicKey(bytes.fromhex(payload.B_), raw=True)
prom_fst, prom_snd = await generate_promises(
cashu.prvkey, outs_fst, B_fst
), await generate_promises(cashu.prvkey, outs_snd, B_snd)
# verify amounts in produced proofs
verify_equation_balanced(proofs, prom_fst + prom_snd)
return prom_fst, prom_snd
async def invalidate_proofs(cashu_id: str, proofs: List[Proof]):
"""Adds secrets of proofs to the list of knwon secrets and stores them in the db."""
for p in proofs:
await invalidate_proof(cashu_id, p)

View File

@@ -0,0 +1,97 @@
import base64
import hashlib
from typing import List, Set
from .core.b_dhke import verify
from .core.base import BlindedSignature
from .core.secp import PrivateKey, PublicKey
from .core.split import amount_split
from .models import BlindedMessage, Proof
# todo: extract const
MAX_ORDER = 64
def derive_keys(master_key: str):
"""Deterministic derivation of keys for 2^n values."""
return {
2
** i: PrivateKey(
hashlib.sha256((str(master_key) + str(i)).encode("utf-8"))
.hexdigest()
.encode("utf-8")[:32],
raw=True,
)
for i in range(MAX_ORDER)
}
def derive_pubkeys(keys: List[PrivateKey]):
return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]}
# async required?
async def verify_proof(master_prvkey: str, proofs_used: Set[str], proof: Proof):
"""Verifies that the proof of promise was issued by this ledger."""
if proof.secret in proofs_used:
raise Exception(f"tokens already spent. Secret: {proof.secret}")
secret_key = derive_keys(master_prvkey)[
proof.amount
] # Get the correct key to check against
C = PublicKey(bytes.fromhex(proof.C), raw=True)
secret = base64.standard_b64decode(proof.secret)
print("### secret", secret)
validMintSig = verify(secret_key, C, secret)
if validMintSig != True:
raise Exception(f"tokens not valid. Secret: {proof.secret}")
def verify_split_amount(amount: int):
"""Split amount like output amount can't be negative or too big."""
try:
verify_amount(amount)
except:
# For better error message
raise Exception("invalid split amount: " + str(amount))
def verify_secret_criteria(proof: Proof):
if proof.secret is None or proof.secret == "":
raise Exception("no secret in proof.")
return True
def verify_no_duplicates(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 outputs]
if len(B_s) != len(list(set(B_s))):
return False
return True
def verify_outputs(total: int, amount: int, outputs: List[BlindedMessage]):
"""Verifies the expected split was correctly computed"""
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_amount(amount: int):
"""Any amount used should be a positive integer not larger than 2^MAX_ORDER."""
valid = isinstance(amount, int) and amount > 0 and amount < 2**MAX_ORDER
if not valid:
raise Exception("invalid amount: " + str(amount))
return amount
def verify_equation_balanced(proofs: List[Proof], outs: List[BlindedSignature]):
"""Verify that Σoutputs - Σinputs = 0."""
sum_inputs = sum(verify_amount(p.amount) for p in proofs)
sum_outputs = sum(verify_amount(p.amount) for p in outs)
assert sum_outputs - sum_inputs == 0

View File

@@ -1,5 +1,5 @@
from sqlite3 import Row
from typing import Optional, List
from typing import List, Union
from fastapi import Query
from pydantic import BaseModel
@@ -20,20 +20,22 @@ class Cashu(BaseModel):
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class Pegs(BaseModel):
id: str
wallet: str
inout: str
amount: str
@classmethod
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str
class Promises(BaseModel):
id: str
amount: int
@@ -41,6 +43,7 @@ class Promises(BaseModel):
C_b: str
cashu_id: str
class Proof(BaseModel):
amount: int
secret: str
@@ -142,4 +145,4 @@ class CheckPayload(BaseModel):
class MeltPayload(BaseModel):
proofs: List[Proof]
amount: int
invoice: str
invoice: str

View File

@@ -0,0 +1,37 @@
function unescapeBase64Url(str) {
return (str + '==='.slice((str.length + 3) % 4))
.replace(/-/g, '+')
.replace(/_/g, '/')
}
function escapeBase64Url(str) {
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
const uint8ToBase64 = (function (exports) {
'use strict'
var fromCharCode = String.fromCharCode
var encode = function encode(uint8array) {
var output = []
for (var i = 0, length = uint8array.length; i < length; i++) {
output.push(fromCharCode(uint8array[i]))
}
return btoa(output.join(''))
}
var asCharCode = function asCharCode(c) {
return c.charCodeAt(0)
}
var decode = function decode(chars) {
return Uint8Array.from(atob(chars), asCharCode)
}
exports.decode = decode
exports.encode = encode
return exports
})({})

View File

@@ -0,0 +1,36 @@
async function hashToCurve(secretMessage) {
console.log(
'### secretMessage',
nobleSecp256k1.utils.bytesToHex(secretMessage)
)
let point
while (!point) {
const hash = await nobleSecp256k1.utils.sha256(secretMessage)
const hashHex = nobleSecp256k1.utils.bytesToHex(hash)
const pointX = '02' + hashHex
console.log('### pointX', pointX)
try {
point = nobleSecp256k1.Point.fromHex(pointX)
console.log('### point', point.toHex())
} catch (error) {
secretMessage = await nobleSecp256k1.utils.sha256(secretMessage)
}
}
return point
}
async function step1Bob(secretMessage) {
const Y = await hashToCurve(secretMessage)
const randomBlindingFactor = bytesToNumber(
nobleSecp256k1.utils.randomPrivateKey()
)
const P = nobleSecp256k1.Point.fromPrivateKey(randomBlindingFactor)
const B_ = Y.add(P)
return {B_: B_.toHex(true), randomBlindingFactor}
}
function step3Bob(C_, r, A) {
const rInt = BigInt(r)
const C = C_.subtract(A.multiply(rInt))
return C
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
function splitAmount(value) {
const chunks = []
for (let i = 0; i < 32; i++) {
const mask = 1 << i
if ((value & mask) !== 0) chunks.push(Math.pow(2, i))
}
return chunks
}
function bytesToNumber(bytes) {
return hexToNumber(nobleSecp256k1.utils.bytesToHex(bytes))
}
function bigIntStringify(key, value) {
return typeof value === 'bigint' ? value.toString() : value
}
function hexToNumber(hex) {
if (typeof hex !== 'string') {
throw new TypeError('hexToNumber: expected string, got ' + typeof hex)
}
return BigInt(`0x${hex}`)
}

View File

@@ -9,6 +9,7 @@ from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from .crud import get_cashu
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)

View File

@@ -71,7 +71,8 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}cashu/api/v1/cashus/&lt;cashu_id&gt; -H "X-Api-Key: &lt;admin_key&gt;"
}}cashu/api/v1/cashus/&lt;cashu_id&gt; -H "X-Api-Key:
&lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>

View File

@@ -2,13 +2,12 @@
<q-card>
<q-card-section>
<p>
Make Ecash mints with peg in/out to a wallet, that can create and manage ecash.
Make Ecash mints with peg in/out to a wallet, that can create and manage
ecash.
</p>
<small
>Created by
<a href="https://github.com/calle" target="_blank"
>Calle</a
>.</small
<a href="https://github.com/calle" target="_blank">Calle</a>.</small
>
</q-card-section>
</q-card>

View File

@@ -4,7 +4,9 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true">New Mint</q-btn>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Mint</q-btn
>
</q-card-section>
</q-card>
@@ -18,8 +20,14 @@
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat :data="cashus" row-key="id" :columns="cashusTable.columns"
:pagination.sync="cashusTable.pagination">
<q-table
dense
flat
:data="cashus"
row-key="id"
:columns="cashusTable.columns"
:pagination.sync="cashusTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
@@ -34,23 +42,43 @@
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn unelevated dense size="xs" icon="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'wallet/?tsh=' + props.row.tickershort + '&mnt=' + hostname + props.row.id + '&nme=' + props.row.name"
target="_blank"><q-tooltip>Shareable wallet page</q-tooltip></q-btn>
:href="'wallet/?tsh=' + (props.row.tickershort || '') + '&mint_id=' + props.row.id + '&mint_name=' + props.row.name"
target="_blank"
><q-tooltip>Shareable wallet page</q-tooltip></q-btn
>
<q-btn unelevated dense size="xs" icon="account_balance" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
<q-btn
unelevated
dense
size="xs"
icon="account_balance"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'mint/' + props.row.id"
target="_blank"><q-tooltip>Shareable mint page</q-tooltip></q-btn>
target="_blank"
><q-tooltip>Shareable mint page</q-tooltip></q-btn
>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="deleteMint(props.row.id)" icon="cancel" color="pink"></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteMint(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
@@ -79,34 +107,90 @@
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createMint" class="q-gutter-md">
<q-input filled dense v-model.trim="formDialog.data.name" label="Mint Name" placeholder="Cashu Mint"></q-input>
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions"
label="Wallet *" ></q-select>
<q-toggle v-model="toggleAdvanced" label="Show advanced options"></q-toggle>
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Mint Name"
placeholder="Cashu Mint"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced">
<div class="row">
<div class="col-5">
<q-checkbox v-model="formDialog.data.fraction" color="primary" label="sats/coins?">
<q-tooltip>Use with hedging extension to create a stablecoin!</q-tooltip>
<q-checkbox
v-model="formDialog.data.fraction"
color="primary"
label="sats/coins?"
>
<q-tooltip
>Use with hedging extension to create a stablecoin!</q-tooltip
>
</q-checkbox>
</div>
<div class="col-7">
<q-input v-if="!formDialog.data.fraction" filled dense type="number" v-model.trim="formDialog.data.cost" label="Sat coin cost (optional)"
value="1" type="number"></q-input>
<q-input v-if="!formDialog.data.fraction" filled dense v-model.trim="formDialog.data.tickershort" label="Ticker shorthand" placeholder="CC"
#></q-input>
<q-input
v-if="!formDialog.data.fraction"
filled
dense
type="number"
v-model.trim="formDialog.data.cost"
label="Sat coin cost (optional)"
value="1"
type="number"
></q-input>
<q-input
v-if="!formDialog.data.fraction"
filled
dense
v-model.trim="formDialog.data.tickershort"
label="Ticker shorthand"
placeholder="CC"
#
></q-input>
</div>
</div>
<q-input class="q-mt-md" filled dense type="number" v-model.trim="formDialog.data.maxsats"
label="Maximum mint liquidity (optional)" placeholder="∞"></q-input>
<q-input class="q-mt-md" filled dense type="number" v-model.trim="formDialog.data.coins"
label="Coins that 'exist' in mint (optional)" placeholder="∞"></q-input>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.maxsats"
label="Maximum mint liquidity (optional)"
placeholder="∞"
></q-input>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.coins"
label="Coins that 'exist' in mint (optional)"
placeholder="∞"
></q-input>
</div>
<div class="row q-mt-md">
<q-btn unelevated color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null" type="submit">Create Mint
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
type="submit"
>Create Mint
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
@@ -130,12 +214,12 @@
data: function () {
return {
cashus: [],
hostname: location.protocol + "//" + location.host + "/cashu/mint/",
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
toggleAdvanced: false,
cashusTable: {
columns: [
{ name: 'id', align: 'left', label: 'ID', field: 'id' },
{ name: 'name', align: 'left', label: 'Name', field: 'name' },
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'tickershort',
align: 'left',
@@ -165,7 +249,7 @@
align: 'left',
label: 'No. of coins',
field: 'coins'
},
}
],
pagination: {
rowsPerPage: 10
@@ -173,7 +257,7 @@
},
formDialog: {
show: false,
data: { fraction: false }
data: {fraction: false}
}
}
},
@@ -203,7 +287,7 @@
var data = {
name: this.formDialog.data.name,
tickershort: this.formDialog.data.tickershort,
maxliquid: this.formDialog.data.maxliquid,
maxliquid: this.formDialog.data.maxliquid
}
var self = this
@@ -211,7 +295,7 @@
.request(
'POST',
'/cashu/api/v1/cashus',
_.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet })
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
@@ -225,17 +309,19 @@
},
deleteMint: function (cashuId) {
var self = this
var cashu = _.findWhere(this.cashus, { id: cashuId })
var cashu = _.findWhere(this.cashus, {id: cashuId})
console.log(cashu)
LNbits.utils
.confirmDialog('Are you sure you want to delete this Mint? It will suck for users.')
.confirmDialog(
'Are you sure you want to delete this Mint? It will suck for users.'
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/cashu/api/v1/cashus/' + cashuId,
_.findWhere(self.g.user.wallets, { id: cashu.wallet }).adminkey
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
)
.then(function (response) {
self.cashus = _.reject(self.cashus, function (obj) {
@@ -258,4 +344,4 @@
}
})
</script>
{% endblock %}
{% endblock %}

View File

@@ -5,14 +5,17 @@
<q-card-section class="q-pa-none">
<center>
<q-icon
name="account_balance"
class="text-grey"
style="font-size: 10rem"
></q-icon>
name="account_balance"
class="text-grey"
style="font-size: 10rem"
></q-icon>
<h3 class="q-my-none">{{ mint_name }}</h3>
<br />
</center>
<h5 class="q-my-none">Some data about mint here: <br/>* whether its online <br/>* Who to contact for support <br/>* etc...</h5>
<h5 class="q-my-none">
Some data about mint here: <br />* whether its online <br />* Who to
contact for support <br />* etc...
</h5>
</q-card-section>
</q-card>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -22,14 +22,19 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
"cashu/index.html", {"request": request, "user": user.dict()}
)
@cashu_ext.get("/wallet")
async def cashu(request: Request):
return cashu_renderer().TemplateResponse("cashu/wallet.html",{"request": request})
return cashu_renderer().TemplateResponse("cashu/wallet.html", {"request": request})
@cashu_ext.get("/mint/{mintID}")
async def cashu(request: Request, mintID):
cashu = await get_cashu(mintID)
return cashu_renderer().TemplateResponse("cashu/mint.html",{"request": request, "mint_name": cashu.name})
return cashu_renderer().TemplateResponse(
"cashu/mint.html", {"request": request, "mint_name": cashu.name}
)
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
async def manifest(cashu_id: str):

View File

@@ -1,5 +1,5 @@
import json
from http import HTTPStatus
from secp256k1 import PublicKey
from typing import Union
import httpx
@@ -7,34 +7,45 @@ from fastapi import Query
from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger
from secp256k1 import PublicKey
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.wallets.base import PaymentStatus
from . import cashu_ext
from .ledger import get_pubkeys, request_mint, mint
from .core.base import CashuError, PostSplitResponse, SplitRequest
from .crud import (
create_cashu,
delete_cashu,
get_cashu,
get_cashus,
update_cashu_keys
create_cashu,
delete_cashu,
get_cashu,
get_cashus,
get_lightning_invoice,
store_lightning_invoice,
store_promise,
update_lightning_invoice,
)
from .ledger import mint, request_mint
from .mint import generate_promises, get_pubkeys, melt, split
from .models import (
Cashu,
Pegs,
CheckPayload,
MeltPayload,
MintPayloads,
SplitPayload,
PayLnurlWData
Cashu,
CheckPayload,
Invoice,
MeltPayload,
MintPayloads,
PayLnurlWData,
Pegs,
SplitPayload,
)
########################################
#################MINT CRUD##############
########################################
# todo: use /mints
@cashu_ext.get("/api/v1/cashus", status_code=HTTPStatus.OK)
async def api_cashus(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
@@ -47,13 +58,12 @@ async def api_cashus(
@cashu_ext.post("/api/v1/cashus", status_code=HTTPStatus.CREATED)
async def api_cashu_create(
data: Cashu, wallet: WalletTypeInfo = Depends(get_key_type)
):
async def api_cashu_create(data: Cashu, wallet: WalletTypeInfo = Depends(get_key_type)):
cashu = await create_cashu(wallet_id=wallet.wallet.id, data=data)
logger.debug(cashu)
return cashu.dict()
@cashu_ext.post("/api/v1/cashus/upodatekeys", status_code=HTTPStatus.CREATED)
async def api_cashu_update_keys(
data: Cashu, wallet: WalletTypeInfo = Depends(get_key_type)
@@ -73,16 +83,19 @@ async def api_cashu_delete(
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
status_code=HTTPStatus.NOT_FOUND, detail="Cashu does not exist."
)
if cashu.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.")
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your Cashu.")
await delete_cashu(cashu_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
########################################
#################????###################
########################################
@cashu_ext.post("/api/v1/cashus/{cashu_id}/invoices", status_code=HTTPStatus.CREATED)
async def api_cashu_create_invoice(
amount: int = Query(..., ge=1), tipAmount: int = None, cashu_id: str = None
@@ -111,7 +124,8 @@ async def api_cashu_create_invoice(
@cashu_ext.post(
"/api/v1/cashus/{cashu_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
"/api/v1/cashus/{cashu_id}/invoices/{payment_request}/pay",
status_code=HTTPStatus.OK,
)
async def api_cashu_pay_invoice(
lnurl_data: PayLnurlWData, payment_request: str = None, cashu_id: str = None
@@ -192,63 +206,152 @@ async def api_cashu_check_invoice(cashu_id: str, payment_hash: str):
#################MINT###################
########################################
@cashu_ext.get("/keys")
def keys(cashu_id: str):
@cashu_ext.get("/api/v1/cashu/{cashu_id}/keys", status_code=HTTPStatus.OK)
async def keys(cashu_id: str = Query(False)):
"""Get the public keys of the mint"""
return get_pubkeys(cashu_id)
mint = await get_cashu(cashu_id)
if mint is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return get_pubkeys(mint.prvkey)
@cashu_ext.get("/mint")
@cashu_ext.get("/api/v1/cashu/{cashu_id}/mint")
async def mint_pay_request(amount: int = 0, cashu_id: str = Query(None)):
"""Request minting of tokens. Server responds with a Lightning invoice."""
payment_request, payment_hash = await request_mint(amount, cashu_id)
print(f"Lightning invoice: {payment_request}")
cashu = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=cashu.wallet,
amount=amount,
memo=f"{cashu.name}",
extra={"tag": "cashu"},
)
invoice = Invoice(
amount=amount, pr=payment_request, hash=payment_hash, issued=False
)
await store_lightning_invoice(cashu_id, invoice)
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"pr": payment_request, "hash": payment_hash}
@cashu_ext.post("/mint")
async def mint_coins(payloads: MintPayloads, payment_hash: Union[str, None] = None, cashu_id: str = Query(None)):
amounts = []
B_s = []
for payload in payloads.blinded_messages:
amounts.append(payload.amount)
B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True))
promises = await mint(B_s, amounts, payment_hash, cashu_id)
logger.debug(promises)
@cashu_ext.post("/api/v1/cashu/{cashu_id}/mint")
async def mint_coins(
data: MintPayloads,
cashu_id: str = Query(None),
payment_hash: Union[str, None] = None,
):
"""
Requests the minting of tokens belonging to a paid payment request.
Call this endpoint after `GET /mint`.
"""
cashu: Cashu = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
invoice: Invoice = (
None
if payment_hash == None
else await get_lightning_invoice(cashu_id, payment_hash)
)
if invoice is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not have this invoice."
)
if invoice.issued == True:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail="Tokens already issued for this invoice.",
)
total_requested = sum([bm.amount for bm in data.blinded_messages])
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
# todo: revert to: status.paid != True:
if status.paid != True:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
try:
promises = await mint(B_s, amounts, payment_hash, cashu_id)
await update_lightning_invoice(cashu_id, payment_hash, True)
amounts = []
B_s = []
for payload in data.blinded_messages:
amounts.append(payload.amount)
B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True))
promises = await generate_promises(cashu.prvkey, amounts, B_s)
for amount, B_, p in zip(amounts, B_s, promises):
await store_promise(amount, B_.serialize().hex(), p.C_, cashu_id)
return promises
except Exception as exc:
return {"error": str(exc)}
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@cashu_ext.post("/melt")
@cashu_ext.post("/api/v1/cashu/{cashu_id}/melt")
async def melt_coins(payload: MeltPayload, cashu_id: str = Query(None)):
ok, preimage = await melt(payload.proofs, payload.amount, payload.invoice, cashu_id)
return {"paid": ok, "preimage": preimage}
"""Invalidates proofs and pays a Lightning invoice."""
cashu: Cashu = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
try:
ok, preimage = await melt(cashu, payload.proofs, payload.invoice)
return {"paid": ok, "preimage": preimage}
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@cashu_ext.post("/check")
@cashu_ext.post("/api/v1/cashu/{cashu_id}/check")
async def check_spendable_coins(payload: CheckPayload, cashu_id: str = Query(None)):
return await check_spendable(payload.proofs, cashu_id)
@cashu_ext.post("/split")
async def spli_coinst(payload: SplitPayload, cashu_id: str = Query(None)):
@cashu_ext.post("/api/v1/cashu/{cashu_id}/split")
async def split_proofs(payload: SplitRequest, cashu_id: str = Query(None)):
"""
Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split".
"""
print("### RECEIVE")
print("payload", json.dumps(payload, default=vars))
cashu: Cashu = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
amount = payload.amount
output_data = payload.output_data.blinded_messages
outputs = payload.outputs.blinded_messages if payload.outputs else None
try:
split_return = await split(proofs, amount, output_data)
split_return = await split(cashu, proofs, amount, outputs)
except Exception as exc:
return {"error": str(exc)}
raise CashuError(error=str(exc))
if not split_return:
"""There was a problem with the split"""
raise Exception("could not split tokens.")
fst_promises, snd_promises = split_return
return {"fst": fst_promises, "snd": snd_promises}
return {"error": "there was a problem with the split."}
frst_promises, scnd_promises = split_return
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
print("### resp", json.dumps(resp, default=vars))
return resp