mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 02:24:20 +01:00
clean
This commit is contained in:
17
README.md
17
README.md
@@ -6,6 +6,17 @@
|
|||||||
|
|
||||||
Cashu is an Ecash implementation based on David Wagner's variant of Chaumian blinding. Token logic based on [minicash](https://github.com/phyro/minicash) ([description](https://gist.github.com/phyro/935badc682057f418842c72961cf096c)) which implements a [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down by Ruben Somsen [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406). The database mechanics and the Lightning backend uses parts from [LNbits](https://github.com/lnbits/lnbits-legend).
|
Cashu is an Ecash implementation based on David Wagner's variant of Chaumian blinding. Token logic based on [minicash](https://github.com/phyro/minicash) ([description](https://gist.github.com/phyro/935badc682057f418842c72961cf096c)) which implements a [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down by Ruben Somsen [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406). The database mechanics and the Lightning backend uses parts from [LNbits](https://github.com/lnbits/lnbits-legend).
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Quick links:
|
||||||
|
<a href="#cashu-client-protocol">Cashu client protocol</a> ·
|
||||||
|
<a href="#easy-install">Quick Install</a> ·
|
||||||
|
<a href="#hard-install-poetry">Manual install</a> ·
|
||||||
|
<a href="#configuration">Configuration</a> ·
|
||||||
|
<a href="#using-cashu">Using Cashu</a> ·
|
||||||
|
<a href="#running-a-mint">Run a mint</a>
|
||||||
|
<br><br>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Cashu client protocol
|
## Cashu client protocol
|
||||||
There are ongoing efforts to implement alternative Cashu clients that use the same protocol such as a [Cashu Javascript wallet](https://github.com/motorina0/cashu-js-wallet). If you are interested in helping with Cashu development, please see the [docs](docs/) for the notation and conventions used.
|
There are ongoing efforts to implement alternative Cashu clients that use the same protocol such as a [Cashu Javascript wallet](https://github.com/motorina0/cashu-js-wallet). If you are interested in helping with Cashu development, please see the [docs](docs/) for the notation and conventions used.
|
||||||
|
|
||||||
@@ -20,8 +31,8 @@ To update Cashu, use `pip install cashu -U`. If you have problems running the co
|
|||||||
|
|
||||||
You can skip the entire next section about Poetry and jump right to [Using Cashu](#using-cashu).
|
You can skip the entire next section about Poetry and jump right to [Using Cashu](#using-cashu).
|
||||||
|
|
||||||
### Hard install: Poetry
|
## Hard install: Poetry
|
||||||
These steps help you install Python via pyenv and Poetry. If you already have Poetry running on your computer, you can skip this step and jump right to [Install Cashu](#install-cashu).
|
These steps help you install Python via pyenv and Poetry. If you already have Poetry running on your computer, you can skip this step and jump right to [Install Cashu](#poetry-install-cashu).
|
||||||
|
|
||||||
#### Poetry: Prerequisites
|
#### Poetry: Prerequisites
|
||||||
|
|
||||||
@@ -168,7 +179,7 @@ Balance: 351 sat (Available: 351 sat in 7 tokens)
|
|||||||
Balance: 339 sat (Available: 339 sat in 8 tokens)
|
Balance: 339 sat (Available: 339 sat in 8 tokens)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run a mint yourself
|
# Running a mint
|
||||||
This command runs the mint on your local computer. Skip this step if you want to use the [public test mint](#test-instance) instead.
|
This command runs the mint on your local computer. Skip this step if you want to use the [public test mint](#test-instance) instead.
|
||||||
```bash
|
```bash
|
||||||
mint
|
mint
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
# Don't trust me with cryptography.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Implementation of https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406
|
|
||||||
Alice:
|
|
||||||
A = a*G
|
|
||||||
return A
|
|
||||||
Bob:
|
|
||||||
Y = hash_to_point(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_point(secret_message)
|
|
||||||
C == a*Y
|
|
||||||
If true, C must have originated from Alice
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from secp256k1 import PrivateKey, PublicKey
|
|
||||||
|
|
||||||
|
|
||||||
def hash_to_point(secret_msg):
|
|
||||||
"""Generates x coordinate from the message hash and checks if the point lies on the curve.
|
|
||||||
If it does not, it tries computing again a new x coordinate from the hash of the coordinate."""
|
|
||||||
point = None
|
|
||||||
msg = secret_msg
|
|
||||||
while point is None:
|
|
||||||
_hash = hashlib.sha256(msg).hexdigest().encode("utf-8")
|
|
||||||
try:
|
|
||||||
# We construct compressed pub which has x coordinate encoded with even y
|
|
||||||
_hash = list(_hash[:33]) # take the 33 bytes and get a list of bytes
|
|
||||||
_hash[0] = 0x02 # set first byte to represent even y coord
|
|
||||||
_hash = bytes(_hash)
|
|
||||||
point = PublicKey(_hash, raw=True)
|
|
||||||
except:
|
|
||||||
msg = _hash
|
|
||||||
|
|
||||||
return point
|
|
||||||
|
|
||||||
|
|
||||||
def step1_alice(secret_msg):
|
|
||||||
secret_msg = secret_msg.encode("utf-8")
|
|
||||||
Y = hash_to_point(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_point(secret_msg.encode("utf-8"))
|
|
||||||
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
|
|
||||||
108
core/base.py
108
core/base.py
@@ -1,108 +0,0 @@
|
|||||||
from sqlite3 import Row
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class Proof(BaseModel):
|
|
||||||
amount: int
|
|
||||||
secret: str
|
|
||||||
C: str
|
|
||||||
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 "secret" in d, "no secret in proof"
|
|
||||||
assert "amount" in d, "no amount in proof"
|
|
||||||
return cls(
|
|
||||||
amount=d.get("amount"),
|
|
||||||
C=d.get("C"),
|
|
||||||
secret=d.get("secret"),
|
|
||||||
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 __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(
|
|
||||||
amount=int(row[0]),
|
|
||||||
pr=str(row[1]),
|
|
||||||
hash=str(row[2]),
|
|
||||||
issued=bool(row[3]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 MintPayloads(BaseModel):
|
|
||||||
blinded_messages: List[BlindedMessage] = []
|
|
||||||
|
|
||||||
|
|
||||||
class SplitPayload(BaseModel):
|
|
||||||
proofs: List[Proof]
|
|
||||||
amount: int
|
|
||||||
output_data: MintPayloads
|
|
||||||
|
|
||||||
|
|
||||||
class CheckPayload(BaseModel):
|
|
||||||
proofs: List[Proof]
|
|
||||||
|
|
||||||
|
|
||||||
class MeltPayload(BaseModel):
|
|
||||||
proofs: List[Proof]
|
|
||||||
amount: int
|
|
||||||
invoice: str
|
|
||||||
370
core/bolt11.py
370
core/bolt11.py
@@ -1,370 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from binascii import unhexlify
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import List, NamedTuple, Optional
|
|
||||||
|
|
||||||
import bitstring # type: ignore
|
|
||||||
import secp256k1
|
|
||||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
|
||||||
from ecdsa import SECP256k1, VerifyingKey # type: ignore
|
|
||||||
from ecdsa.util import sigdecode_string # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
class Route(NamedTuple):
|
|
||||||
pubkey: str
|
|
||||||
short_channel_id: str
|
|
||||||
base_fee_msat: int
|
|
||||||
ppm_fee: int
|
|
||||||
cltv: int
|
|
||||||
|
|
||||||
|
|
||||||
class Invoice(object):
|
|
||||||
payment_hash: str
|
|
||||||
amount_msat: int = 0
|
|
||||||
description: Optional[str] = None
|
|
||||||
description_hash: Optional[str] = None
|
|
||||||
payee: Optional[str] = None
|
|
||||||
date: int
|
|
||||||
expiry: int = 3600
|
|
||||||
secret: Optional[str] = None
|
|
||||||
route_hints: List[Route] = []
|
|
||||||
min_final_cltv_expiry: int = 18
|
|
||||||
|
|
||||||
|
|
||||||
def decode(pr: str) -> Invoice:
|
|
||||||
"""bolt11 decoder,
|
|
||||||
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
hrp, decoded_data = bech32_decode(pr)
|
|
||||||
if hrp is None or decoded_data is None:
|
|
||||||
raise ValueError("Bad bech32 checksum")
|
|
||||||
if not hrp.startswith("ln"):
|
|
||||||
raise ValueError("Does not start with ln")
|
|
||||||
|
|
||||||
bitarray = _u5_to_bitarray(decoded_data)
|
|
||||||
|
|
||||||
# final signature 65 bytes, split it off.
|
|
||||||
if len(bitarray) < 65 * 8:
|
|
||||||
raise ValueError("Too short to contain signature")
|
|
||||||
|
|
||||||
# extract the signature
|
|
||||||
signature = bitarray[-65 * 8 :].tobytes()
|
|
||||||
|
|
||||||
# the tagged fields as a bitstream
|
|
||||||
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
|
|
||||||
|
|
||||||
# build the invoice object
|
|
||||||
invoice = Invoice()
|
|
||||||
|
|
||||||
# decode the amount from the hrp
|
|
||||||
m = re.search(r"[^\d]+", hrp[2:])
|
|
||||||
if m:
|
|
||||||
amountstr = hrp[2 + m.end() :]
|
|
||||||
if amountstr != "":
|
|
||||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
|
||||||
|
|
||||||
# pull out date
|
|
||||||
invoice.date = data.read(35).uint
|
|
||||||
|
|
||||||
while data.pos != data.len:
|
|
||||||
tag, tagdata, data = _pull_tagged(data)
|
|
||||||
data_length = len(tagdata) / 5
|
|
||||||
|
|
||||||
if tag == "d":
|
|
||||||
invoice.description = _trim_to_bytes(tagdata).decode("utf-8")
|
|
||||||
elif tag == "h" and data_length == 52:
|
|
||||||
invoice.description_hash = _trim_to_bytes(tagdata).hex()
|
|
||||||
elif tag == "p" and data_length == 52:
|
|
||||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
|
||||||
elif tag == "x":
|
|
||||||
invoice.expiry = tagdata.uint
|
|
||||||
elif tag == "n":
|
|
||||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
|
||||||
# this won't work in most cases, we must extract the payee
|
|
||||||
# from the signature
|
|
||||||
elif tag == "s":
|
|
||||||
invoice.secret = _trim_to_bytes(tagdata).hex()
|
|
||||||
elif tag == "r":
|
|
||||||
s = bitstring.ConstBitStream(tagdata)
|
|
||||||
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
|
|
||||||
route = Route(
|
|
||||||
pubkey=s.read(264).tobytes().hex(),
|
|
||||||
short_channel_id=_readable_scid(s.read(64).intbe),
|
|
||||||
base_fee_msat=s.read(32).intbe,
|
|
||||||
ppm_fee=s.read(32).intbe,
|
|
||||||
cltv=s.read(16).intbe,
|
|
||||||
)
|
|
||||||
invoice.route_hints.append(route)
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
# A reader MUST check that the `signature` is valid (see the `n` tagged
|
|
||||||
# field specified below).
|
|
||||||
# A reader MUST use the `n` field to validate the signature instead of
|
|
||||||
# performing signature recovery if a valid `n` field is provided.
|
|
||||||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
|
||||||
sig = signature[0:64]
|
|
||||||
if invoice.payee:
|
|
||||||
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
|
|
||||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
|
||||||
else:
|
|
||||||
keys = VerifyingKey.from_public_key_recovery(
|
|
||||||
sig, message, SECP256k1, hashlib.sha256
|
|
||||||
)
|
|
||||||
signaling_byte = signature[64]
|
|
||||||
key = keys[int(signaling_byte)]
|
|
||||||
invoice.payee = key.to_string("compressed").hex()
|
|
||||||
|
|
||||||
return invoice
|
|
||||||
|
|
||||||
|
|
||||||
def encode(options):
|
|
||||||
"""Convert options into LnAddr and pass it to the encoder"""
|
|
||||||
addr = LnAddr()
|
|
||||||
addr.currency = options["currency"]
|
|
||||||
addr.fallback = options["fallback"] if options["fallback"] else None
|
|
||||||
if options["amount"]:
|
|
||||||
addr.amount = options["amount"]
|
|
||||||
if options["timestamp"]:
|
|
||||||
addr.date = int(options["timestamp"])
|
|
||||||
|
|
||||||
addr.paymenthash = unhexlify(options["paymenthash"])
|
|
||||||
|
|
||||||
if options["description"]:
|
|
||||||
addr.tags.append(("d", options["description"]))
|
|
||||||
if options["description_hash"]:
|
|
||||||
addr.tags.append(("h", options["description_hash"]))
|
|
||||||
if options["expires"]:
|
|
||||||
addr.tags.append(("x", options["expires"]))
|
|
||||||
|
|
||||||
if options["fallback"]:
|
|
||||||
addr.tags.append(("f", options["fallback"]))
|
|
||||||
if options["route"]:
|
|
||||||
for r in options["route"]:
|
|
||||||
splits = r.split("/")
|
|
||||||
route = []
|
|
||||||
while len(splits) >= 5:
|
|
||||||
route.append(
|
|
||||||
(
|
|
||||||
unhexlify(splits[0]),
|
|
||||||
unhexlify(splits[1]),
|
|
||||||
int(splits[2]),
|
|
||||||
int(splits[3]),
|
|
||||||
int(splits[4]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
splits = splits[5:]
|
|
||||||
assert len(splits) == 0
|
|
||||||
addr.tags.append(("r", route))
|
|
||||||
return lnencode(addr, options["privkey"])
|
|
||||||
|
|
||||||
|
|
||||||
def lnencode(addr, privkey):
|
|
||||||
if addr.amount:
|
|
||||||
amount = Decimal(str(addr.amount))
|
|
||||||
# We can only send down to millisatoshi.
|
|
||||||
if amount * 10**12 % 10:
|
|
||||||
raise ValueError(
|
|
||||||
"Cannot encode {}: too many decimal places".format(addr.amount)
|
|
||||||
)
|
|
||||||
|
|
||||||
amount = addr.currency + shorten_amount(amount)
|
|
||||||
else:
|
|
||||||
amount = addr.currency if addr.currency else ""
|
|
||||||
|
|
||||||
hrp = "ln" + amount + "0n"
|
|
||||||
|
|
||||||
# Start with the timestamp
|
|
||||||
data = bitstring.pack("uint:35", addr.date)
|
|
||||||
|
|
||||||
# Payment hash
|
|
||||||
data += tagged_bytes("p", addr.paymenthash)
|
|
||||||
tags_set = set()
|
|
||||||
|
|
||||||
for k, v in addr.tags:
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
#
|
|
||||||
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
|
||||||
if k in ("d", "h", "n", "x"):
|
|
||||||
if k in tags_set:
|
|
||||||
raise ValueError("Duplicate '{}' tag".format(k))
|
|
||||||
|
|
||||||
if k == "r":
|
|
||||||
route = bitstring.BitArray()
|
|
||||||
for step in v:
|
|
||||||
pubkey, channel, feebase, feerate, cltv = step
|
|
||||||
route.append(
|
|
||||||
bitstring.BitArray(pubkey)
|
|
||||||
+ bitstring.BitArray(channel)
|
|
||||||
+ bitstring.pack("intbe:32", feebase)
|
|
||||||
+ bitstring.pack("intbe:32", feerate)
|
|
||||||
+ bitstring.pack("intbe:16", cltv)
|
|
||||||
)
|
|
||||||
data += tagged("r", route)
|
|
||||||
elif k == "f":
|
|
||||||
data += encode_fallback(v, addr.currency)
|
|
||||||
elif k == "d":
|
|
||||||
data += tagged_bytes("d", v.encode())
|
|
||||||
elif k == "x":
|
|
||||||
# Get minimal length by trimming leading 5 bits at a time.
|
|
||||||
expirybits = bitstring.pack("intbe:64", v)[4:64]
|
|
||||||
while expirybits.startswith("0b00000"):
|
|
||||||
expirybits = expirybits[5:]
|
|
||||||
data += tagged("x", expirybits)
|
|
||||||
elif k == "h":
|
|
||||||
data += tagged_bytes("h", v)
|
|
||||||
elif k == "n":
|
|
||||||
data += tagged_bytes("n", v)
|
|
||||||
else:
|
|
||||||
# FIXME: Support unknown tags?
|
|
||||||
raise ValueError("Unknown tag {}".format(k))
|
|
||||||
|
|
||||||
tags_set.add(k)
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
#
|
|
||||||
# A writer MUST include either a `d` or `h` field, and MUST NOT include
|
|
||||||
# both.
|
|
||||||
if "d" in tags_set and "h" in tags_set:
|
|
||||||
raise ValueError("Cannot include both 'd' and 'h'")
|
|
||||||
if not "d" in tags_set and not "h" in tags_set:
|
|
||||||
raise ValueError("Must include either 'd' or 'h'")
|
|
||||||
|
|
||||||
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
|
|
||||||
privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
|
|
||||||
sig = privkey.ecdsa_sign_recoverable(
|
|
||||||
bytearray([ord(c) for c in hrp]) + data.tobytes()
|
|
||||||
)
|
|
||||||
# This doesn't actually serialize, but returns a pair of values :(
|
|
||||||
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
|
|
||||||
data += bytes(sig) + bytes([recid])
|
|
||||||
|
|
||||||
return bech32_encode(hrp, bitarray_to_u5(data))
|
|
||||||
|
|
||||||
|
|
||||||
class LnAddr(object):
|
|
||||||
def __init__(
|
|
||||||
self, paymenthash=None, amount=None, currency="bc", tags=None, date=None
|
|
||||||
):
|
|
||||||
self.date = int(time.time()) if not date else int(date)
|
|
||||||
self.tags = [] if not tags else tags
|
|
||||||
self.unknown_tags = []
|
|
||||||
self.paymenthash = paymenthash
|
|
||||||
self.signature = None
|
|
||||||
self.pubkey = None
|
|
||||||
self.currency = currency
|
|
||||||
self.amount = amount
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
|
|
||||||
hexlify(self.pubkey.serialize()).decode("utf-8"),
|
|
||||||
self.amount,
|
|
||||||
self.currency,
|
|
||||||
", ".join([k + "=" + str(v) for k, v in self.tags]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def shorten_amount(amount):
|
|
||||||
"""Given an amount in bitcoin, shorten it"""
|
|
||||||
# Convert to pico initially
|
|
||||||
amount = int(amount * 10**12)
|
|
||||||
units = ["p", "n", "u", "m", ""]
|
|
||||||
for unit in units:
|
|
||||||
if amount % 1000 == 0:
|
|
||||||
amount //= 1000
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return str(amount) + unit
|
|
||||||
|
|
||||||
|
|
||||||
def _unshorten_amount(amount: str) -> int:
|
|
||||||
"""Given a shortened amount, return millisatoshis"""
|
|
||||||
# BOLT #11:
|
|
||||||
# The following `multiplier` letters are defined:
|
|
||||||
#
|
|
||||||
# * `m` (milli): multiply by 0.001
|
|
||||||
# * `u` (micro): multiply by 0.000001
|
|
||||||
# * `n` (nano): multiply by 0.000000001
|
|
||||||
# * `p` (pico): multiply by 0.000000000001
|
|
||||||
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
|
|
||||||
unit = str(amount)[-1]
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
|
||||||
# anything except a `multiplier` in the table above.
|
|
||||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
|
||||||
raise ValueError("Invalid amount '{}'".format(amount))
|
|
||||||
|
|
||||||
if unit in units:
|
|
||||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
|
||||||
else:
|
|
||||||
return int(amount) * 100_000_000_000
|
|
||||||
|
|
||||||
|
|
||||||
def _pull_tagged(stream):
|
|
||||||
tag = stream.read(5).uint
|
|
||||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
|
||||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
|
||||||
|
|
||||||
|
|
||||||
def is_p2pkh(currency, prefix):
|
|
||||||
return prefix == base58_prefix_map[currency][0]
|
|
||||||
|
|
||||||
|
|
||||||
def is_p2sh(currency, prefix):
|
|
||||||
return prefix == base58_prefix_map[currency][1]
|
|
||||||
|
|
||||||
|
|
||||||
# Tagged field containing BitArray
|
|
||||||
def tagged(char, l):
|
|
||||||
# Tagged fields need to be zero-padded to 5 bits.
|
|
||||||
while l.len % 5 != 0:
|
|
||||||
l.append("0b0")
|
|
||||||
return (
|
|
||||||
bitstring.pack(
|
|
||||||
"uint:5, uint:5, uint:5",
|
|
||||||
CHARSET.find(char),
|
|
||||||
(l.len / 5) / 32,
|
|
||||||
(l.len / 5) % 32,
|
|
||||||
)
|
|
||||||
+ l
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def tagged_bytes(char, l):
|
|
||||||
return tagged(char, bitstring.BitArray(l))
|
|
||||||
|
|
||||||
|
|
||||||
def _trim_to_bytes(barr):
|
|
||||||
# Adds a byte if necessary.
|
|
||||||
b = barr.tobytes()
|
|
||||||
if barr.len % 8 != 0:
|
|
||||||
return b[:-1]
|
|
||||||
return b
|
|
||||||
|
|
||||||
|
|
||||||
def _readable_scid(short_channel_id: int) -> str:
|
|
||||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
|
||||||
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
|
||||||
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
|
||||||
outputindex=(short_channel_id & 0xFFFF),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
|
||||||
ret = bitstring.BitArray()
|
|
||||||
for a in arr:
|
|
||||||
ret += bitstring.pack("uint:5", a)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def bitarray_to_u5(barr):
|
|
||||||
assert barr.len % 5 == 0
|
|
||||||
ret = []
|
|
||||||
s = bitstring.ConstBitStream(barr)
|
|
||||||
while s.pos != s.len:
|
|
||||||
ret.append(s.read(5).uint)
|
|
||||||
return ret
|
|
||||||
182
core/db.py
182
core/db.py
@@ -1,182 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy_aio.base import AsyncConnection
|
|
||||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
|
|
||||||
|
|
||||||
POSTGRES = "POSTGRES"
|
|
||||||
COCKROACH = "COCKROACH"
|
|
||||||
SQLITE = "SQLITE"
|
|
||||||
|
|
||||||
|
|
||||||
class Compat:
|
|
||||||
type: Optional[str] = "<inherited>"
|
|
||||||
schema: Optional[str] = "<inherited>"
|
|
||||||
|
|
||||||
def interval_seconds(self, seconds: int) -> str:
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
return f"interval '{seconds} seconds'"
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
return f"{seconds}"
|
|
||||||
return "<nothing>"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def timestamp_now(self) -> str:
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
return "now()"
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
return "(strftime('%s', 'now'))"
|
|
||||||
return "<nothing>"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serial_primary_key(self) -> str:
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
return "SERIAL PRIMARY KEY"
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
|
||||||
return "<nothing>"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def references_schema(self) -> str:
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
return f"{self.schema}."
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
return ""
|
|
||||||
return "<nothing>"
|
|
||||||
|
|
||||||
|
|
||||||
class Connection(Compat):
|
|
||||||
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
|
||||||
self.conn = conn
|
|
||||||
self.txn = txn
|
|
||||||
self.type = typ
|
|
||||||
self.name = name
|
|
||||||
self.schema = schema
|
|
||||||
|
|
||||||
def rewrite_query(self, query) -> str:
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
query = query.replace("%", "%%")
|
|
||||||
query = query.replace("?", "%s")
|
|
||||||
return query
|
|
||||||
|
|
||||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
|
||||||
result = await self.conn.execute(self.rewrite_query(query), values)
|
|
||||||
return await result.fetchall()
|
|
||||||
|
|
||||||
async def fetchone(self, query: str, values: tuple = ()):
|
|
||||||
result = await self.conn.execute(self.rewrite_query(query), values)
|
|
||||||
row = await result.fetchone()
|
|
||||||
await result.close()
|
|
||||||
return row
|
|
||||||
|
|
||||||
async def execute(self, query: str, values: tuple = ()):
|
|
||||||
return await self.conn.execute(self.rewrite_query(query), values)
|
|
||||||
|
|
||||||
|
|
||||||
class Database(Compat):
|
|
||||||
def __init__(self, db_name: str, db_location: str):
|
|
||||||
self.name = db_name
|
|
||||||
self.db_location = db_location
|
|
||||||
self.db_location_is_url = "://" in self.db_location
|
|
||||||
|
|
||||||
if self.db_location_is_url:
|
|
||||||
database_uri = self.db_location
|
|
||||||
|
|
||||||
if database_uri.startswith("cockroachdb://"):
|
|
||||||
self.type = COCKROACH
|
|
||||||
else:
|
|
||||||
self.type = POSTGRES
|
|
||||||
|
|
||||||
import psycopg2 # type: ignore
|
|
||||||
|
|
||||||
def _parse_timestamp(value, _):
|
|
||||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
|
||||||
if not "." in value:
|
|
||||||
f = "%Y-%m-%d %H:%M:%S"
|
|
||||||
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
|
||||||
|
|
||||||
psycopg2.extensions.register_type(
|
|
||||||
psycopg2.extensions.new_type(
|
|
||||||
psycopg2.extensions.DECIMAL.values,
|
|
||||||
"DEC2FLOAT",
|
|
||||||
lambda value, curs: float(value) if value is not None else None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
psycopg2.extensions.register_type(
|
|
||||||
psycopg2.extensions.new_type(
|
|
||||||
(1082, 1083, 1266),
|
|
||||||
"DATE2INT",
|
|
||||||
lambda value, curs: time.mktime(value.timetuple())
|
|
||||||
if value is not None
|
|
||||||
else None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
psycopg2.extensions.register_type(
|
|
||||||
psycopg2.extensions.new_type(
|
|
||||||
(1184, 1114), "TIMESTAMP2INT", _parse_timestamp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if not os.path.exists(self.db_location):
|
|
||||||
print(f"Creating database directory: {self.db_location}")
|
|
||||||
os.makedirs(self.db_location)
|
|
||||||
self.path = os.path.join(self.db_location, f"{self.name}.sqlite3")
|
|
||||||
database_uri = f"sqlite:///{self.path}"
|
|
||||||
self.type = SQLITE
|
|
||||||
|
|
||||||
self.schema = self.name
|
|
||||||
if self.name.startswith("ext_"):
|
|
||||||
self.schema = self.name[4:]
|
|
||||||
else:
|
|
||||||
self.schema = None
|
|
||||||
|
|
||||||
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
|
|
||||||
self.lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def connect(self):
|
|
||||||
await self.lock.acquire()
|
|
||||||
try:
|
|
||||||
async with self.engine.connect() as conn:
|
|
||||||
async with conn.begin() as txn:
|
|
||||||
wconn = Connection(conn, txn, self.type, self.name, self.schema)
|
|
||||||
|
|
||||||
if self.schema:
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
await wconn.execute(
|
|
||||||
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
|
||||||
)
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
await wconn.execute(
|
|
||||||
f"ATTACH '{self.path}' AS {self.schema}"
|
|
||||||
)
|
|
||||||
|
|
||||||
yield wconn
|
|
||||||
finally:
|
|
||||||
self.lock.release()
|
|
||||||
|
|
||||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
|
||||||
async with self.connect() as conn:
|
|
||||||
result = await conn.execute(query, values)
|
|
||||||
return await result.fetchall()
|
|
||||||
|
|
||||||
async def fetchone(self, query: str, values: tuple = ()):
|
|
||||||
async with self.connect() as conn:
|
|
||||||
result = await conn.execute(query, values)
|
|
||||||
row = await result.fetchone()
|
|
||||||
await result.close()
|
|
||||||
return row
|
|
||||||
|
|
||||||
async def execute(self, query: str, values: tuple = ()):
|
|
||||||
async with self.connect() as conn:
|
|
||||||
return await conn.execute(query, values)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def reuse_conn(self, conn: Connection):
|
|
||||||
yield conn
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from functools import partial, wraps
|
|
||||||
|
|
||||||
from core.settings import LIGHTNING_FEE_PERCENT, LIGHTNING_RESERVE_FEE_MIN
|
|
||||||
|
|
||||||
|
|
||||||
def async_wrap(func):
|
|
||||||
@wraps(func)
|
|
||||||
async def run(*args, loop=None, executor=None, **kwargs):
|
|
||||||
if loop is None:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
partial_func = partial(func, *args, **kwargs)
|
|
||||||
return await loop.run_in_executor(executor, partial_func)
|
|
||||||
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def async_unwrap(to_await):
|
|
||||||
async_response = []
|
|
||||||
|
|
||||||
async def run_and_capture_result():
|
|
||||||
r = await to_await
|
|
||||||
async_response.append(r)
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
coroutine = run_and_capture_result()
|
|
||||||
loop.run_until_complete(coroutine)
|
|
||||||
return async_response[0]
|
|
||||||
|
|
||||||
|
|
||||||
def fee_reserve(amount_msat: int) -> int:
|
|
||||||
"""Function for calculating the Lightning fee reserve"""
|
|
||||||
return max(
|
|
||||||
int(LIGHTNING_RESERVE_FEE_MIN), int(amount_msat * LIGHTNING_FEE_PERCENT / 100.0)
|
|
||||||
)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from core.db import COCKROACH, POSTGRES, SQLITE, Database
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_databases(db: Database, migrations_module):
|
|
||||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
|
||||||
|
|
||||||
async def set_migration_version(conn, db_name, version):
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO dbversions (db, version) VALUES (?, ?)
|
|
||||||
ON CONFLICT (db) DO UPDATE SET version = ?
|
|
||||||
""",
|
|
||||||
(db_name, version, version),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run_migration(db, migrations_module):
|
|
||||||
db_name = migrations_module.__name__.split(".")[-2]
|
|
||||||
for key, migrate in migrations_module.__dict__.items():
|
|
||||||
match = match = matcher.match(key)
|
|
||||||
if match:
|
|
||||||
version = int(match.group(1))
|
|
||||||
if version > current_versions.get(db_name, 0):
|
|
||||||
await migrate(db)
|
|
||||||
|
|
||||||
if db.schema == None:
|
|
||||||
await set_migration_version(db, db_name, version)
|
|
||||||
else:
|
|
||||||
async with db.connect() as conn:
|
|
||||||
await set_migration_version(conn, db_name, version)
|
|
||||||
|
|
||||||
async with db.connect() as conn:
|
|
||||||
if conn.type == SQLITE:
|
|
||||||
exists = await conn.fetchone(
|
|
||||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
|
||||||
)
|
|
||||||
elif conn.type in {POSTGRES, COCKROACH}:
|
|
||||||
exists = await conn.fetchone(
|
|
||||||
"SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not exists:
|
|
||||||
await migrations_module.m000_create_migrations_table(conn)
|
|
||||||
|
|
||||||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
|
||||||
current_versions = {row["db"]: row["version"] for row in rows}
|
|
||||||
matcher = re.compile(r"^m(\d\d\d)_")
|
|
||||||
await run_migration(conn, migrations_module)
|
|
||||||
52
core/secp.py
52
core/secp.py
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from environs import Env # type: ignore
|
|
||||||
|
|
||||||
env = Env()
|
|
||||||
env.read_env()
|
|
||||||
|
|
||||||
DEBUG = env.bool("DEBUG", default=False)
|
|
||||||
CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu")
|
|
||||||
CASHU_DIR = CASHU_DIR.replace("~", str(Path.home()))
|
|
||||||
assert len(CASHU_DIR), "CASHU_DIR not defined"
|
|
||||||
|
|
||||||
LIGHTNING = env.bool("LIGHTNING", default=True)
|
|
||||||
LIGHTNING_FEE_PERCENT = env.float("LIGHTNING_FEE_PERCENT", default=1.0)
|
|
||||||
assert LIGHTNING_FEE_PERCENT >= 0, "LIGHTNING_FEE_PERCENT must be at least 0"
|
|
||||||
LIGHTNING_RESERVE_FEE_MIN = env.float("LIGHTNING_RESERVE_FEE_MIN", default=4000)
|
|
||||||
|
|
||||||
MINT_PRIVATE_KEY = env.str("MINT_PRIVATE_KEY", default=None)
|
|
||||||
|
|
||||||
MINT_SERVER_HOST = env.str("MINT_SERVER_HOST", default="127.0.0.1")
|
|
||||||
MINT_SERVER_PORT = env.int("MINT_SERVER_PORT", default=3338)
|
|
||||||
|
|
||||||
MINT_HOST = env.str("MINT_HOST", default="8333.space")
|
|
||||||
MINT_PORT = env.int("MINT_PORT", default=3338)
|
|
||||||
|
|
||||||
if MINT_HOST in ["localhost", "127.0.0.1"]:
|
|
||||||
MINT_URL = f"http://{MINT_HOST}:{MINT_PORT}"
|
|
||||||
else:
|
|
||||||
MINT_URL = f"https://{MINT_HOST}:{MINT_PORT}"
|
|
||||||
|
|
||||||
LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None)
|
|
||||||
LNBITS_KEY = env.str("LNBITS_KEY", default=None)
|
|
||||||
|
|
||||||
MAX_ORDER = 64
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from lightning.lnbits import LNbitsWallet
|
|
||||||
|
|
||||||
WALLET = LNbitsWallet()
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import AsyncGenerator, Coroutine, NamedTuple, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class StatusResponse(NamedTuple):
|
|
||||||
error_message: Optional[str]
|
|
||||||
balance_msat: int
|
|
||||||
|
|
||||||
|
|
||||||
class InvoiceResponse(NamedTuple):
|
|
||||||
ok: bool
|
|
||||||
checking_id: Optional[str] = None # payment_hash, rpc_id
|
|
||||||
payment_request: Optional[str] = None
|
|
||||||
error_message: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentResponse(NamedTuple):
|
|
||||||
# when ok is None it means we don't know if this succeeded
|
|
||||||
ok: Optional[bool] = None
|
|
||||||
checking_id: Optional[str] = None # payment_hash, rcp_id
|
|
||||||
fee_msat: Optional[int] = None
|
|
||||||
preimage: Optional[str] = None
|
|
||||||
error_message: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentStatus(NamedTuple):
|
|
||||||
paid: Optional[bool] = None
|
|
||||||
fee_msat: Optional[int] = None
|
|
||||||
preimage: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pending(self) -> bool:
|
|
||||||
return self.paid is not True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def failed(self) -> bool:
|
|
||||||
return self.paid == False
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.paid == True:
|
|
||||||
return "settled"
|
|
||||||
elif self.paid == False:
|
|
||||||
return "failed"
|
|
||||||
elif self.paid == None:
|
|
||||||
return "still pending"
|
|
||||||
else:
|
|
||||||
return "unknown (should never happen)"
|
|
||||||
|
|
||||||
|
|
||||||
class Wallet(ABC):
|
|
||||||
@abstractmethod
|
|
||||||
def status(self) -> Coroutine[None, None, StatusResponse]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def create_invoice(
|
|
||||||
self,
|
|
||||||
amount: int,
|
|
||||||
memo: Optional[str] = None,
|
|
||||||
description_hash: Optional[bytes] = None,
|
|
||||||
) -> Coroutine[None, None, InvoiceResponse]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def pay_invoice(
|
|
||||||
self, bolt11: str, fee_limit_msat: int
|
|
||||||
) -> Coroutine[None, None, PaymentResponse]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_invoice_status(
|
|
||||||
self, checking_id: str
|
|
||||||
) -> Coroutine[None, None, PaymentStatus]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_payment_status(
|
|
||||||
self, checking_id: str
|
|
||||||
) -> Coroutine[None, None, PaymentStatus]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
|
||||||
pass
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from os import getenv
|
|
||||||
from typing import AsyncGenerator, Dict, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from core.settings import LNBITS_ENDPOINT, LNBITS_KEY
|
|
||||||
|
|
||||||
from .base import (InvoiceResponse, PaymentResponse, PaymentStatus,
|
|
||||||
StatusResponse, Wallet)
|
|
||||||
|
|
||||||
|
|
||||||
class LNbitsWallet(Wallet):
|
|
||||||
"""https://github.com/lnbits/lnbits"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.endpoint = LNBITS_ENDPOINT
|
|
||||||
|
|
||||||
key = LNBITS_KEY
|
|
||||||
self.key = {"X-Api-Key": key}
|
|
||||||
self.s = requests.Session()
|
|
||||||
self.s.auth = ("user", "pass")
|
|
||||||
self.s.headers.update({"X-Api-Key": key})
|
|
||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
|
||||||
try:
|
|
||||||
r = self.s.get(url=f"{self.endpoint}/api/v1/wallet", timeout=15)
|
|
||||||
except Exception as exc:
|
|
||||||
return StatusResponse(
|
|
||||||
f"Failed to connect to {self.endpoint} due to: {exc}", 0
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = r.json()
|
|
||||||
except:
|
|
||||||
return StatusResponse(
|
|
||||||
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
|
|
||||||
)
|
|
||||||
if "detail" in data:
|
|
||||||
return StatusResponse(f"LNbits error: {data['detail']}", 0)
|
|
||||||
return StatusResponse(None, data["balance"])
|
|
||||||
|
|
||||||
async def create_invoice(
|
|
||||||
self,
|
|
||||||
amount: int,
|
|
||||||
memo: Optional[str] = None,
|
|
||||||
description_hash: Optional[bytes] = None,
|
|
||||||
unhashed_description: Optional[bytes] = None,
|
|
||||||
) -> InvoiceResponse:
|
|
||||||
data: Dict = {"out": False, "amount": amount}
|
|
||||||
if description_hash:
|
|
||||||
data["description_hash"] = description_hash.hex()
|
|
||||||
if unhashed_description:
|
|
||||||
data["unhashed_description"] = unhashed_description.hex()
|
|
||||||
|
|
||||||
data["memo"] = memo or ""
|
|
||||||
try:
|
|
||||||
r = self.s.post(url=f"{self.endpoint}/api/v1/payments", json=data)
|
|
||||||
except:
|
|
||||||
return InvoiceResponse(False, None, None, r.json()["detail"])
|
|
||||||
ok, checking_id, payment_request, error_message = (
|
|
||||||
True,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
checking_id, payment_request = data["checking_id"], data["payment_request"]
|
|
||||||
|
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
|
||||||
try:
|
|
||||||
r = self.s.post(
|
|
||||||
url=f"{self.endpoint}/api/v1/payments",
|
|
||||||
json={"out": True, "bolt11": bolt11},
|
|
||||||
timeout=None,
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
error_message = r.json()["detail"]
|
|
||||||
return PaymentResponse(None, None, None, None, error_message)
|
|
||||||
if "detail" in r.json():
|
|
||||||
return PaymentResponse(None, None, None, None, r.json()["detail"])
|
|
||||||
ok, checking_id, fee_msat, preimage, error_message = (
|
|
||||||
True,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
checking_id = data["payment_hash"]
|
|
||||||
|
|
||||||
# we do this to get the fee and preimage
|
|
||||||
payment: PaymentStatus = await self.get_payment_status(checking_id)
|
|
||||||
|
|
||||||
return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage)
|
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
|
||||||
try:
|
|
||||||
|
|
||||||
r = self.s.get(
|
|
||||||
url=f"{self.endpoint}/api/v1/payments/{checking_id}",
|
|
||||||
headers=self.key,
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
return PaymentStatus(None)
|
|
||||||
return PaymentStatus(r.json()["paid"])
|
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
|
||||||
try:
|
|
||||||
r = self.s.get(
|
|
||||||
url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
return PaymentStatus(None)
|
|
||||||
data = r.json()
|
|
||||||
if "paid" not in data and "details" not in data:
|
|
||||||
return PaymentStatus(None)
|
|
||||||
|
|
||||||
return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"])
|
|
||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
||||||
url = f"{self.endpoint}/api/v1/payments/sse"
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
async with requests.stream("GET", url) as r:
|
|
||||||
async for line in r.aiter_lines():
|
|
||||||
if line.startswith("data:"):
|
|
||||||
try:
|
|
||||||
data = json.loads(line[5:])
|
|
||||||
except json.decoder.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if type(data) is not dict:
|
|
||||||
continue
|
|
||||||
|
|
||||||
yield data["payment_hash"] # payment_hash
|
|
||||||
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from core.settings import MINT_PRIVATE_KEY
|
|
||||||
from mint.ledger import Ledger
|
|
||||||
|
|
||||||
print("init")
|
|
||||||
|
|
||||||
ledger = Ledger(MINT_PRIVATE_KEY, "data/mint")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from .main import main
|
|
||||||
|
|
||||||
print("main")
|
|
||||||
|
|
||||||
|
|
||||||
main()
|
|
||||||
86
mint/app.py
86
mint/app.py
@@ -1,86 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from core.settings import CASHU_DIR, DEBUG
|
|
||||||
|
|
||||||
from lightning import WALLET
|
|
||||||
from mint.migrations import m001_initial
|
|
||||||
|
|
||||||
from . import ledger
|
|
||||||
|
|
||||||
|
|
||||||
def startup(app: FastAPI):
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def load_ledger():
|
|
||||||
await asyncio.wait([m001_initial(ledger.db)])
|
|
||||||
await ledger.load_used_proofs()
|
|
||||||
|
|
||||||
error_message, balance = await WALLET.status()
|
|
||||||
if error_message:
|
|
||||||
logger.warning(
|
|
||||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
|
||||||
RuntimeWarning,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Lightning balance: {balance} sat")
|
|
||||||
logger.info(f"Data dir: {CASHU_DIR}")
|
|
||||||
logger.info("Mint started.")
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object="core.settings") -> FastAPI:
|
|
||||||
def configure_logger() -> None:
|
|
||||||
class Formatter:
|
|
||||||
def __init__(self):
|
|
||||||
self.padding = 0
|
|
||||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
|
||||||
if DEBUG:
|
|
||||||
self.fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
|
|
||||||
else:
|
|
||||||
self.fmt: str = self.minimal_fmt
|
|
||||||
|
|
||||||
def format(self, record):
|
|
||||||
function = "{function}".format(**record)
|
|
||||||
if function == "emit": # uvicorn logs
|
|
||||||
return self.minimal_fmt
|
|
||||||
return self.fmt
|
|
||||||
|
|
||||||
class InterceptHandler(logging.Handler):
|
|
||||||
def emit(self, record):
|
|
||||||
try:
|
|
||||||
level = logger.level(record.levelname).name
|
|
||||||
except ValueError:
|
|
||||||
level = record.levelno
|
|
||||||
logger.log(level, record.getMessage())
|
|
||||||
|
|
||||||
logger.remove()
|
|
||||||
log_level: str = "INFO"
|
|
||||||
formatter = Formatter()
|
|
||||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
|
||||||
|
|
||||||
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
|
|
||||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
|
||||||
|
|
||||||
configure_logger()
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="Cashu Mint",
|
|
||||||
description="Ecash wallet and mint.",
|
|
||||||
license_info={
|
|
||||||
"name": "MIT License",
|
|
||||||
"url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
startup(app)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
# if __name__ == "__main__":
|
|
||||||
# main()
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
110
mint/crud.py
110
mint/crud.py
@@ -1,110 +0,0 @@
|
|||||||
import secrets
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from core.base import Invoice, Proof
|
|
||||||
from core.db import Connection, Database
|
|
||||||
|
|
||||||
|
|
||||||
async def store_promise(
|
|
||||||
amount: int,
|
|
||||||
B_: str,
|
|
||||||
C_: str,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
await (conn or db).execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO promises
|
|
||||||
(amount, B_b, C_b)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
amount,
|
|
||||||
str(B_),
|
|
||||||
str(C_),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_proofs_used(
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
rows = await (conn or db).fetchall(
|
|
||||||
"""
|
|
||||||
SELECT secret from proofs_used
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return [row[0] for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def invalidate_proof(
|
|
||||||
proof: Proof,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
# we add the proof and secret to the used list
|
|
||||||
await (conn or db).execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO proofs_used
|
|
||||||
(amount, C, secret)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
proof.amount,
|
|
||||||
str(proof.C),
|
|
||||||
str(proof.secret),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def store_lightning_invoice(
|
|
||||||
invoice: Invoice,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
await (conn or db).execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO invoices
|
|
||||||
(amount, pr, hash, issued)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
invoice.amount,
|
|
||||||
invoice.pr,
|
|
||||||
invoice.hash,
|
|
||||||
invoice.issued,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_lightning_invoice(
|
|
||||||
hash: str,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
row = await (conn or db).fetchone(
|
|
||||||
"""
|
|
||||||
SELECT * from invoices
|
|
||||||
WHERE hash = ?
|
|
||||||
""",
|
|
||||||
hash,
|
|
||||||
)
|
|
||||||
return Invoice.from_row(row)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_lightning_invoice(
|
|
||||||
hash: str,
|
|
||||||
issued: bool,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
await (conn or db).execute(
|
|
||||||
"UPDATE invoices SET issued = ? WHERE hash = ?",
|
|
||||||
(issued, hash),
|
|
||||||
)
|
|
||||||
258
mint/ledger.py
258
mint/ledger.py
@@ -1,258 +0,0 @@
|
|||||||
"""
|
|
||||||
Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from typing import List, Set
|
|
||||||
|
|
||||||
import core.b_dhke as b_dhke
|
|
||||||
from core.base import BlindedMessage, BlindedSignature, Invoice, Proof
|
|
||||||
from core.db import Database
|
|
||||||
from core.helpers import fee_reserve
|
|
||||||
from core.secp import PrivateKey, PublicKey
|
|
||||||
from core.settings import LIGHTNING, MAX_ORDER
|
|
||||||
from core.split import amount_split
|
|
||||||
from lightning import WALLET
|
|
||||||
from mint.crud import (
|
|
||||||
get_lightning_invoice,
|
|
||||||
get_proofs_used,
|
|
||||||
invalidate_proof,
|
|
||||||
store_lightning_invoice,
|
|
||||||
store_promise,
|
|
||||||
update_lightning_invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Ledger:
|
|
||||||
def __init__(self, secret_key: str, db: str):
|
|
||||||
self.proofs_used: Set[str] = set()
|
|
||||||
|
|
||||||
self.master_key: str = secret_key
|
|
||||||
self.keys: List[PrivateKey] = self._derive_keys(self.master_key)
|
|
||||||
self.pub_keys: List[PublicKey] = self._derive_pubkeys(self.keys)
|
|
||||||
self.db: Database = Database("mint", db)
|
|
||||||
|
|
||||||
async def load_used_proofs(self):
|
|
||||||
self.proofs_used = set(await get_proofs_used(db=self.db))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _derive_pubkeys(keys: List[PrivateKey]):
|
|
||||||
return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]}
|
|
||||||
|
|
||||||
async def _generate_promises(self, amounts: List[int], B_s: List[str]):
|
|
||||||
"""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(self, amount: int, B_: PublicKey):
|
|
||||||
"""Generates a promise for given amount and returns a pair (amount, C')."""
|
|
||||||
secret_key = self.keys[amount] # Get the correct key
|
|
||||||
C_ = b_dhke.step2_bob(B_, secret_key)
|
|
||||||
await store_promise(
|
|
||||||
amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db
|
|
||||||
)
|
|
||||||
return BlindedSignature(amount=amount, C_=C_.serialize().hex())
|
|
||||||
|
|
||||||
def _check_spendable(self, proof: Proof):
|
|
||||||
"""Checks whether the proof was already spent."""
|
|
||||||
return not proof.secret in self.proofs_used
|
|
||||||
|
|
||||||
def _verify_proof(self, proof: Proof):
|
|
||||||
"""Verifies that the proof of promise was issued by this ledger."""
|
|
||||||
if not self._check_spendable(proof):
|
|
||||||
raise Exception(f"tokens already spent. Secret: {proof.secret}")
|
|
||||||
secret_key = self.keys[proof.amount] # Get the correct key to check against
|
|
||||||
C = PublicKey(bytes.fromhex(proof.C), raw=True)
|
|
||||||
return b_dhke.verify(secret_key, C, proof.secret)
|
|
||||||
|
|
||||||
def _verify_outputs(
|
|
||||||
self, total: int, amount: int, output_data: 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]
|
|
||||||
return given == expected
|
|
||||||
|
|
||||||
def _verify_no_duplicates(
|
|
||||||
self, proofs: List[Proof], output_data: 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]
|
|
||||||
if len(B_s) != len(list(set(B_s))):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _verify_split_amount(self, amount: int):
|
|
||||||
"""Split amount like output amount can't be negative or too big."""
|
|
||||||
try:
|
|
||||||
self._verify_amount(amount)
|
|
||||||
except:
|
|
||||||
# For better error message
|
|
||||||
raise Exception("invalid split amount: " + str(amount))
|
|
||||||
|
|
||||||
def _verify_amount(self, 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(
|
|
||||||
self, proofs: List[Proof], outs: List[BlindedMessage]
|
|
||||||
):
|
|
||||||
"""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(self, amount: int):
|
|
||||||
"""Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8]."""
|
|
||||||
self._verify_amount(amount)
|
|
||||||
bits_amt = bin(amount)[::-1][:-2]
|
|
||||||
rv = []
|
|
||||||
for (pos, bit) in enumerate(bits_amt):
|
|
||||||
if bit == "1":
|
|
||||||
rv.append(2**pos)
|
|
||||||
return rv
|
|
||||||
|
|
||||||
async def _request_lightning_invoice(self, amount: int):
|
|
||||||
"""Returns an invoice from the Lightning backend."""
|
|
||||||
error, balance = await WALLET.status()
|
|
||||||
if error:
|
|
||||||
raise Exception(f"Lightning wallet not responding: {error}")
|
|
||||||
ok, checking_id, payment_request, error_message = await WALLET.create_invoice(
|
|
||||||
amount, "cashu deposit"
|
|
||||||
)
|
|
||||||
return payment_request, checking_id
|
|
||||||
|
|
||||||
async def _check_lightning_invoice(self, payment_hash: str):
|
|
||||||
"""Checks with the Lightning backend whether an invoice with this payment_hash was paid."""
|
|
||||||
invoice: Invoice = await get_lightning_invoice(payment_hash, db=self.db)
|
|
||||||
if invoice.issued:
|
|
||||||
raise Exception("tokens already issued for this invoice")
|
|
||||||
status = await WALLET.get_invoice_status(payment_hash)
|
|
||||||
if status.paid:
|
|
||||||
await update_lightning_invoice(payment_hash, issued=True, db=self.db)
|
|
||||||
return status.paid
|
|
||||||
|
|
||||||
async def _pay_lightning_invoice(self, invoice: str, amount: int):
|
|
||||||
"""Returns an invoice from the Lightning backend."""
|
|
||||||
error, balance = await WALLET.status()
|
|
||||||
if error:
|
|
||||||
raise Exception(f"Lightning wallet not responding: {error}")
|
|
||||||
ok, checking_id, fee_msat, preimage, error_message = await WALLET.pay_invoice(
|
|
||||||
invoice, fee_limit_msat=fee_reserve(amount * 1000)
|
|
||||||
)
|
|
||||||
return ok, preimage
|
|
||||||
|
|
||||||
async def _invalidate_proofs(self, proofs: List[Proof]):
|
|
||||||
"""Adds secrets of proofs to the list of knwon secrets and stores them in the db."""
|
|
||||||
# Mark proofs as used and prepare new promises
|
|
||||||
proof_msgs = set([p.secret for p in proofs])
|
|
||||||
self.proofs_used |= proof_msgs
|
|
||||||
# store in db
|
|
||||||
for p in proofs:
|
|
||||||
await invalidate_proof(p, db=self.db)
|
|
||||||
|
|
||||||
# Public methods
|
|
||||||
def get_pubkeys(self):
|
|
||||||
"""Returns public keys for possible amounts."""
|
|
||||||
return {a: p.serialize().hex() for a, p in self.pub_keys.items()}
|
|
||||||
|
|
||||||
async def request_mint(self, amount):
|
|
||||||
"""Returns Lightning invoice and stores it in the db."""
|
|
||||||
payment_request, checking_id = await self._request_lightning_invoice(amount)
|
|
||||||
invoice = Invoice(
|
|
||||||
amount=amount, pr=payment_request, hash=checking_id, issued=False
|
|
||||||
)
|
|
||||||
if not payment_request or not checking_id:
|
|
||||||
raise Exception(f"Could not create Lightning invoice.")
|
|
||||||
await store_lightning_invoice(invoice, db=self.db)
|
|
||||||
return payment_request, checking_id
|
|
||||||
|
|
||||||
async def mint(self, B_s: List[PublicKey], amounts: List[int], payment_hash=None):
|
|
||||||
"""Mints a promise for coins for B_."""
|
|
||||||
# check if lightning invoice was paid
|
|
||||||
if LIGHTNING and (
|
|
||||||
payment_hash and not await self._check_lightning_invoice(payment_hash)
|
|
||||||
):
|
|
||||||
raise Exception("Lightning invoice not paid yet.")
|
|
||||||
|
|
||||||
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 self._generate_promise(amount, B_) for B_, amount in zip(B_s, amounts)
|
|
||||||
]
|
|
||||||
return promises
|
|
||||||
|
|
||||||
async def melt(self, proofs: List[Proof], amount: int, invoice: str):
|
|
||||||
"""Invalidates proofs and pays a Lightning invoice."""
|
|
||||||
# if not LIGHTNING:
|
|
||||||
total = sum([p["amount"] for p in proofs])
|
|
||||||
# check that lightning fees are included
|
|
||||||
assert total + fee_reserve(amount * 1000) >= amount, Exception(
|
|
||||||
"provided proofs not enough for Lightning payment."
|
|
||||||
)
|
|
||||||
|
|
||||||
status, preimage = await self._pay_lightning_invoice(invoice, amount)
|
|
||||||
if status == True:
|
|
||||||
await self._invalidate_proofs(proofs)
|
|
||||||
return status, preimage
|
|
||||||
|
|
||||||
async def check_spendable(self, proofs: List[Proof]):
|
|
||||||
"""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(
|
|
||||||
self, proofs: List[Proof], amount: int, output_data: List[BlindedMessage]
|
|
||||||
):
|
|
||||||
"""Consumes proofs and prepares new promises based on the amount split."""
|
|
||||||
self._verify_split_amount(amount)
|
|
||||||
# Verify proofs are valid
|
|
||||||
if not all([self._verify_proof(p) for p in proofs]):
|
|
||||||
return False
|
|
||||||
|
|
||||||
total = sum([p.amount for p in proofs])
|
|
||||||
|
|
||||||
if not self._verify_no_duplicates(proofs, output_data):
|
|
||||||
raise Exception("duplicate proofs or promises")
|
|
||||||
if amount > total:
|
|
||||||
raise Exception("split amount is higher than the total sum")
|
|
||||||
if not self._verify_outputs(total, amount, output_data):
|
|
||||||
raise Exception("split of promises is not as expected")
|
|
||||||
|
|
||||||
# Mark proofs as used and prepare new promises
|
|
||||||
await self._invalidate_proofs(proofs)
|
|
||||||
|
|
||||||
outs_fst = amount_split(total - amount)
|
|
||||||
outs_snd = amount_split(amount)
|
|
||||||
B_fst = [od.B_ for od in output_data[: len(outs_fst)]]
|
|
||||||
B_snd = [od.B_ for od in output_data[len(outs_fst) :]]
|
|
||||||
prom_fst, prom_snd = await self._generate_promises(
|
|
||||||
outs_fst, B_fst
|
|
||||||
), await self._generate_promises(outs_snd, B_snd)
|
|
||||||
self._verify_equation_balanced(proofs, prom_fst + prom_snd)
|
|
||||||
return prom_fst, prom_snd
|
|
||||||
52
mint/main.py
52
mint/main.py
@@ -1,52 +0,0 @@
|
|||||||
import click
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
from core.settings import (
|
|
||||||
MINT_SERVER_HOST,
|
|
||||||
MINT_SERVER_PORT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@click.command(
|
|
||||||
context_settings=dict(
|
|
||||||
ignore_unknown_options=True,
|
|
||||||
allow_extra_args=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@click.option("--port", default=MINT_SERVER_PORT, help="Port to listen on")
|
|
||||||
@click.option("--host", default=MINT_SERVER_HOST, help="Host to run mint on")
|
|
||||||
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
|
|
||||||
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
|
|
||||||
@click.pass_context
|
|
||||||
def main(
|
|
||||||
ctx,
|
|
||||||
port: int = MINT_SERVER_PORT,
|
|
||||||
host: str = MINT_SERVER_HOST,
|
|
||||||
ssl_keyfile: str = None,
|
|
||||||
ssl_certfile: str = None,
|
|
||||||
):
|
|
||||||
"""Launched with `poetry run mint` at root level"""
|
|
||||||
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
|
|
||||||
d = dict()
|
|
||||||
for a in ctx.args:
|
|
||||||
item = a.split("=")
|
|
||||||
if len(item) > 1: # argument like --key=value
|
|
||||||
print(a, item)
|
|
||||||
d[item[0].strip("--").replace("-", "_")] = (
|
|
||||||
int(item[1]) # need to convert to int if it's a number
|
|
||||||
if item[1].isdigit()
|
|
||||||
else item[1]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
d[a.strip("--")] = True # argument like --key
|
|
||||||
|
|
||||||
config = uvicorn.Config(
|
|
||||||
"mint.app:app",
|
|
||||||
port=port,
|
|
||||||
host=host,
|
|
||||||
ssl_keyfile=ssl_keyfile,
|
|
||||||
ssl_certfile=ssl_certfile,
|
|
||||||
**d,
|
|
||||||
)
|
|
||||||
server = uvicorn.Server(config)
|
|
||||||
server.run()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from core.db import Database
|
|
||||||
|
|
||||||
|
|
||||||
async def m000_create_migrations_table(db):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS dbversions (
|
|
||||||
db TEXT PRIMARY KEY,
|
|
||||||
version INT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m001_initial(db: Database):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS promises (
|
|
||||||
amount INTEGER NOT NULL,
|
|
||||||
B_b TEXT NOT NULL,
|
|
||||||
C_b TEXT NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (B_b)
|
|
||||||
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS proofs_used (
|
|
||||||
amount INTEGER NOT NULL,
|
|
||||||
C TEXT NOT NULL,
|
|
||||||
secret TEXT NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (secret)
|
|
||||||
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS invoices (
|
|
||||||
amount INTEGER NOT NULL,
|
|
||||||
pr TEXT NOT NULL,
|
|
||||||
hash TEXT NOT NULL,
|
|
||||||
issued BOOL NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (hash)
|
|
||||||
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE VIEW IF NOT EXISTS balance_issued AS
|
|
||||||
SELECT COALESCE(SUM(s), 0) AS balance FROM (
|
|
||||||
SELECT SUM(amount) AS s
|
|
||||||
FROM promises
|
|
||||||
WHERE amount > 0
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE VIEW IF NOT EXISTS balance_used AS
|
|
||||||
SELECT COALESCE(SUM(s), 0) AS balance FROM (
|
|
||||||
SELECT SUM(amount) AS s
|
|
||||||
FROM proofs_used
|
|
||||||
WHERE amount > 0
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE VIEW IF NOT EXISTS balance AS
|
|
||||||
SELECT s_issued - s_used AS balance FROM (
|
|
||||||
SELECT bi.balance AS s_issued, bu.balance AS s_used
|
|
||||||
FROM balance_issued bi
|
|
||||||
CROSS JOIN balance_used bu
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
from mint import ledger
|
|
||||||
from core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload
|
|
||||||
from typing import Union
|
|
||||||
from secp256k1 import PublicKey
|
|
||||||
|
|
||||||
router: APIRouter = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/keys")
|
|
||||||
def keys():
|
|
||||||
"""Get the public keys of the mint"""
|
|
||||||
return ledger.get_pubkeys()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mint")
|
|
||||||
async def request_mint(amount: int = 0):
|
|
||||||
"""Request minting of tokens. Server responds with a Lightning invoice."""
|
|
||||||
payment_request, payment_hash = await ledger.request_mint(amount)
|
|
||||||
print(f"Lightning invoice: {payment_request}")
|
|
||||||
return {"pr": payment_request, "hash": payment_hash}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mint")
|
|
||||||
async def mint(payloads: MintPayloads, 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.
|
|
||||||
"""
|
|
||||||
amounts = []
|
|
||||||
B_s = []
|
|
||||||
for payload in payloads.blinded_messages:
|
|
||||||
amounts.append(payload.amount)
|
|
||||||
B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True))
|
|
||||||
try:
|
|
||||||
promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash)
|
|
||||||
return promises
|
|
||||||
except Exception as exc:
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/melt")
|
|
||||||
async def melt(payload: MeltPayload):
|
|
||||||
"""
|
|
||||||
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}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/check")
|
|
||||||
async def check_spendable(payload: CheckPayload):
|
|
||||||
return await ledger.check_spendable(payload.proofs)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/split")
|
|
||||||
async def split(payload: SplitPayload):
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
try:
|
|
||||||
split_return = await ledger.split(proofs, amount, output_data)
|
|
||||||
except Exception as exc:
|
|
||||||
return {"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}
|
|
||||||
14
poetry.lock
generated
14
poetry.lock
generated
@@ -85,7 +85,7 @@ uvloop = ["uvloop (>=0.15.2)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2022.9.14"
|
version = "2022.9.24"
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@@ -205,11 +205,11 @@ dotenv = ["python-dotenv"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.13.0"
|
version = "0.14.0"
|
||||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||||
@@ -759,8 +759,8 @@ black = [
|
|||||||
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
|
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
|
||||||
]
|
]
|
||||||
certifi = [
|
certifi = [
|
||||||
{file = "certifi-2022.9.14-py3-none-any.whl", hash = "sha256:e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516"},
|
{file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"},
|
||||||
{file = "certifi-2022.9.14.tar.gz", hash = "sha256:36973885b9542e6bd01dea287b2b4b3b21236307c56324fcc3f1160f2d655ed5"},
|
{file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"},
|
||||||
]
|
]
|
||||||
cffi = [
|
cffi = [
|
||||||
{file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
|
{file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
|
||||||
@@ -857,8 +857,8 @@ Flask = [
|
|||||||
{file = "Flask-2.2.2.tar.gz", hash = "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b"},
|
{file = "Flask-2.2.2.tar.gz", hash = "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b"},
|
||||||
]
|
]
|
||||||
h11 = [
|
h11 = [
|
||||||
{file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"},
|
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||||
{file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"},
|
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||||
]
|
]
|
||||||
idna = [
|
idna = [
|
||||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
||||||
|
|||||||
@@ -46,6 +46,6 @@ requires = ["poetry-core>=1.0.0"]
|
|||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
mint = "mint.app:main"
|
mint = "cashu.mint.app:main"
|
||||||
cashu = "wallet.cashu:cli"
|
cashu = "cashu.wallet.cli:cli"
|
||||||
wallet-test = "tests.test_wallet:test"
|
wallet-test = "tests.test_wallet:test"
|
||||||
2
setup.py
2
setup.py
@@ -9,7 +9,7 @@ with open(path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
|||||||
with open("requirements.txt") as f:
|
with open("requirements.txt") as f:
|
||||||
requirements = f.read().splitlines()
|
requirements = f.read().splitlines()
|
||||||
|
|
||||||
entry_points = {"console_scripts": ["cashu = wallet.cashu:cli"]}
|
entry_points = {"console_scripts": ["cashu = cahu.wallet.cashu:cli"]}
|
||||||
|
|
||||||
setuptools.setup(
|
setuptools.setup(
|
||||||
name="cashu",
|
name="cashu",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
sys.tracebacklimit = None
|
|
||||||
212
wallet/cashu.py
212
wallet/cashu.py
@@ -1,212 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from datetime import datetime
|
|
||||||
from functools import wraps
|
|
||||||
from itertools import groupby
|
|
||||||
from operator import itemgetter
|
|
||||||
|
|
||||||
import click
|
|
||||||
from bech32 import bech32_decode, bech32_encode, convertbits
|
|
||||||
|
|
||||||
import core.bolt11 as bolt11
|
|
||||||
from core.base import Proof
|
|
||||||
from core.bolt11 import Invoice
|
|
||||||
from core.helpers import fee_reserve
|
|
||||||
from core.migrations import migrate_databases
|
|
||||||
from core.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL
|
|
||||||
from wallet import migrations
|
|
||||||
from wallet.crud import get_reserved_proofs
|
|
||||||
from wallet.wallet import Wallet as Wallet
|
|
||||||
|
|
||||||
|
|
||||||
async def init_wallet(wallet: Wallet):
|
|
||||||
"""Performs migrations and loads proofs from db."""
|
|
||||||
await migrate_databases(wallet.db, migrations)
|
|
||||||
await wallet.load_proofs()
|
|
||||||
|
|
||||||
|
|
||||||
class NaturalOrderGroup(click.Group):
|
|
||||||
"""For listing commands in help in order of definition"""
|
|
||||||
|
|
||||||
def list_commands(self, ctx):
|
|
||||||
return self.commands.keys()
|
|
||||||
|
|
||||||
|
|
||||||
@click.group(cls=NaturalOrderGroup)
|
|
||||||
@click.option("--host", "-h", default=MINT_URL, help="Mint address.")
|
|
||||||
@click.option("--wallet", "-w", "walletname", default="wallet", help="Wallet to use.")
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, host: str, walletname: str):
|
|
||||||
ctx.ensure_object(dict)
|
|
||||||
ctx.obj["HOST"] = host
|
|
||||||
ctx.obj["WALLET_NAME"] = walletname
|
|
||||||
ctx.obj["WALLET"] = Wallet(ctx.obj["HOST"], f"{CASHU_DIR}/{walletname}", walletname)
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# https://github.com/pallets/click/issues/85#issuecomment-503464628
|
|
||||||
def coro(f):
|
|
||||||
@wraps(f)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return asyncio.run(f(*args, **kwargs))
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("mint", help="Mint tokens.")
|
|
||||||
@click.argument("amount", type=int)
|
|
||||||
@click.option("--hash", default="", help="Hash of the paid invoice.", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def mint(ctx, amount: int, hash: str):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
wallet.status()
|
|
||||||
if not LIGHTNING:
|
|
||||||
r = await wallet.mint(amount)
|
|
||||||
elif amount and not hash:
|
|
||||||
r = await wallet.request_mint(amount)
|
|
||||||
if "pr" in r:
|
|
||||||
print(f"Pay this invoice to mint {amount} sat:")
|
|
||||||
print(f"Invoice: {r['pr']}")
|
|
||||||
print("")
|
|
||||||
print(
|
|
||||||
f"After paying the invoice, run this command:\ncashu mint {amount} --hash {r['hash']}"
|
|
||||||
)
|
|
||||||
elif amount and hash:
|
|
||||||
await wallet.mint(amount, hash)
|
|
||||||
wallet.status()
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("balance", help="See balance.")
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def balance(ctx):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
wallet.status()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("send", help="Send tokens.")
|
|
||||||
@click.argument("amount", type=int)
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def send(ctx, amount: int):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
wallet.status()
|
|
||||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, amount)
|
|
||||||
await wallet.set_reserved(send_proofs, reserved=True)
|
|
||||||
token = await wallet.serialize_proofs(send_proofs)
|
|
||||||
print(token)
|
|
||||||
wallet.status()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("receive", help="Receive tokens.")
|
|
||||||
@click.argument("token", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def receive(ctx, token: str):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
wallet.status()
|
|
||||||
proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))]
|
|
||||||
_, _ = await wallet.redeem(proofs)
|
|
||||||
wallet.status()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("burn", help="Burn spent tokens.")
|
|
||||||
@click.argument("token", required=False, type=str)
|
|
||||||
@click.option("--all", "-a", default=False, is_flag=True, help="Burn all spent tokens.")
|
|
||||||
@click.option(
|
|
||||||
"--force", "-f", default=False, is_flag=True, help="Force check on all tokens."
|
|
||||||
)
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def burn(ctx, token: str, all: bool, force: bool):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
if not (all or token or force) or (token and all):
|
|
||||||
print(
|
|
||||||
"Error: enter a token or use --all to burn all pending tokens or --force to check all tokens."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if all:
|
|
||||||
# check only those who are flagged as reserved
|
|
||||||
proofs = await get_reserved_proofs(wallet.db)
|
|
||||||
elif force:
|
|
||||||
# check all proofs in db
|
|
||||||
proofs = wallet.proofs
|
|
||||||
else:
|
|
||||||
# check only the specified ones
|
|
||||||
proofs = [
|
|
||||||
Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))
|
|
||||||
]
|
|
||||||
wallet.status()
|
|
||||||
await wallet.invalidate(proofs)
|
|
||||||
wallet.status()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("pending", help="Show pending tokens.")
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def pending(ctx):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
reserved_proofs = await get_reserved_proofs(wallet.db)
|
|
||||||
if len(reserved_proofs):
|
|
||||||
sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id"))
|
|
||||||
for key, value in groupby(sorted_proofs, key=itemgetter("send_id")):
|
|
||||||
grouped_proofs = list(value)
|
|
||||||
token = await wallet.serialize_proofs(grouped_proofs)
|
|
||||||
reserved_date = datetime.utcfromtimestamp(
|
|
||||||
int(grouped_proofs[0].time_reserved)
|
|
||||||
).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
print(
|
|
||||||
f"Amount: {sum([p['amount'] for p in grouped_proofs])} sat Sent: {reserved_date} ID: {key}\n"
|
|
||||||
)
|
|
||||||
print(token)
|
|
||||||
print("")
|
|
||||||
wallet.status()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("pay", help="Pay lightning invoice.")
|
|
||||||
@click.argument("invoice", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def pay(ctx, invoice: str):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
wallet.status()
|
|
||||||
decoded_invoice: Invoice = bolt11.decode(invoice)
|
|
||||||
amount = math.ceil(
|
|
||||||
(decoded_invoice.amount_msat + fee_reserve(decoded_invoice.amount_msat)) / 1000
|
|
||||||
) # 1% fee for Lightning
|
|
||||||
print(
|
|
||||||
f"Paying Lightning invoice of {decoded_invoice.amount_msat // 1000} sat ({amount} sat incl. fees)"
|
|
||||||
)
|
|
||||||
assert amount > 0, "amount is not positive"
|
|
||||||
if wallet.available_balance < amount:
|
|
||||||
print("Error: Balance too low.")
|
|
||||||
return
|
|
||||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, amount)
|
|
||||||
await wallet.pay_lightning(send_proofs, amount, invoice)
|
|
||||||
wallet.status()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("info", help="Information about Cashu wallet.")
|
|
||||||
@click.pass_context
|
|
||||||
@coro
|
|
||||||
async def info(ctx):
|
|
||||||
wallet: Wallet = ctx.obj["WALLET"]
|
|
||||||
await init_wallet(wallet)
|
|
||||||
wallet.status()
|
|
||||||
print(f"Debug: {DEBUG}")
|
|
||||||
print(f"Cashu dir: {CASHU_DIR}")
|
|
||||||
print(f"Mint URL: {MINT_URL}")
|
|
||||||
return
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from core.base import Proof
|
|
||||||
from core.db import Connection, Database
|
|
||||||
|
|
||||||
|
|
||||||
async def store_proof(
|
|
||||||
proof: Proof,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
await (conn or db).execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO proofs
|
|
||||||
(amount, C, secret, time_created)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(proof.amount, str(proof.C), str(proof.secret), int(time.time())),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_proofs(
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
rows = await (conn or db).fetchall(
|
|
||||||
"""
|
|
||||||
SELECT * from proofs
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return [Proof.from_row(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_reserved_proofs(
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
rows = await (conn or db).fetchall(
|
|
||||||
"""
|
|
||||||
SELECT * from proofs
|
|
||||||
WHERE reserved
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return [Proof.from_row(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def invalidate_proof(
|
|
||||||
proof: Proof,
|
|
||||||
db: Database,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
|
|
||||||
await (conn or db).execute(
|
|
||||||
f"""
|
|
||||||
DELETE FROM proofs
|
|
||||||
WHERE secret = ?
|
|
||||||
""",
|
|
||||||
str(proof["secret"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
await (conn or db).execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO proofs_used
|
|
||||||
(amount, C, secret, time_used)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(proof.amount, str(proof.C), str(proof.secret), int(time.time())),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_proof_reserved(
|
|
||||||
proof: Proof,
|
|
||||||
reserved: bool,
|
|
||||||
send_id: str = None,
|
|
||||||
db: Database = None,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
|
||||||
clauses = []
|
|
||||||
values = []
|
|
||||||
clauses.append("reserved = ?")
|
|
||||||
values.append(reserved)
|
|
||||||
|
|
||||||
if send_id:
|
|
||||||
clauses.append("send_id = ?")
|
|
||||||
values.append(send_id)
|
|
||||||
|
|
||||||
if reserved:
|
|
||||||
# set the time of reserving
|
|
||||||
clauses.append("time_reserved = ?")
|
|
||||||
values.append(int(time.time()))
|
|
||||||
|
|
||||||
await (conn or db).execute(
|
|
||||||
f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?",
|
|
||||||
(*values, str(proof.secret)),
|
|
||||||
)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
from core.db import Database
|
|
||||||
|
|
||||||
|
|
||||||
async def m000_create_migrations_table(db):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS dbversions (
|
|
||||||
db TEXT PRIMARY KEY,
|
|
||||||
version INT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m001_initial(db: Database):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS proofs (
|
|
||||||
amount INTEGER NOT NULL,
|
|
||||||
C TEXT NOT NULL,
|
|
||||||
secret TEXT NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (secret)
|
|
||||||
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS proofs_used (
|
|
||||||
amount INTEGER NOT NULL,
|
|
||||||
C TEXT NOT NULL,
|
|
||||||
secret TEXT NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (secret)
|
|
||||||
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE VIEW IF NOT EXISTS balance AS
|
|
||||||
SELECT COALESCE(SUM(s), 0) AS balance FROM (
|
|
||||||
SELECT SUM(amount) AS s
|
|
||||||
FROM proofs
|
|
||||||
WHERE amount > 0
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE VIEW IF NOT EXISTS balance_used AS
|
|
||||||
SELECT COALESCE(SUM(s), 0) AS used FROM (
|
|
||||||
SELECT SUM(amount) AS s
|
|
||||||
FROM proofs_used
|
|
||||||
WHERE amount > 0
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m002_add_proofs_reserved(db):
|
|
||||||
"""
|
|
||||||
Column for marking proofs as reserved when they are being sent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
await db.execute("ALTER TABLE proofs ADD COLUMN reserved BOOL")
|
|
||||||
|
|
||||||
|
|
||||||
async def m003_add_proofs_sendid_and_timestamps(db):
|
|
||||||
"""
|
|
||||||
Column with unique ID for each initiated send attempt
|
|
||||||
so proofs can be later grouped together for each send attempt.
|
|
||||||
"""
|
|
||||||
await db.execute("ALTER TABLE proofs ADD COLUMN send_id TEXT")
|
|
||||||
await db.execute("ALTER TABLE proofs ADD COLUMN time_created TIMESTAMP")
|
|
||||||
await db.execute("ALTER TABLE proofs ADD COLUMN time_reserved TIMESTAMP")
|
|
||||||
await db.execute("ALTER TABLE proofs_used ADD COLUMN time_used TIMESTAMP")
|
|
||||||
252
wallet/wallet.py
252
wallet/wallet.py
@@ -1,252 +0,0 @@
|
|||||||
import base64
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import secrets as scrts
|
|
||||||
import uuid
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
import core.b_dhke as b_dhke
|
|
||||||
from core.base import (
|
|
||||||
BlindedMessage,
|
|
||||||
BlindedSignature,
|
|
||||||
CheckPayload,
|
|
||||||
MeltPayload,
|
|
||||||
MintPayloads,
|
|
||||||
Proof,
|
|
||||||
SplitPayload,
|
|
||||||
)
|
|
||||||
from core.db import Database
|
|
||||||
from core.secp import PublicKey
|
|
||||||
from core.settings import DEBUG
|
|
||||||
from core.split import amount_split
|
|
||||||
from wallet.crud import get_proofs, invalidate_proof, store_proof, update_proof_reserved
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerAPI:
|
|
||||||
def __init__(self, url):
|
|
||||||
self.url = url
|
|
||||||
self.keys = self._get_keys(url)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_keys(url):
|
|
||||||
resp = requests.get(url + "/keys").json()
|
|
||||||
return {
|
|
||||||
int(amt): PublicKey(bytes.fromhex(val), raw=True)
|
|
||||||
for amt, val in resp.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_output_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
|
|
||||||
|
|
||||||
def _construct_proofs(self, promises: List[BlindedSignature], secrets: List[str]):
|
|
||||||
"""Returns proofs of promise from promises."""
|
|
||||||
proofs = []
|
|
||||||
for promise, (r, secret) in zip(promises, secrets):
|
|
||||||
C_ = PublicKey(bytes.fromhex(promise.C_), raw=True)
|
|
||||||
C = b_dhke.step3_alice(C_, r, self.keys[promise.amount])
|
|
||||||
proof = Proof(amount=promise.amount, C=C.serialize().hex(), secret=secret)
|
|
||||||
proofs.append(proof)
|
|
||||||
return proofs
|
|
||||||
|
|
||||||
def _generate_secret(self, randombits=128):
|
|
||||||
"""Returns base64 encoded random string."""
|
|
||||||
return scrts.token_urlsafe(randombits // 8)
|
|
||||||
|
|
||||||
def request_mint(self, amount):
|
|
||||||
"""Requests a mint from the server and returns Lightning invoice."""
|
|
||||||
r = requests.get(self.url + "/mint", params={"amount": amount})
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def mint(self, amounts, payment_hash=None):
|
|
||||||
"""Mints new coins and returns a proof of promise."""
|
|
||||||
payloads: MintPayloads = MintPayloads()
|
|
||||||
secrets = []
|
|
||||||
rs = []
|
|
||||||
for amount in amounts:
|
|
||||||
secret = self._generate_secret()
|
|
||||||
secrets.append(secret)
|
|
||||||
B_, r = b_dhke.step1_alice(secret)
|
|
||||||
rs.append(r)
|
|
||||||
payload: BlindedMessage = BlindedMessage(
|
|
||||||
amount=amount, B_=B_.serialize().hex()
|
|
||||||
)
|
|
||||||
payloads.blinded_messages.append(payload)
|
|
||||||
promises_list = requests.post(
|
|
||||||
self.url + "/mint",
|
|
||||||
json=payloads.dict(),
|
|
||||||
params={"payment_hash": payment_hash},
|
|
||||||
).json()
|
|
||||||
if "error" in promises_list:
|
|
||||||
raise Exception("Error: {}".format(promises_list["error"]))
|
|
||||||
promises = [BlindedSignature.from_dict(p) for p in promises_list]
|
|
||||||
return self._construct_proofs(promises, [(r, s) for r, s in zip(rs, secrets)])
|
|
||||||
|
|
||||||
def split(self, proofs, amount):
|
|
||||||
"""Consume proofs and create new promises based on amount split."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
# TODO: Refactor together with the same procedure in self.mint()
|
|
||||||
secrets = []
|
|
||||||
payloads: MintPayloads = MintPayloads()
|
|
||||||
for output_amt in fst_outputs + snd_outputs:
|
|
||||||
secret = self._generate_secret()
|
|
||||||
B_, r = b_dhke.step1_alice(secret)
|
|
||||||
secrets.append((r, secret))
|
|
||||||
payload: BlindedMessage = BlindedMessage(
|
|
||||||
amount=output_amt, B_=B_.serialize().hex()
|
|
||||||
)
|
|
||||||
payloads.blinded_messages.append(payload)
|
|
||||||
split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads)
|
|
||||||
promises_dict = requests.post(
|
|
||||||
self.url + "/split",
|
|
||||||
json=split_payload.dict(),
|
|
||||||
).json()
|
|
||||||
if "error" in promises_dict:
|
|
||||||
raise Exception("Error: {}".format(promises_dict["error"]))
|
|
||||||
promises_fst = [BlindedSignature.from_dict(p) for p in promises_dict["fst"]]
|
|
||||||
promises_snd = [BlindedSignature.from_dict(p) for p in promises_dict["snd"]]
|
|
||||||
# Obtain proofs from promises
|
|
||||||
fst_proofs = self._construct_proofs(promises_fst, secrets[: len(promises_fst)])
|
|
||||||
snd_proofs = self._construct_proofs(promises_snd, secrets[len(promises_fst) :])
|
|
||||||
|
|
||||||
return fst_proofs, snd_proofs
|
|
||||||
|
|
||||||
async def check_spendable(self, proofs: List[Proof]):
|
|
||||||
payload = CheckPayload(proofs=proofs)
|
|
||||||
return_dict = requests.post(
|
|
||||||
self.url + "/check",
|
|
||||||
json=payload.dict(),
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return return_dict
|
|
||||||
|
|
||||||
async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str):
|
|
||||||
payload = MeltPayload(proofs=proofs, amount=amount, invoice=invoice)
|
|
||||||
return_dict = requests.post(
|
|
||||||
self.url + "/melt",
|
|
||||||
json=payload.dict(),
|
|
||||||
).json()
|
|
||||||
return return_dict
|
|
||||||
|
|
||||||
|
|
||||||
class Wallet(LedgerAPI):
|
|
||||||
"""Minimal wallet wrapper."""
|
|
||||||
|
|
||||||
def __init__(self, url: str, db: str, name: str = "no_name"):
|
|
||||||
super().__init__(url)
|
|
||||||
self.db = Database("wallet", db)
|
|
||||||
self.proofs: List[Proof] = []
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
async def load_proofs(self):
|
|
||||||
self.proofs = await get_proofs(db=self.db)
|
|
||||||
|
|
||||||
async def _store_proofs(self, proofs):
|
|
||||||
for proof in proofs:
|
|
||||||
await store_proof(proof, db=self.db)
|
|
||||||
|
|
||||||
async def request_mint(self, amount):
|
|
||||||
return super().request_mint(amount)
|
|
||||||
|
|
||||||
async def mint(self, amount: int, payment_hash: str = None):
|
|
||||||
split = amount_split(amount)
|
|
||||||
proofs = super().mint(split, payment_hash)
|
|
||||||
if proofs == []:
|
|
||||||
raise Exception("received no proofs.")
|
|
||||||
await self._store_proofs(proofs)
|
|
||||||
self.proofs += proofs
|
|
||||||
return proofs
|
|
||||||
|
|
||||||
async def redeem(self, proofs: List[Proof]):
|
|
||||||
return await self.split(proofs, sum(p["amount"] for p in proofs))
|
|
||||||
|
|
||||||
async def split(self, proofs: List[Proof], amount: int):
|
|
||||||
assert len(proofs) > 0, ValueError("no proofs provided.")
|
|
||||||
fst_proofs, snd_proofs = super().split(proofs, amount)
|
|
||||||
if len(fst_proofs) == 0 and len(snd_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)
|
|
||||||
for proof in proofs:
|
|
||||||
await invalidate_proof(proof, db=self.db)
|
|
||||||
return fst_proofs, snd_proofs
|
|
||||||
|
|
||||||
async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str):
|
|
||||||
"""Pays a lightning invoice"""
|
|
||||||
status = await super().pay_lightning(proofs, amount, invoice)
|
|
||||||
if status["paid"] == True:
|
|
||||||
await self.invalidate(proofs)
|
|
||||||
else:
|
|
||||||
raise Exception("could not pay invoice.")
|
|
||||||
return status["paid"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def serialize_proofs(proofs: List[Proof]):
|
|
||||||
proofs_serialized = [p.to_dict() for p in proofs]
|
|
||||||
token = base64.urlsafe_b64encode(
|
|
||||||
json.dumps(proofs_serialized).encode()
|
|
||||||
).decode()
|
|
||||||
return token
|
|
||||||
|
|
||||||
async def split_to_send(self, proofs: List[Proof], amount):
|
|
||||||
"""Like self.split but only considers non-reserved tokens."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
async def set_reserved(self, proofs: List[Proof], reserved: bool):
|
|
||||||
"""Mark a proof as reserved to avoid reuse or delete marking."""
|
|
||||||
uuid_str = str(uuid.uuid1())
|
|
||||||
for proof in proofs:
|
|
||||||
proof.reserved = True
|
|
||||||
await update_proof_reserved(
|
|
||||||
proof, reserved=reserved, send_id=uuid_str, db=self.db
|
|
||||||
)
|
|
||||||
|
|
||||||
async def check_spendable(self, proofs):
|
|
||||||
return await super().check_spendable(proofs)
|
|
||||||
|
|
||||||
async def invalidate(self, proofs):
|
|
||||||
"""Invalidates all spendable tokens supplied in proofs."""
|
|
||||||
spendables = await self.check_spendable(proofs)
|
|
||||||
invalidated_proofs = []
|
|
||||||
for idx, spendable in spendables.items():
|
|
||||||
if not spendable:
|
|
||||||
invalidated_proofs.append(proofs[int(idx)])
|
|
||||||
await invalidate_proof(proofs[int(idx)], db=self.db)
|
|
||||||
invalidate_secrets = [p["secret"] for p in invalidated_proofs]
|
|
||||||
self.proofs = list(
|
|
||||||
filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def balance(self):
|
|
||||||
return sum(p["amount"] for p in self.proofs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available_balance(self):
|
|
||||||
return sum(p["amount"] for p in self.proofs if not p.reserved)
|
|
||||||
|
|
||||||
def status(self):
|
|
||||||
print(
|
|
||||||
f"Balance: {self.balance} sat (Available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def proof_amounts(self):
|
|
||||||
return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])]
|
|
||||||
Reference in New Issue
Block a user