This commit is contained in:
callebtc
2023-01-11 03:34:36 +01:00
67 changed files with 5613 additions and 977 deletions

View File

@@ -8,6 +8,13 @@ CASHU_DIR=~/.cashu
MINT_HOST=127.0.0.1
MINT_PORT=3338
# use builtin tor, this overrides SOCKS_HOST and SOCKS_PORT
TOR=TRUE
# use custom tor proxy, use with TOR=false
SOCKS_HOST=localhost
SOCKS_PORT=9050
# MINT
MINT_PRIVATE_KEY=supersecretprivatekey
@@ -22,4 +29,10 @@ LIGHTNING_FEE_PERCENT=1.0
LIGHTNING_RESERVE_FEE_MIN=4000
LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_KEY=yourkeyasdasdasd
LNBITS_KEY=yourkeyasdasdasd
# NOSTR
# nostr private key to which to receive tokens to
NOSTR_PRIVATE_KEY=hex_nostrprivatekey_here
# nostr relays (comma separated list)
NOSTR_RELAYS="wss://nostr-pub.wellorder.net"

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: https://legend.lnbits.com/tipjar/794

9
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
coverage:
status:
patch: off
project:
default:
target: auto
# adjust accordingly based on how flaky your tests are
# this allows a 10% drop from the previous base commit coverage
threshold: 10%

View File

@@ -1,4 +1,4 @@
name: formatting
name: checks
on:
push:
@@ -7,7 +7,7 @@ on:
branches: [main]
jobs:
poetry:
formatting:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -29,3 +29,25 @@ jobs:
run: poetry run black --check .
- name: Check isort
run: poetry run isort --profile black --check-only .
linting:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install packages
run: poetry install --with dev
- name: Setup mypy
run: yes | poetry run mypy cashu --install-types || true
- name: Run mypy
run: poetry run mypy cashu --ignore-missing

View File

@@ -4,9 +4,10 @@ on: [push, pull_request]
jobs:
poetry:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps:
@@ -22,6 +23,7 @@ jobs:
- name: Install dependencies
run: |
poetry install --with dev
shell: bash
- name: Run mint
env:
LIGHTNING: False
@@ -35,5 +37,8 @@ jobs:
LIGHTNING: False
MINT_HOST: localhost
MINT_PORT: 3338
TOR: False
run: |
poetry run pytest tests
poetry run pytest tests --cov-report xml --cov cashu
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "cashu/nostr"]
path = cashu/nostr
url = https://github.com/callebtc/python-nostr/

2
MANIFEST.in Normal file
View File

@@ -0,0 +1,2 @@
include requirements.txt
recursive-include cashu/tor *

123
README.md
View File

@@ -1,8 +1,9 @@
# cashu
**Cashu is a Chaumian Ecash wallet and mint with Bitcoin Lightning support.**
**Cashu is a Chaumian Ecash wallet and mint for Bitcoin Lightning.**
<a href="https://pypi.org/project/cashu/"><img alt="Release" src="https://img.shields.io/pypi/v/cashu?color=black"></a> <a href="https://pepy.tech/project/cashu"> <img alt="Downloads" src="https://pepy.tech/badge/cashu"></a> <a href="https://app.codecov.io/gh/callebtc/cashu"><img alt="Coverage" src="https://img.shields.io/codecov/c/gh/callebtc/cashu"></a>
<a href="https://pypi.org/project/cashu/"><img alt="Release" src="https://img.shields.io/pypi/v/cashu?color=black"></a>
*Disclaimer: The author is NOT a cryptographer and this work has not been reviewed. This means that there is very likely a fatal flaw somewhere. Cashu is still experimental and not production-ready.*
@@ -17,6 +18,16 @@ Cashu is an Ecash implementation based on David Wagner's variant of Chaumian bli
<a href="#running-a-mint">Run a mint</a>
</p>
### Feature overview
- Full Bitcoin Lightning support
- Standalone CLI wallet and mint server
- Mint library includable into other Python projects
- PostgreSQL and SQLite database support
- Builtin Tor for hiding IPs for wallet and mint interactions
- Multimint wallet for tokens from different mints
- Send tokens to nostr pubkeys
## 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.
@@ -29,7 +40,7 @@ pip install cashu
To update Cashu, use `pip install cashu -U`.
If you have problems running the command above on Ubuntu, run `sudo apt install -y pip pkg-config`.
If you have problems running the command above on Ubuntu, run `sudo apt install -y pip pkg-config` and `pip install wheel`. On macOS, you might have to run `pip install wheel` and `brew install pkg-config`.
You can skip the entire next section about Poetry and jump right to [Using Cashu](#using-cashu).
@@ -39,8 +50,8 @@ These steps help you install Python via pyenv and Poetry. If you already have Po
#### Poetry: Prerequisites
```bash
sudo apt install -y build-essential pkg-config libffi-dev libpq-dev zlib1g-dev libssl-dev python3-dev
# on mac: brew install postgres
# on ubuntu:
sudo apt install -y build-essential pkg-config libffi-dev libpq-dev zlib1g-dev libssl-dev python3-dev libsqlite3-dev ncurses-dev libbz2-dev libreadline-dev lzma-dev
# install python using pyenv
curl https://pyenv.run | bash
@@ -59,7 +70,7 @@ source ~/.bashrc
#### Poetry: Install Cashu
```bash
# install cashu
git clone https://github.com/callebtc/cashu.git
git clone https://github.com/callebtc/cashu.git --recurse-submodules
cd cashu
pyenv local 3.9.13
poetry install
@@ -89,8 +100,7 @@ vim .env
To use the wallet with the [public test mint](#test-instance), you need to change the appropriate entries in the `.env` file.
#### Test instance
*Warning: this instance is just for demonstration only. Currently, only Lightning deposits work but not withdrawals. The server could vanish at any moment so consider any Satoshis you deposit a donation. I will add Lightning withdrawals soon so unless someone comes up with a huge inflation bug, you might be able to claim them back at a later point in time.*
*Warning: this instance is just for demonstration only. The server could vanish at any moment so consider any Satoshis you deposit a donation.*
Change the appropriate `.env` file settings to
```bash
@@ -99,41 +109,39 @@ MINT_PORT=3338
```
# Using Cashu
#### Request a mint
This command will return a Lightning invoice and a payment hash. You have to pay the invoice before you can receive the tokens. Note: Minting tokens involves two steps: requesting a mint, and actually minting tokens (see below).
```bash
cashu mint 420
cashu info
```
Returns:
```bash
Pay this invoice to mint 420 sat:
Invoice: lnbc4200n1p3nfk7zsp522g8wlsx9cvmhtyuyuae48nvreew9x9f8kkqhd2v2umrdtwl2ysspp5w2w6jvcnz4ftcwsxtad5kv3yev62pcp5cvq42dqqrmwtr2k6mk8qdq4vdshx6r4ypjx2ur0wd5hgxqyjw5qcqpjrzjqfe5jlwxmwt4sa4s8mqjqp8qtreqant6mqwwkts46dtawvncjwvhczurxgqqvvgqqqqqqqqnqqqqqzgqyg9qyysgqzaus4lsfs3zzk4ehdzrkxzv8ryu2yxppxyjrune3nks2dgrnua6nv7lsztmyjaf96xp569tf7rxdmfud5q45zmr4xue5hjple6xhcrcpfmveag
After paying the invoice, run this command:
cashu mint 420 --hash 729da933131552bc3a065f5b4b3224cb34a0e034c3015534001edcb1aadadd8e
Version: 0.7.0
Debug: False
Cashu dir: /home/user/.cashu
Wallet: wallet
Mint URL: https://8333.space:3338
```
#### Mint tokens
After paying the invoice, copy the `hash` value from above and add it to the command
```bash
cashu mint 420 --hash 729da933131552bc3a065f5b4b3224cb34a0e034c3015534001edcb1aadadd8e
```
You should see your balance update accordingly:
```bash
Balance: 0 sat (Available: 0 sat in 0 tokens)
Balance: 420 sat (Available: 420 sat in 4 tokens)
```
Available tokens here means those tokens that have not been reserved for sending.
#### Check balance
```bash
cashu balance
```
#### Generate a Lightning invoice
This command will return a Lightning invoice that you need to pay to mint new ecash tokens.
```bash
cashu invoice 420
```
The client will check every few seconds if the invoice has been paid. If you abort this step but still pay the invoice, you can use the command `cashu invoice <amount> --hash <hash>`.
#### Pay a Lightning invoice
```bash
cashu pay lnbc120n1p3jfmdapp5r9jz...
```
#### Send tokens
To send tokens to another user, enter
```bash
@@ -141,12 +149,12 @@ cashu send 69
```
You should see the encoded token. Copy the token and send it to another user such as via email or a messenger. The token looks like this:
```bash
W3siYW1vdW50IjogMSwgIkMiOiB7IngiOiAzMzg0Mzg0NDYzNzAwMTY1NDA2MTQxMDY3Mzg1MDg5MjA2MTU2NjQxMjM4Nzg5MDE4NzAzODg0NjAwNDUzNTAwNzY3...
W3siYW1vdW50IjogMSwgIkMiOiB7IngiOi...
```
You can now see that your available balance has dropped by the amount that you reserved for sending if you enter `cashu balance`:
```bash
Balance: 420 sat (Available: 351 sat in 7 tokens)
Balance: 420 sat
```
#### Receive tokens
@@ -156,53 +164,10 @@ cashu receive W3siYW1vdW50IjogMSwgIkMiOi...
```
You should see the balance increase:
```bash
Balance: 0 sat (Available: 0 sat in 0 tokens)
Balance: 69 sat (Available: 69 sat in 3 tokens)
Balance: 0 sat
Balance: 69 sat
```
#### Burn tokens
The sending user needs to burn (invalidate) their tokens from above, otherwise they will try to double spend them (which won't work because the server keeps a list of all spent tokens):
```bash
cashu burn W3siYW1vdW50IjogMSwgIkMiOi...
```
Returns:
```bash
Balance: 420 sat (Available: 351 sat in 7 tokens)
Balance: 351 sat (Available: 351 sat in 7 tokens)
```
Use `cashu burn -a` to burn all used tokens or `cashu burn -f` to force a spent recheck on all tokens and burn them is they are used. This command is safe to use, it won't burn unspent tokens.
#### Check pending tokens
```bash
cashu pending
```
Returns
```bash
Amount: 64 sat Sent: 2022-09-28 06:53:03 ID: 33025ade-3efa-11ed-9096-16a10f0dbf61
W3siYW1vdW50Ijog...
Amount: 64 sat Sent: 2022-09-28 06:57:25 ID: cf588354-3efa-11ed-b5ec-16a10f0dbf61
W3siYW1vdW50Ijog...
Amount: 128 sat Sent: 2022-09-28 09:57:43 ID: fef371fa-3f13-11ed-b31a-16a10f0dbf61
W3siYW1vdW50Ij...
Balance: 1234 sat (Available: 1234 sat in 7 tokens)
```
You can either burn these tokens manually when the receiver has redeemed them, or you can receive them yourself if you want to cancel a pending payment.
#### Pay a Lightning invoice
```bash
cashu pay lnbc120n1p3jfmdapp5r9jz...
```
Returns:
```bash
Balance: 351 sat (Available: 351 sat in 7 tokens)
Balance: 339 sat (Available: 339 sat in 8 tokens)
```
# 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.

View File

@@ -2,27 +2,32 @@
"""
Implementation of https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406
Alice:
Bob (Mint):
A = a*G
return A
Bob:
Y = hash_to_point(secret_message)
Alice (Client):
Y = hash_to_curve(secret_message)
r = random blinding factor
B'= Y + r*G
return B'
Alice:
Bob:
C' = a*B'
(= a*Y + a*r*G)
return C'
Bob:
Alice:
C = C' - r*A
(= C' - a*r*G)
(= a*Y)
return C, secret_message
Alice:
Y = hash_to_point(secret_message)
Bob:
Y = hash_to_curve(secret_message)
C == a*Y
If true, C must have originated from Alice
If true, C must have originated from Bob
"""
import hashlib
@@ -30,29 +35,26 @@ 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."""
def hash_to_curve(message: bytes):
"""Generates a point from the message hash and checks if the point lies on the curve.
If it does not, it tries computing a new point from the hash."""
point = None
msg = secret_msg
msg_to_hash = message
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)
_hash = hashlib.sha256(msg_to_hash).digest()
point = PublicKey(b"\x02" + _hash, raw=True)
except:
msg = _hash
msg_to_hash = _hash
return point
def step1_alice(secret_msg):
secret_msg = secret_msg.encode("utf-8")
Y = hash_to_point(secret_msg)
r = PrivateKey()
def step1_alice(secret_msg: str, blinding_factor: bytes = None):
Y = hash_to_curve(secret_msg.encode("utf-8"))
if blinding_factor:
r = PrivateKey(privkey=blinding_factor, raw=True)
else:
r = PrivateKey()
B_ = Y + r.pubkey
return B_, r
@@ -68,7 +70,7 @@ def step3_alice(C_, r, A):
def verify(a, C, secret_msg):
Y = hash_to_point(secret_msg.encode("utf-8"))
Y = hash_to_curve(secret_msg.encode("utf-8"))
return C == Y.mult(a)

View File

@@ -1,5 +1,5 @@
from sqlite3 import Row
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, TypedDict, Union
from pydantic import BaseModel
@@ -12,52 +12,19 @@ class P2SHScript(BaseModel):
signature: str
address: Union[str, None] = None
@classmethod
def from_row(cls, row: Row):
return cls(
address=row[0],
script=row[1],
signature=row[2],
used=row[3],
)
class Proof(BaseModel):
id: str = ""
amount: int
id: Union[
None, str
] = "" # NOTE: None for backwards compatibility of old clients < 0.3
amount: int = 0
secret: str = ""
C: str
C: str = ""
script: Union[P2SHScript, None] = None
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
time_created: str = ""
time_reserved: str = ""
@classmethod
def from_row(cls, row: Row):
return cls(
amount=row[0],
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
id=row[7] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "amount" in d, "no amount in proof"
return cls(
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret") or "",
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
reserved: Union[None, bool] = False # whether this proof is reserved for sending
send_id: Union[None, str] = "" # unique ID of send attempt
time_created: Union[None, str] = ""
time_reserved: Union[None, str] = ""
def to_dict(self):
return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C)
@@ -81,21 +48,15 @@ class Proofs(BaseModel):
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]),
)
hash: Union[None, str] = None
preimage: Union[str, None] = None
issued: Union[None, bool] = False
paid: Union[None, bool] = False
time_created: Union[None, str, int, float] = ""
time_paid: Union[None, str, int, float] = ""
class BlindedMessage(BaseModel):
id: str = ""
amount: int
B_: str
@@ -105,14 +66,6 @@ class BlindedSignature(BaseModel):
amount: int
C_: str
@classmethod
def from_dict(cls, d: dict):
return cls(
id=d.get("id"),
amount=d["amount"],
C_=d["C_"],
)
class MintRequest(BaseModel):
blinded_messages: List[BlindedMessage] = []
@@ -166,7 +119,6 @@ class CheckFeesResponse(BaseModel):
class MeltRequest(BaseModel):
proofs: List[Proof]
amount: int = None # deprecated
invoice: str
@@ -175,16 +127,6 @@ class KeyBase(BaseModel):
amount: int
pubkey: str
@classmethod
def from_row(cls, row: Row):
if row is None:
return cls
return cls(
id=row[0],
amount=int(row[1]),
pubkey=row[2],
)
class WalletKeyset:
id: str
@@ -215,29 +157,17 @@ class WalletKeyset:
self.public_keys = pubkeys
self.id = derive_keyset_id(self.public_keys)
@classmethod
def from_row(cls, row: Row):
if row is None:
return cls
return cls(
id=row[0],
mint_url=row[1],
valid_from=row[2],
valid_to=row[3],
first_seen=row[4],
active=row[5],
)
class MintKeyset:
id: str
derivation_path: str
private_keys: Dict[int, PrivateKey]
public_keys: Dict[int, PublicKey] = None
public_keys: Dict[int, PublicKey] = {}
valid_from: Union[str, None] = None
valid_to: Union[str, None] = None
first_seen: Union[str, None] = None
active: bool = True
version: Union[str, None] = None
def __init__(
self,
@@ -246,8 +176,9 @@ class MintKeyset:
valid_to=None,
first_seen=None,
active=None,
seed: Union[None, str] = None,
derivation_path: str = "0",
seed: str = "",
derivation_path: str = "",
version: str = "",
):
self.derivation_path = derivation_path
self.id = id
@@ -255,28 +186,17 @@ class MintKeyset:
self.valid_to = valid_to
self.first_seen = first_seen
self.active = active
self.version = version
# generate keys from seed
if seed:
self.generate_keys(seed)
def generate_keys(self, seed):
"""Generates keys of a keyset from a seed."""
self.private_keys = derive_keys(seed, self.derivation_path)
self.public_keys = derive_pubkeys(self.private_keys)
self.id = derive_keyset_id(self.public_keys)
@classmethod
def from_row(cls, row: Row):
if row is None:
return cls
return cls(
id=row[0],
derivation_path=row[1],
valid_from=row[2],
valid_to=row[3],
first_seen=row[4],
active=row[5],
)
def get_keybase(self):
return {
k: KeyBase(id=self.id, amount=k, pubkey=v.serialize().hex())
@@ -292,3 +212,19 @@ class MintKeysets:
def get_ids(self):
return [k for k, _ in self.keysets.items()]
class TokenMintJson(BaseModel):
url: str
ks: List[str]
class TokenJson(BaseModel):
tokens: List[Proof]
mints: Optional[Dict[str, TokenMintJson]] = None
def to_dict(self):
return dict(
tokens=[p.to_dict() for p in self.tokens],
mints={k: v.dict() for k, v in self.mints.items()}, # type: ignore
)

View File

@@ -5,6 +5,14 @@ from typing import Dict, List
from cashu.core.secp import PrivateKey, PublicKey
from cashu.core.settings import MAX_ORDER
# entropy = bytes([random.getrandbits(8) for i in range(16)])
# mnemonic = bip39.mnemonic_from_bytes(entropy)
# seed = bip39.mnemonic_to_seed(mnemonic)
# root = bip32.HDKey.from_seed(seed, version=NETWORKS["main"]["xprv"])
# bip44_xprv = root.derive("m/44h/1h/0h")
# bip44_xpub = bip44_xprv.to_public()
def derive_keys(master_key: str, derivation_path: str = ""):
"""
@@ -27,9 +35,11 @@ def derive_pubkeys(keys: Dict[int, PrivateKey]):
return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]}
def derive_keyset_id(keys: Dict[str, PublicKey]):
def derive_keyset_id(keys: Dict[int, PublicKey]):
"""Deterministic derivation keyset_id from set of public keys."""
pubkeys_concat = "".join([p.serialize().hex() for _, p in keys.items()])
# sort public keys by amount
sorted_keys = dict(sorted(keys.items()))
pubkeys_concat = "".join([p.serialize().hex() for _, p in sorted_keys.items()])
return base64.b64encode(
hashlib.sha256((pubkeys_concat).encode("utf-8")).digest()
).decode()[:12]

31
cashu/core/legacy.py Normal file
View File

@@ -0,0 +1,31 @@
import hashlib
from secp256k1 import PublicKey
def hash_to_point_pre_0_3_3(secret_msg):
"""
NOTE: Clients pre 0.3.3 used a different hash_to_curve
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 verify_pre_0_3_3(a, C, secret_msg):
Y = hash_to_point_pre_0_3_3(secret_msg.encode("utf-8"))
return C == Y.mult(a)

View File

@@ -3,13 +3,17 @@ import re
from cashu.core.db import COCKROACH, POSTGRES, SQLITE, Database
def table_with_schema(db, table: str):
return f"{db.references_schema if db.schema else ''}{table}"
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 (?, ?)
f"""
INSERT INTO {table_with_schema(db, 'dbversions')} (db, version) VALUES (?, ?)
ON CONFLICT (db) DO UPDATE SET version = ?
""",
(db_name, version, version),
@@ -18,7 +22,7 @@ async def migrate_databases(db: Database, migrations_module):
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)
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
@@ -33,17 +37,19 @@ async def migrate_databases(db: Database, migrations_module):
async with db.connect() as conn:
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
f"SELECT * FROM sqlite_master WHERE type='table' AND name='{table_with_schema(db, 'dbversions')}'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'"
f"SELECT * FROM information_schema.tables WHERE table_name = '{table_with_schema(db, 'dbversions')}'"
)
if not exists:
await migrations_module.m000_create_migrations_table(conn)
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
rows = await (
await conn.execute(f"SELECT * FROM {table_with_schema(db, '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)
await run_migration(db, migrations_module)

View File

@@ -7,14 +7,15 @@ from environs import Env # type: ignore
env = Env()
ENV_FILE: Union[str, None] = os.path.join(str(Path.home()), ".cashu", ".env")
# env file: default to current dir, else home dir
ENV_FILE = os.path.join(os.getcwd(), ".env")
if not os.path.isfile(ENV_FILE):
ENV_FILE = os.path.join(os.getcwd(), ".env")
ENV_FILE = os.path.join(str(Path.home()), ".cashu", ".env")
if os.path.isfile(ENV_FILE):
env.read_env(ENV_FILE)
else:
ENV_FILE = None
env.read_env()
ENV_FILE = ""
env.read_env(recurse=False)
DEBUG = env.bool("DEBUG", default=False)
if not DEBUG:
@@ -24,6 +25,11 @@ CASHU_DIR = env.str("CASHU_DIR", default=os.path.join(str(Path.home()), ".cashu"
CASHU_DIR = CASHU_DIR.replace("~", str(Path.home()))
assert len(CASHU_DIR), "CASHU_DIR not defined"
TOR = env.bool("TOR", default=True)
SOCKS_HOST = env.str("SOCKS_HOST", default=None)
SOCKS_PORT = env.int("SOCKS_PORT", default=9050)
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"
@@ -47,5 +53,8 @@ if not MINT_URL:
LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None)
LNBITS_KEY = env.str("LNBITS_KEY", default=None)
NOSTR_PRIVATE_KEY = env.str("NOSTR_PRIVATE_KEY", default=None)
NOSTR_RELAYS = env.list("NOSTR_RELAYS", default=["wss://nostr-pub.wellorder.net"])
MAX_ORDER = 64
VERSION = "0.3.2"
VERSION = "0.7.0"

View File

@@ -1,3 +1,3 @@
from cashu.lightning.lnbits import LNbitsWallet
# from cashu.lightning.lnbits import LNbitsWallet
WALLET = LNbitsWallet()
# WALLET = LNbitsWallet()

View File

@@ -79,9 +79,9 @@ class Wallet(ABC):
) -> Coroutine[None, None, PaymentStatus]:
pass
@abstractmethod
def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
pass
# @abstractmethod
# def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# pass
class Unsupported(Exception):

View File

@@ -1,8 +1,5 @@
import asyncio
import hashlib
import json
from os import getenv
from typing import AsyncGenerator, Dict, Optional
from typing import Dict, Optional
import requests
@@ -133,26 +130,26 @@ class LNbitsWallet(Wallet):
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"
# 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
# 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
# if type(data) is not dict:
# continue
yield data["payment_hash"] # payment_hash
# yield data["payment_hash"] # payment_hash
except:
pass
# except:
# pass
print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
await asyncio.sleep(5)
# print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
# await asyncio.sleep(5)

View File

@@ -1,4 +1 @@
from cashu.core.settings import MINT_PRIVATE_KEY
from cashu.mint.ledger import Ledger
ledger = Ledger(MINT_PRIVATE_KEY, "data/mint")

View File

@@ -1,17 +1,29 @@
import asyncio
import logging
import sys
from fastapi import FastAPI
from loguru import logger
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from cashu.core.settings import DEBUG, VERSION
from cashu.lightning import WALLET
from cashu.mint.migrations import m001_initial
from . import ledger
from .router import router
from .startup import load_ledger
from .startup import start_mint_init
# from starlette_context import context
# from starlette_context.middleware import RawContextMiddleware
# class CustomHeaderMiddleware(BaseHTTPMiddleware):
# """
# Middleware for starlette that can set the context from request headers
# """
# async def dispatch(self, request, call_next):
# context["client-version"] = request.headers.get("Client-version")
# response = await call_next(request)
# return response
def create_app(config_object="core.settings") -> FastAPI:
@@ -49,6 +61,13 @@ def create_app(config_object="core.settings") -> FastAPI:
configure_logger()
# middleware = [
# Middleware(
# RawContextMiddleware,
# ),
# Middleware(CustomHeaderMiddleware),
# ]
app = FastAPI(
title="Cashu Mint",
description="Ecash wallet and mint with Bitcoin Lightning support.",
@@ -57,8 +76,8 @@ def create_app(config_object="core.settings") -> FastAPI:
"name": "MIT License",
"url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE",
},
# middleware=middleware,
)
return app
@@ -68,5 +87,5 @@ app.include_router(router=router)
@app.on_event("startup")
async def startup_load_ledger():
await load_ledger()
async def startup_mint():
await start_mint_init()

View File

@@ -1,20 +1,74 @@
from typing import Optional
import time
from typing import Any, List, Optional
from cashu.core.base import Invoice, MintKeyset, Proof
from cashu.core.db import Connection, Database
from cashu.core.migrations import table_with_schema
class LedgerCrud:
"""
Database interface for Cashu mint.
This class needs to be overloaded by any app that imports the Cashu mint.
"""
async def get_keyset(*args, **kwags):
return await get_keyset(*args, **kwags)
async def get_lightning_invoice(*args, **kwags):
return await get_lightning_invoice(*args, **kwags)
async def get_proofs_used(*args, **kwags):
return await get_proofs_used(*args, **kwags)
async def invalidate_proof(*args, **kwags):
return await invalidate_proof(*args, **kwags)
async def get_proofs_pending(*args, **kwags):
return await get_proofs_pending(*args, **kwags)
async def set_proof_pending(*args, **kwags):
return await set_proof_pending(*args, **kwags)
async def unset_proof_pending(*args, **kwags):
return await unset_proof_pending(*args, **kwags)
async def store_keyset(*args, **kwags):
return await store_keyset(*args, **kwags)
async def store_lightning_invoice(*args, **kwags):
return await store_lightning_invoice(*args, **kwags)
async def store_promise(*args, **kwags):
return await store_promise(*args, **kwags)
async def update_lightning_invoice(*args, **kwags):
return await update_lightning_invoice(*args, **kwags)
async def store_promise(
db: Database,
amount: int,
B_: str,
C_: str,
db: Database,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"""
INSERT INTO promises
f"""
INSERT INTO {table_with_schema(db, 'promises')}
(amount, B_b, C_b)
VALUES (?, ?, ?)
""",
@@ -32,23 +86,23 @@ async def get_proofs_used(
):
rows = await (conn or db).fetchall(
"""
SELECT secret from proofs_used
f"""
SELECT secret from {table_with_schema(db, 'proofs_used')}
"""
)
return [row[0] for row in rows]
async def invalidate_proof(
proof: Proof,
db: Database,
proof: Proof,
conn: Optional[Connection] = None,
):
# we add the proof and secret to the used list
await (conn or db).execute(
"""
INSERT INTO proofs_used
f"""
INSERT INTO {table_with_schema(db, 'proofs_used')}
(amount, C, secret)
VALUES (?, ?, ?)
""",
@@ -60,15 +114,64 @@ async def invalidate_proof(
)
async def store_lightning_invoice(
invoice: Invoice,
async def get_proofs_pending(
db: Database,
conn: Optional[Connection] = None,
):
rows = await (conn or db).fetchall(
f"""
SELECT * from {table_with_schema(db, 'proofs_pending')}
"""
)
return [Proof(**r) for r in rows]
async def set_proof_pending(
db: Database,
proof: Proof,
conn: Optional[Connection] = None,
):
# we add the proof and secret to the used list
await (conn or db).execute(
f"""
INSERT INTO {table_with_schema(db, 'proofs_pending')}
(amount, C, secret)
VALUES (?, ?, ?)
""",
(
proof.amount,
str(proof.C),
str(proof.secret),
),
)
async def unset_proof_pending(
proof: Proof,
db: Database,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"""
INSERT INTO invoices
f"""
DELETE FROM {table_with_schema(db, 'proofs_pending')}
WHERE secret = ?
""",
(str(proof["secret"]),),
)
async def store_lightning_invoice(
db: Database,
invoice: Invoice,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
f"""
INSERT INTO {table_with_schema(db, 'invoices')}
(amount, pr, hash, issued)
VALUES (?, ?, ?, ?)
""",
@@ -82,29 +185,29 @@ async def store_lightning_invoice(
async def get_lightning_invoice(
hash: str,
db: Database,
hash: str,
conn: Optional[Connection] = None,
):
row = await (conn or db).fetchone(
"""
SELECT * from invoices
f"""
SELECT * from {table_with_schema(db, 'invoices')}
WHERE hash = ?
""",
(hash,),
)
return Invoice.from_row(row)
return Invoice(**row)
async def update_lightning_invoice(
db: Database,
hash: str,
issued: bool,
db: Database,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"UPDATE invoices SET issued = ? WHERE hash = ?",
f"UPDATE {table_with_schema(db, 'invoices')} SET issued = ? WHERE hash = ?",
(
issued,
hash,
@@ -113,37 +216,37 @@ async def update_lightning_invoice(
async def store_keyset(
db: Database,
keyset: MintKeyset,
mint_url: str = None,
db: Database = None,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"""
INSERT INTO keysets
(id, derivation_path, valid_from, valid_to, first_seen, active)
VALUES (?, ?, ?, ?, ?, ?)
await (conn or db).execute( # type: ignore
f"""
INSERT INTO {table_with_schema(db, 'keysets')}
(id, derivation_path, valid_from, valid_to, first_seen, active, version)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
keyset.id,
keyset.derivation_path,
keyset.valid_from,
keyset.valid_to,
keyset.first_seen,
keyset.valid_from or db.timestamp_now,
keyset.valid_to or db.timestamp_now,
keyset.first_seen or db.timestamp_now,
True,
keyset.version,
),
)
async def get_keyset(
db: Database,
id: str = None,
derivation_path: str = None,
db: Database = None,
derivation_path: str = "",
conn: Optional[Connection] = None,
):
clauses = []
values = []
values: List[Any] = []
clauses.append("active = ?")
values.append(True)
if id:
@@ -156,11 +259,11 @@ async def get_keyset(
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall(
rows = await (conn or db).fetchall( # type: ignore
f"""
SELECT * from keysets
SELECT * from {table_with_schema(db, 'keysets')}
{where}
""",
tuple(values),
)
return [MintKeyset.from_row(row) for row in rows]
return [MintKeyset(**row) for row in rows]

View File

@@ -9,6 +9,7 @@ from loguru import logger
import cashu.core.b_dhke as b_dhke
import cashu.core.bolt11 as bolt11
import cashu.core.legacy as legacy
from cashu.core.base import (
BlindedMessage,
BlindedSignature,
@@ -17,102 +18,129 @@ from cashu.core.base import (
MintKeysets,
Proof,
)
from cashu.core.crypto import derive_keys, derive_keyset_id, derive_pubkeys
from cashu.core.db import Database
from cashu.core.helpers import fee_reserve, sum_proofs
from cashu.core.script import verify_script
from cashu.core.secp import PublicKey
from cashu.core.settings import LIGHTNING, MAX_ORDER
from cashu.core.settings import LIGHTNING, MAX_ORDER, VERSION
from cashu.core.split import amount_split
from cashu.lightning import WALLET
from cashu.mint.crud import (
get_keyset,
get_lightning_invoice,
get_proofs_used,
invalidate_proof,
store_keyset,
store_lightning_invoice,
store_promise,
update_lightning_invoice,
)
from cashu.mint.crud import LedgerCrud
# from starlette_context import context
class Ledger:
def __init__(self, secret_key: str, db: str, derivation_path=""):
def __init__(
self,
db: Database,
seed: str,
derivation_path="",
crud=LedgerCrud,
lightning=None,
):
self.proofs_used: Set[str] = set()
self.master_key = secret_key
self.master_key = seed
self.derivation_path = derivation_path
self.db: Database = Database("mint", db)
self.db = db
self.crud = crud
self.lightning = lightning
async def load_used_proofs(self):
"""Load all used proofs from database."""
self.proofs_used = set(await get_proofs_used(db=self.db))
proofs_used = await self.crud.get_proofs_used(db=self.db)
self.proofs_used = set(proofs_used)
async def init_keysets(self):
"""Loads all past keysets and stores the active one if not already in db"""
# generate current keyset from seed and current derivation path
self.keyset = MintKeyset(
seed=self.master_key, derivation_path=self.derivation_path
async def load_keyset(self, derivation_path, autosave=True):
"""Load current keyset keyset or generate new one."""
keyset = MintKeyset(
seed=self.master_key, derivation_path=derivation_path, version=VERSION
)
# check if current keyset is stored in db and store if not
current_keyset_local: List[MintKeyset] = await get_keyset(
id=self.keyset.id, db=self.db
logger.trace(f"Loading keyset {keyset.id} from db.")
tmp_keyset_local: List[MintKeyset] = await self.crud.get_keyset(
id=keyset.id, db=self.db
)
if not len(current_keyset_local):
logger.debug(f"Storing keyset {self.keyset.id}")
await store_keyset(keyset=self.keyset, db=self.db)
if not len(tmp_keyset_local) and autosave:
logger.trace(f"Storing keyset {keyset.id}.")
await self.crud.store_keyset(keyset=keyset, db=self.db)
# store the new keyset in the current keysets
self.keysets.keysets[keyset.id] = keyset
return keyset
async def init_keysets(self, autosave=True):
"""Loads all keysets from db."""
# load all past keysets from db
# this needs two steps because the types of tmp_keysets and the argument of MintKeysets() are different
tmp_keysets: List[MintKeyset] = await get_keyset(db=self.db)
tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db)
self.keysets = MintKeysets(tmp_keysets)
logger.debug(f"Keysets {self.keysets.keysets}")
logger.trace(f"Loading {len(self.keysets.keysets)} keysets form db.")
# generate all derived keys from stored derivation paths of past keysets
for _, v in self.keysets.keysets.items():
logger.trace(f"Generating keys for keyset {v.id}")
v.generate_keys(self.master_key)
# load the current keyset
self.keyset = await self.load_keyset(self.derivation_path, autosave)
if len(self.keysets.keysets):
logger.debug(f"Loaded {len(self.keysets.keysets)} keysets from db.")
async def _generate_promises(self, amounts: List[int], B_s: List[str]):
async def _generate_promises(
self, B_s: List[BlindedMessage], keyset: MintKeyset = None
):
"""Generates promises that sum to the given amount."""
return [
await self._generate_promise(amount, PublicKey(bytes.fromhex(B_), raw=True))
for (amount, B_) in zip(amounts, B_s)
await self._generate_promise(
b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset
)
for b in B_s
]
async def _generate_promise(self, amount: int, B_: PublicKey):
async def _generate_promise(
self, amount: int, B_: PublicKey, keyset: MintKeyset = None
):
"""Generates a promise for given amount and returns a pair (amount, C')."""
secret_key = self.keyset.private_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
keyset = keyset if keyset else self.keyset
private_key_amount = keyset.private_keys[amount]
C_ = b_dhke.step2_bob(B_, private_key_amount)
await self.crud.store_promise(
amount=amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db
)
return BlindedSignature(amount=amount, C_=C_.serialize().hex())
return BlindedSignature(id=keyset.id, 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_secret_criteria(self, proof: Proof):
"""Verifies that a secret is present"""
"""Verifies that a secret is present and is not too long (DOS prevention)."""
if proof.secret is None or proof.secret == "":
raise Exception("no secret in proof.")
if len(proof.secret) > 64:
raise Exception("secret too long.")
return True
def _verify_proof(self, proof: Proof):
def _verify_proof_bdhke(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}")
# if no keyset id is given in proof, assume the current one
if not proof.id:
secret_key = self.keyset.private_keys[proof.amount]
private_key_amount = self.keyset.private_keys[proof.amount]
else:
# use the appropriate active keyset for this proof.id
secret_key = self.keysets.keysets[proof.id].private_keys[proof.amount]
private_key_amount = self.keysets.keysets[proof.id].private_keys[
proof.amount
]
C = PublicKey(bytes.fromhex(proof.C), raw=True)
return b_dhke.verify(secret_key, C, proof.secret)
# backwards compatibility with old hash_to_curve < 0.4.0
try:
ret = legacy.verify_pre_0_3_3(private_key_amount, C, proof.secret)
if ret:
return ret
except:
pass
return b_dhke.verify(private_key_amount, C, proof.secret)
def _verify_script(self, idx: int, proof: Proof):
"""
@@ -153,10 +181,13 @@ class Ledger:
given = [o.amount for o in outputs]
return given == expected
def _verify_no_duplicates(self, proofs: List[Proof], outputs: List[BlindedMessage]):
def _verify_no_duplicate_proofs(self, proofs: List[Proof]):
secrets = [p.secret for p in proofs]
if len(secrets) != len(list(set(secrets))):
return False
return True
def _verify_no_duplicate_outputs(self, outputs: List[BlindedMessage]):
B_s = [od.B_ for od in outputs]
if len(B_s) != len(list(set(B_s))):
return False
@@ -185,67 +216,132 @@ class Ledger:
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()
error, balance = await self.lightning.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"
)
(
ok,
checking_id,
payment_request,
error_message,
) = await self.lightning.create_invoice(amount, "cashu deposit")
return payment_request, checking_id
async def _check_lightning_invoice(self, amounts, 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)
async def _check_lightning_invoice(self, amount: int, payment_hash: str):
"""
Checks with the Lightning backend whether an invoice with this payment_hash was paid.
Raises exception if invoice is unpaid.
"""
invoice: Invoice = await self.crud.get_lightning_invoice(
hash=payment_hash, db=self.db
)
if invoice is None:
raise Exception("invoice not found.")
if invoice.issued:
raise Exception("tokens already issued for this invoice.")
total_requested = sum(amounts)
if total_requested > invoice.amount:
raise Exception(
f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}"
# set this invoice as issued
await self.crud.update_lightning_invoice(
hash=payment_hash, issued=True, db=self.db
)
try:
if amount > invoice.amount:
raise Exception(
f"requested amount too high: {amount}. Invoice amount: {invoice.amount}"
)
status = await self.lightning.get_invoice_status(payment_hash)
if status.paid:
return status.paid
else:
raise Exception("Lightning invoice not paid yet.")
except Exception as e:
# unset issued
await self.crud.update_lightning_invoice(
hash=payment_hash, issued=False, db=self.db
)
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
raise e
async def _pay_lightning_invoice(self, invoice: str, fees_msat: int):
"""Returns an invoice from the Lightning backend."""
error, _ = await WALLET.status()
error, _ = await self.lightning.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=fees_msat
)
(
ok,
checking_id,
fee_msat,
preimage,
error_message,
) = await self.lightning.pay_invoice(invoice, fee_limit_msat=fees_msat)
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."""
"""
Adds secrets of proofs to the list of known secrets and stores them in the db.
Removes proofs from pending table.
"""
# 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)
await self.crud.invalidate_proof(proof=p, db=self.db)
def _serialize_pubkeys(self):
"""Returns public keys for possible amounts."""
return {a: p.serialize().hex() for a, p in self.keyset.public_keys.items()}
async def _set_proofs_pending(self, proofs: List[Proof]):
"""
If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to
the list of pending proofs or removes them. Used as a mutex for proofs.
"""
# first we check whether these proofs are pending aready
await self._validate_proofs_pending(proofs)
for p in proofs:
try:
await self.crud.set_proof_pending(proof=p, db=self.db)
except:
raise Exception("proofs already pending.")
async def _unset_proofs_pending(self, proofs: List[Proof]):
"""Deletes proofs from pending table."""
# we try: except: this block in order to avoid that any errors here
# could block the _invalidate_proofs() call that happens afterwards.
try:
for p in proofs:
await self.crud.unset_proof_pending(proof=p, db=self.db)
except Exception as e:
print(e)
pass
async def _validate_proofs_pending(self, proofs: List[Proof]):
"""Checks if any of the provided proofs is in the pending proofs table. Raises exception for at least one match."""
proofs_pending = await self.crud.get_proofs_pending(db=self.db)
for p in proofs:
for pp in proofs_pending:
if p.secret == pp.secret:
raise Exception("proofs are pending.")
async def _verify_proofs(self, proofs: List[Proof]):
"""Checks a series of criteria for the verification of proofs."""
# Verify scripts
if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]):
raise Exception("script validation failed.")
# Verify secret criteria
if not all([self._verify_secret_criteria(p) for p in proofs]):
raise Exception("secrets do not match criteria.")
# verify that only unique proofs were used
if not self._verify_no_duplicate_proofs(proofs):
raise Exception("duplicate proofs.")
# Verify proofs
if not all([self._verify_proof_bdhke(p) for p in proofs]):
raise Exception("could not verify proofs.")
# Public methods
def get_keyset(self):
return self._serialize_pubkeys()
def get_keyset(self, keyset_id: str = None):
keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset
return {a: p.serialize().hex() for a, p in keyset.public_keys.items()}
async def request_mint(self, amount):
"""Returns Lightning invoice and stores it in the db."""
@@ -255,46 +351,63 @@ class Ledger:
)
if not payment_request or not checking_id:
raise Exception(f"Could not create Lightning invoice.")
await store_lightning_invoice(invoice, db=self.db)
await self.crud.store_lightning_invoice(invoice=invoice, db=self.db)
return payment_request, checking_id
async def mint(self, B_s: List[PublicKey], amounts: List[int], payment_hash=None):
async def mint(
self,
B_s: List[BlindedMessage],
payment_hash=None,
keyset: MintKeyset = None,
):
"""Mints a promise for coins for B_."""
amounts = [b.amount for b in B_s]
amount = sum(amounts)
# check if lightning invoice was paid
if LIGHTNING:
if not payment_hash:
raise Exception("no payment_hash provided.")
try:
paid = await self._check_lightning_invoice(amounts, payment_hash)
paid = await self._check_lightning_invoice(amount, payment_hash)
except Exception as e:
raise Exception("could not check invoice: " + str(e))
if not paid:
raise Exception("Lightning invoice not paid yet.")
raise e
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)
]
promises = await self._generate_promises(B_s, keyset)
return promises
async def melt(self, proofs: List[Proof], invoice: str):
"""Invalidates proofs and pays a Lightning invoice."""
# Verify proofs
if not all([self._verify_proof(p) for p in proofs]):
raise Exception("could not verify proofs.")
total_provided = sum_proofs(proofs)
invoice_obj = bolt11.decode(invoice)
amount = math.ceil(invoice_obj.amount_msat / 1000)
fees_msat = await self.check_fees(invoice)
assert total_provided >= amount + fees_msat / 1000, Exception(
"provided proofs not enough for Lightning payment."
)
# validate and set proofs as pending
await self._set_proofs_pending(proofs)
try:
await self._verify_proofs(proofs)
total_provided = sum_proofs(proofs)
invoice_obj = bolt11.decode(invoice)
amount = math.ceil(invoice_obj.amount_msat / 1000)
fees_msat = await self.check_fees(invoice)
assert total_provided >= amount + fees_msat / 1000, Exception(
"provided proofs not enough for Lightning payment."
)
if LIGHTNING:
status, preimage = await self._pay_lightning_invoice(invoice, fees_msat)
else:
status, preimage = True, "preimage"
if status == True:
await self._invalidate_proofs(proofs)
except Exception as e:
raise e
finally:
# delete proofs from pending list
await self._unset_proofs_pending(proofs)
status, preimage = await self._pay_lightning_invoice(invoice, fees_msat)
if status == True:
await self._invalidate_proofs(proofs)
return status, preimage
async def check_spendable(self, proofs: List[Proof]):
@@ -307,49 +420,62 @@ class Ledger:
amount = math.ceil(decoded_invoice.amount_msat / 1000)
# hack: check if it's internal, if it exists, it will return paid = False,
# if id does not exist (not internal), it returns paid = None
paid = await WALLET.get_invoice_status(decoded_invoice.payment_hash)
internal = paid.paid == False
if LIGHTNING:
paid = await self.lightning.get_invoice_status(decoded_invoice.payment_hash)
internal = paid.paid == False
else:
internal = True
fees_msat = fee_reserve(amount * 1000, internal)
return fees_msat
async def split(
self, proofs: List[Proof], amount: int, outputs: List[BlindedMessage]
self,
proofs: List[Proof],
amount: int,
outputs: List[BlindedMessage],
keyset: MintKeyset = None,
):
"""Consumes proofs and prepares new promises based on the amount split."""
# set proofs as pending
await self._set_proofs_pending(proofs)
total = sum_proofs(proofs)
# verify that amount is kosher
self._verify_split_amount(amount)
# verify overspending attempt
if amount > total:
raise Exception("split amount is higher than the total sum.")
try:
# verify that amount is kosher
self._verify_split_amount(amount)
# verify overspending attempt
if amount > total:
raise Exception("split amount is higher than the total sum.")
# Verify scripts
if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]):
raise Exception("script verification failed.")
# Verify secret criteria
if not all([self._verify_secret_criteria(p) for p in proofs]):
raise Exception("secrets do not match criteria.")
# verify that only unique proofs and outputs were used
if not self._verify_no_duplicates(proofs, outputs):
raise Exception("duplicate proofs or promises.")
# verify that outputs have the correct amount
if not self._verify_outputs(total, amount, outputs):
raise Exception("split of promises is not as expected.")
# Verify proofs
if not all([self._verify_proof(p) for p in proofs]):
raise Exception("could not verify proofs.")
await self._verify_proofs(proofs)
# verify that only unique outputs were used
if not self._verify_no_duplicate_outputs(outputs):
raise Exception("duplicate promises.")
# verify that outputs have the correct amount
if not self._verify_outputs(total, amount, outputs):
raise Exception("split of promises is not as expected.")
except Exception as e:
raise e
finally:
# delete proofs from pending list
await self._unset_proofs_pending(proofs)
# Mark proofs as used and prepare new promises
await self._invalidate_proofs(proofs)
# split outputs according to amount
outs_fst = amount_split(total - amount)
outs_snd = amount_split(amount)
B_fst = [od.B_ for od in outputs[: len(outs_fst)]]
B_snd = [od.B_ for od in outputs[len(outs_fst) :]]
B_fst = [od for od in outputs[: len(outs_fst)]]
B_snd = [od for od in outputs[len(outs_fst) :]]
# generate promises
prom_fst, prom_snd = await self._generate_promises(
outs_fst, B_fst
), await self._generate_promises(outs_snd, B_snd)
B_fst, keyset
), await self._generate_promises(B_snd, keyset)
# verify amounts in produced proofs
self._verify_equation_balanced(proofs, prom_fst + prom_snd)
return prom_fst, prom_snd

View File

@@ -22,7 +22,8 @@ def main(
ssl_keyfile: str = None,
ssl_certfile: str = None,
):
"""Launched with `poetry run mint` at root level"""
"""This routine starts the uvicorn server if the Cashu mint is
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:

View File

@@ -1,10 +1,11 @@
from cashu.core.db import Database
from cashu.core.migrations import table_with_schema
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'dbversions')} (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
@@ -14,8 +15,8 @@ async def m000_create_migrations_table(db):
async def m001_initial(db: Database):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS promises (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises')} (
amount INTEGER NOT NULL,
B_b TEXT NOT NULL,
C_b TEXT NOT NULL,
@@ -27,8 +28,8 @@ async def m001_initial(db: Database):
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS proofs_used (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} (
amount INTEGER NOT NULL,
C TEXT NOT NULL,
secret TEXT NOT NULL,
@@ -40,8 +41,8 @@ async def m001_initial(db: Database):
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS invoices (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'invoices')} (
amount INTEGER NOT NULL,
pr TEXT NOT NULL,
hash TEXT NOT NULL,
@@ -53,38 +54,38 @@ async def m001_initial(db: Database):
"""
)
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(
# f"""
# CREATE VIEW {table_with_schema(db, 'balance_issued')} AS
# SELECT COALESCE(SUM(s), 0) AS balance FROM (
# SELECT SUM(amount) AS s
# FROM {table_with_schema(db, '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(
# f"""
# CREATE VIEW {table_with_schema(db, 'balance_used')} AS
# SELECT COALESCE(SUM(s), 0) AS balance FROM (
# SELECT SUM(amount) AS s
# FROM {table_with_schema(db, '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
);
"""
)
# await db.execute(
# f"""
# CREATE VIEW {table_with_schema(db, 'balance')} AS
# SELECT s_issued - s_used AS balance FROM (
# SELECT bi.balance AS s_issued, bu.balance AS s_used
# FROM {table_with_schema(db, 'balance_issued')} bi
# CROSS JOIN {table_with_schema(db, 'balance_used')} bu
# );
# """
# )
async def m003_mint_keysets(db: Database):
@@ -93,12 +94,12 @@ async def m003_mint_keysets(db: Database):
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS keysets (
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'keysets')} (
id TEXT NOT NULL,
derivation_path TEXT,
valid_from TIMESTAMP DEFAULT {db.timestamp_now},
valid_to TIMESTAMP DEFAULT {db.timestamp_now},
first_seen TIMESTAMP DEFAULT {db.timestamp_now},
valid_from TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
valid_to TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
first_seen TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
active BOOL DEFAULT TRUE,
UNIQUE (derivation_path)
@@ -108,7 +109,7 @@ async def m003_mint_keysets(db: Database):
)
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS mint_pubkeys (
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_pubkeys')} (
id TEXT NOT NULL,
amount INTEGER NOT NULL,
pubkey TEXT NOT NULL,
@@ -118,3 +119,30 @@ async def m003_mint_keysets(db: Database):
);
"""
)
async def m004_keysets_add_version(db: Database):
"""
Column that remembers with which version
"""
await db.execute(
f"ALTER TABLE {table_with_schema(db, 'keysets')} ADD COLUMN version TEXT"
)
async def m005_pending_proofs_table(db: Database) -> None:
"""
Store pending proofs.
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} (
amount INTEGER NOT NULL,
C TEXT NOT NULL,
secret TEXT NOT NULL,
UNIQUE (secret)
);
"""
)

View File

@@ -1,9 +1,10 @@
from typing import Union
from typing import Dict, List, Union
from fastapi import APIRouter
from secp256k1 import PublicKey
from cashu.core.base import (
BlindedSignature,
CheckFeesRequest,
CheckFeesResponse,
CheckRequest,
@@ -15,25 +16,38 @@ from cashu.core.base import (
SplitRequest,
)
from cashu.core.errors import CashuError
from cashu.mint import ledger
from cashu.mint.startup import ledger
router: APIRouter = APIRouter()
@router.get("/keys")
def keys():
"""Get the public keys of the mint"""
return ledger.get_keyset()
async def keys() -> dict[int, str]:
"""Get the public keys of the mint of the newest keyset"""
keyset = ledger.get_keyset()
return keyset
@router.get("/keys/{idBase64Urlsafe}")
async def keyset_keys(idBase64Urlsafe: str) -> dict[int, str]:
"""
Get the public keys of the mint of a specificy keyset id.
The id is encoded in base64_urlsafe and needs to be converted back to
normal base64 before it can be processed.
"""
id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
keyset = ledger.get_keyset(keyset_id=id)
return keyset
@router.get("/keysets")
def keysets():
async def keysets() -> dict[str, list[str]]:
"""Get all active keysets of the mint"""
return {"keysets": ledger.keysets.get_ids()}
@router.get("/mint")
async def request_mint(amount: int = 0):
async def request_mint(amount: int = 0) -> GetMintResponse:
"""
Request minting of new tokens. The mint responds with a Lightning invoice.
This endpoint can be used for a Lightning invoice UX flow.
@@ -48,29 +62,25 @@ async def request_mint(amount: int = 0):
@router.post("/mint")
async def mint(
payloads: MintRequest,
bolt11: Union[str, None] = None,
mint_request: MintRequest,
payment_hash: Union[str, None] = None,
):
) -> Union[List[BlindedSignature], CashuError]:
"""
Requests the minting of tokens belonging to a paid payment request.
Call this endpoint after `GET /mint`.
"""
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)
promises = await ledger.mint(
mint_request.blinded_messages, payment_hash=payment_hash
)
return promises
except Exception as exc:
return CashuError(error=str(exc))
@router.post("/melt")
async def melt(payload: MeltRequest):
async def melt(payload: MeltRequest) -> GetMeltResponse:
"""
Requests tokens to be destroyed and sent out via Lightning.
"""
@@ -80,13 +90,13 @@ async def melt(payload: MeltRequest):
@router.post("/check")
async def check_spendable(payload: CheckRequest):
async def check_spendable(payload: CheckRequest) -> Dict[int, bool]:
"""Check whether a secret has been spent already or not."""
return await ledger.check_spendable(payload.proofs)
@router.post("/checkfees")
async def check_fees(payload: CheckFeesRequest):
async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse:
"""
Responds with the fees necessary to pay a Lightning invoice.
Used by wallets for figuring out the fees they need to supply.
@@ -97,20 +107,26 @@ async def check_fees(payload: CheckFeesRequest):
@router.post("/split")
async def split(payload: SplitRequest):
async def split(
payload: SplitRequest,
) -> Union[CashuError, PostSplitResponse]:
"""
Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split".
"""
proofs = payload.proofs
amount = payload.amount
# NOTE: backwards compatibility with clients < v0.2.2
outputs = payload.outputs.blinded_messages if payload.outputs else None
assert outputs, Exception("no outputs provided.")
try:
split_return = await ledger.split(proofs, amount, outputs)
except Exception as exc:
return CashuError(error=str(exc))
if not split_return:
return {"error": "there was a problem with the split."}
return CashuError(error="there was an error with the split")
frst_promises, scnd_promises = split_return
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
return resp

View File

@@ -1,26 +1,37 @@
# startup routine of the standalone app. These are the steps that need
# to be taken by external apps importing the cashu mint.
import asyncio
from loguru import logger
from cashu.core.db import Database
from cashu.core.migrations import migrate_databases
from cashu.core.settings import CASHU_DIR, LIGHTNING
from cashu.lightning import WALLET
from cashu.core.settings import CASHU_DIR, LIGHTNING, MINT_PRIVATE_KEY
from cashu.lightning.lnbits import LNbitsWallet
from cashu.mint import migrations
from cashu.mint.ledger import Ledger
from . import ledger
ledger = Ledger(
db=Database("mint", "data/mint"),
seed=MINT_PRIVATE_KEY,
# seed="asd",
derivation_path="0/0/0/0",
lightning=LNbitsWallet() if LIGHTNING else None,
)
async def load_ledger():
async def start_mint_init():
await migrate_databases(ledger.db, migrations)
# await asyncio.wait([m001_initial(ledger.db)])
await ledger.load_used_proofs()
await ledger.init_keysets()
if LIGHTNING:
error_message, balance = await WALLET.status()
error_message, balance = await ledger.lightning.status()
if error_message:
logger.warning(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
f"The backend for {ledger.lightning.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
logger.info(f"Lightning balance: {balance} sat")

1
cashu/nostr Submodule

Submodule cashu/nostr added at d7fb45f6a1

1609
cashu/tor/LICENCE_tor Executable file

File diff suppressed because it is too large Load Diff

BIN
cashu/wallet/wallet_live/.DS_Store → cashu/tor/bundle/.DS_Store vendored Normal file → Executable file

Binary file not shown.

389
cashu/tor/bundle/linux/LICENSE Executable file
View File

@@ -0,0 +1,389 @@
This file contains the license for Tor,
a free software project to provide anonymity on the Internet.
It also lists the licenses for other components used by Tor.
For more information about Tor, see https://www.torproject.org/.
If you got this file as a part of a larger bundle,
there may be other license terms that you should be aware of.
===============================================================================
Tor is distributed under the "3-clause BSD" license, a commonly used
software license that means Tor is both free software and open source:
Copyright (c) 2001-2004, Roger Dingledine
Copyright (c) 2004-2006, Roger Dingledine, Nick Mathewson
Copyright (c) 2007-2019, The Tor Project, Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the names of the copyright owners nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
src/ext/strlcat.c and src/ext/strlcpy.c by Todd C. Miller are licensed
under the following license:
* Copyright (c) 1998 Todd C. Miller <Todd.Miller@courtesan.com>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The name of the author may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
* OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
src/ext/tor_queue.h is licensed under the following license:
* Copyright (c) 1991, 1993
* The Regents of the University of California. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of the University nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
===============================================================================
src/ext/csiphash.c is licensed under the following license:
Copyright (c) 2013 Marek Majkowski <marek@popcount.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
===============================================================================
Trunnel is distributed under this license:
Copyright 2014 The Tor Project, Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the names of the copyright owners nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
getdelim.c is distributed under this license:
Copyright (c) 2011 The NetBSD Foundation, Inc.
All rights reserved.
This code is derived from software contributed to The NetBSD Foundation
by Christos Zoulas.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION, INC. AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
===============================================================================
src/config/geoip and src/config/geoip6:
These files are based on the IPFire Location Database. For more
information, see https://location.ipfire.org/.
The data is distributed under a creative commons "BY-SA 4.0" license.
Find the full license terms at:
https://creativecommons.org/licenses/by-sa/4.0/
===============================================================================
m4/pc_from_ucontext.m4 is available under the following license. Note that
it is *not* built into the Tor software.
Copyright (c) 2005, Google Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
m4/pkg.m4 is available under the following license. Note that
it is *not* built into the Tor software.
pkg.m4 - Macros to locate and utilise pkg-config. -*- Autoconf -*-
serial 1 (pkg-config-0.24)
Copyright © 2004 Scott James Remnant <scott@netsplit.com>.
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
As a special exception to the GNU General Public License, if you
distribute this file as part of a program that contains a
configuration script generated by Autoconf, you may include it under
the same distribution terms that you use for the rest of that program.
===============================================================================
src/ext/readpassphrase.[ch] are distributed under this license:
Copyright (c) 2000-2002, 2007 Todd C. Miller <Todd.Miller@courtesan.com>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
Sponsored in part by the Defense Advanced Research Projects
Agency (DARPA) and Air Force Research Laboratory, Air Force
Materiel Command, USAF, under agreement number F39502-99-1-0512.
===============================================================================
src/ext/mulodi4.c is distributed under this license:
=========================================================================
compiler_rt License
=========================================================================
The compiler_rt library is dual licensed under both the
University of Illinois "BSD-Like" license and the MIT license.
As a user of this code you may choose to use it under either
license. As a contributor, you agree to allow your code to be
used under both.
Full text of the relevant licenses is included below.
=========================================================================
University of Illinois/NCSA
Open Source License
Copyright (c) 2009-2016 by the contributors listed in CREDITS.TXT
All rights reserved.
Developed by:
LLVM Team
University of Illinois at Urbana-Champaign
http://llvm.org
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal with the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
* Redistributions of source code must retain the above
copyright notice, this list of conditions and the following
disclaimers.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimers in the documentation and/or other materials
provided with the distribution.
* Neither the names of the LLVM Team, University of Illinois
at Urbana-Champaign, nor the names of its contributors may
be used to endorse or promote products derived from this
Software without specific prior written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS WITH THE SOFTWARE.
=========================================================================
Copyright (c) 2009-2015 by the contributors listed in CREDITS.TXT
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
=========================================================================
Copyrights and Licenses for Third Party Software Distributed with LLVM:
=========================================================================
The LLVM software contains code written by third parties. Such
software will have its own individual LICENSE.TXT file in the
directory in which it appears. This file will describe the
copyrights, license, and restrictions which apply to that code.
The disclaimer of warranty in the University of Illinois Open
Source License applies to all code in the LLVM Distribution, and
nothing in any of the other licenses gives permission to use the
names of the LLVM Team or the University of Illinois to endorse
or promote products derived from this Software.
===============================================================================
If you got Tor as a static binary with OpenSSL included, then you should know:
"This product includes software developed by the OpenSSL Project
for use in the OpenSSL Toolkit (http://www.openssl.org/)"
===============================================================================

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
cashu/tor/bundle/linux/tor Executable file

Binary file not shown.

Binary file not shown.

BIN
cashu/tor/bundle/mac/tor Executable file

Binary file not shown.

34
cashu/tor/timeout.py Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python
import os
import subprocess
import sys
import time
def main():
assert len(sys.argv) > 2, "Usage: timeout.py [seconds] [command...]"
# cmd = " ".join(sys.argv[2:]) # for with shell=True
cmd = sys.argv[2:]
timeout = int(sys.argv[1])
assert timeout > 0, "timeout (in seconds) must be a positive integer."
start_time = time.time()
pro = subprocess.Popen(cmd, shell=False)
while time.time() < start_time + timeout + 1:
time.sleep(1)
pro.terminate()
pro.wait()
pro.kill()
pro.wait()
# we kill the child processes as well (tor.py and tor) just to be sure
os.kill(pro.pid + 1, 15)
os.kill(pro.pid + 1, 9)
os.kill(pro.pid + 2, 15)
os.kill(pro.pid + 2, 9)
if __name__ == "__main__":
main()

177
cashu/tor/tor.py Executable file
View File

@@ -0,0 +1,177 @@
import os
import pathlib
import platform
import socket
import subprocess
import sys
import time
from loguru import logger
class TorProxy:
def __init__(self, timeout=False):
self.base_path = pathlib.Path(__file__).parent.resolve()
self.platform = platform.system()
self.timeout = 60 * 60 if timeout else 0 # seconds
self.tor_proc = None
self.pid_file = os.path.join(self.base_path, "tor.pid")
self.tor_pid = None
self.startup_finished = True
self.tor_running = self.is_running()
@classmethod
def check_platform(cls):
if platform.system() == "Linux":
if platform.machine() != "x86_64":
logger.debug("Builtin Tor not supported on this platform.")
return False
return True
def log_status(self):
logger.debug(f"Tor binary path: {self.tor_path()}")
logger.debug(f"Tor config path: {self.tor_config_path()}")
logger.debug(f"Tor running: {self.tor_running}")
logger.debug(
f"Tor port open: {self.is_port_open()}",
)
logger.debug(f"Tor PID in tor.pid: {self.read_pid()}")
logger.debug(f"Tor PID running: {self.signal_pid(self.read_pid())}")
def run_daemon(self, verbose=False):
if not self.check_platform() or self.tor_running:
return
self.log_status()
logger.debug("Starting Tor")
cmd = [f"{self.tor_path()}", "--defaults-torrc", f"{self.tor_config_path()}"]
if self.timeout:
logger.debug(f"Starting tor with timeout {self.timeout}s")
cmd = [
sys.executable,
os.path.join(self.base_path, "timeout.py"),
f"{self.timeout}",
] + cmd
env = dict(os.environ)
if platform.system() == "Linux":
env["LD_LIBRARY_PATH"] = os.path.dirname(self.tor_path())
elif platform.system() == "Darwin":
env["DYLD_LIBRARY_PATH"] = os.path.dirname(self.tor_path())
self.tor_proc = subprocess.Popen(
cmd,
env=env,
shell=False,
close_fds=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
logger.debug("Running tor daemon with pid {}".format(self.tor_proc.pid))
with open(self.pid_file, "w", encoding="utf-8") as f:
f.write(str(self.tor_proc.pid))
self.wait_until_startup(verbose=verbose)
def stop_daemon(self, pid=None):
pid = pid or self.tor_proc.pid if self.tor_proc else None
if self.tor_proc and pid:
self.signal_pid(pid, 15) # sigterm
time.sleep(5)
self.signal_pid(pid, 9) # sigkill
if os.path.exists(self.pid_file):
os.remove(self.pid_file)
def tor_path(self):
PATHS = {
"Windows": os.path.join(self.base_path, "bundle", "win", "Tor", "tor.exe"),
"Linux": os.path.join(self.base_path, "bundle", "linux", "tor"),
"Darwin": os.path.join(self.base_path, "bundle", "mac", "tor"),
}
# make sure that file has correct permissions
try:
logger.debug(f"Setting permissions of {PATHS[platform.system()]} to 755")
os.chmod(PATHS[platform.system()], 0o755)
except:
logger.debug("Exception: could not set permissions of Tor binary")
return PATHS[platform.system()]
def tor_config_path(self):
return os.path.join(self.base_path, "torrc")
def is_running(self):
# another tor proxy is running
if not self.is_port_open():
return False
# our tor proxy running from a previous session
if self.signal_pid(self.read_pid()):
return True
# current attached process running
return self.tor_proc and self.tor_proc.poll() is None
def wait_until_startup(self, verbose=False):
if not self.check_platform():
return
if self.is_port_open():
return
if self.tor_proc is None:
raise Exception("Tor proxy not attached.")
if not self.tor_proc.stdout:
raise Exception("could not get tor stdout.")
if verbose:
print("Starting Tor...", end="", flush=True)
for line in self.tor_proc.stdout:
# print(line)
if verbose:
print(".", end="", flush=True)
if "Bootstrapped 100%" in str(line):
if verbose:
print("done", flush=True)
break
# tor is ready
self.startup_finished = True
return
def is_port_open(self):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
location = ("127.0.0.1", 9050)
try:
s.connect(location)
s.close()
return True
except Exception as e:
return False
def read_pid(self):
if not os.path.isfile(self.pid_file):
return None
with open(self.pid_file, "r") as f:
pid = f.readlines()
# check if pid is valid
if len(pid) == 0 or not int(pid[0]) > 0:
return None
return pid[0]
def signal_pid(self, pid, signal=0):
"""
Checks whether a process with pid is running (signal 0 is not a kill signal!)
or stops (signal 15) or kills it (signal 9).
"""
if not pid:
return False
if not int(pid) > 0:
return False
pid = int(pid)
try:
os.kill(pid, signal)
except:
return False
else:
return True
if __name__ == "__main__":
tor = TorProxy(timeout=True)
tor.run_daemon(verbose=True)
# time.sleep(5)
# logger.debug("Killing Tor")
# tor.stop_daemon()

254
cashu/tor/torrc Executable file
View File

@@ -0,0 +1,254 @@
## Configuration file for a typical Tor user
## Last updated 28 February 2019 for Tor 0.3.5.1-alpha.
## (may or may not work for much older or much newer versions of Tor.)
##
## Lines that begin with "## " try to explain what's going on. Lines
## that begin with just "#" are disabled commands: you can enable them
## by removing the "#" symbol.
##
## See 'man tor', or https://www.torproject.org/docs/tor-manual.html,
## for more options you can use in this file.
##
## Tor will look for this file in various places based on your platform:
## https://www.torproject.org/docs/faq#torrc
## Tor opens a SOCKS proxy on port 9050 by default -- even if you don't
## configure one below. Set "SOCKSPort 0" if you plan to run Tor only
## as a relay, and not make any local application connections yourself.
SOCKSPort 9050 # Default: Bind to localhost:9050 for local connections.
#SOCKSPort 192.168.0.1:9100 # Bind to this address:port too.
## Entry policies to allow/deny SOCKS requests based on IP address.
## First entry that matches wins. If no SOCKSPolicy is set, we accept
## all (and only) requests that reach a SOCKSPort. Untrusted users who
## can access your SOCKSPort may be able to learn about the connections
## you make.
#SOCKSPolicy accept 192.168.0.0/16
#SOCKSPolicy accept6 FC00::/7
#SOCKSPolicy reject *
SOCKSPolicy accept 127.0.0.1
## Logs go to stdout at level "notice" unless redirected by something
## else, like one of the below lines. You can have as many Log lines as
## you want.
##
## We advise using "notice" in most cases, since anything more verbose
## may provide sensitive information to an attacker who obtains the logs.
##
## Send all messages of level 'notice' or higher to /usr/local/var/log/tor/notices.log
#Log notice file /usr/local/var/log/tor/notices.log
## Send every possible message to /usr/local/var/log/tor/debug.log
#Log debug file /usr/local/var/log/tor/debug.log
## Use the system log instead of Tor's logfiles
#Log notice syslog
## To send all messages to stderr:
#Log debug stderr
## Uncomment this to start the process in the background... or use
## --runasdaemon 1 on the command line. This is ignored on Windows;
## see the FAQ entry if you want Tor to run as an NT service.
#RunAsDaemon 1
## The directory for keeping all the keys/etc. By default, we store
## things in $HOME/.tor on Unix, and in Application Data\tor on Windows.
#DataDirectory /usr/local/var/lib/tor
## The port on which Tor will listen for local connections from Tor
## controller applications, as documented in control-spec.txt.
ControlPort 9051
## If you enable the controlport, be sure to enable one of these
## authentication methods, to prevent attackers from accessing it.
HashedControlPassword 16:3F85DAF2A2A34032603235343E19ABBE3CB6BF03F1443984F21EEE749F
#CookieAuthentication 1
############### This section is just for location-hidden services ###
## Once you have configured a hidden service, you can look at the
## contents of the file ".../hidden_service/hostname" for the address
## to tell people.
##
## HiddenServicePort x y:z says to redirect requests on port x to the
## address y:z.
#HiddenServiceDir /usr/local/var/lib/tor/hidden_service/
#HiddenServicePort 80 127.0.0.1:80
#HiddenServiceDir /usr/local/var/lib/tor/other_hidden_service/
#HiddenServicePort 80 127.0.0.1:80
#HiddenServicePort 22 127.0.0.1:22
################ This section is just for relays #####################
#
## See https://www.torproject.org/docs/tor-doc-relay for details.
## Required: what port to advertise for incoming Tor connections.
#ORPort 9001
## If you want to listen on a port other than the one advertised in
## ORPort (e.g. to advertise 443 but bind to 9090), you can do it as
## follows. You'll need to do ipchains or other port forwarding
## yourself to make this work.
#ORPort 443 NoListen
#ORPort 127.0.0.1:9090 NoAdvertise
## If you want to listen on IPv6 your numeric address must be explictly
## between square brackets as follows. You must also listen on IPv4.
#ORPort [2001:DB8::1]:9050
## The IP address or full DNS name for incoming connections to your
## relay. Leave commented out and Tor will guess.
Address 1.1.1.1
## If you have multiple network interfaces, you can specify one for
## outgoing traffic to use.
## OutboundBindAddressExit will be used for all exit traffic, while
## OutboundBindAddressOR will be used for all OR and Dir connections
## (DNS connections ignore OutboundBindAddress).
## If you do not wish to differentiate, use OutboundBindAddress to
## specify the same address for both in a single line.
#OutboundBindAddressExit 10.0.0.4
#OutboundBindAddressOR 10.0.0.5
## A handle for your relay, so people don't have to refer to it by key.
## Nicknames must be between 1 and 19 characters inclusive, and must
## contain only the characters [a-zA-Z0-9].
## If not set, "Unnamed" will be used.
#Nickname ididnteditheconfig
## Define these to limit how much relayed traffic you will allow. Your
## own traffic is still unthrottled. Note that RelayBandwidthRate must
## be at least 75 kilobytes per second.
## Note that units for these config options are bytes (per second), not
## bits (per second), and that prefixes are binary prefixes, i.e. 2^10,
## 2^20, etc.
#RelayBandwidthRate 100 KBytes # Throttle traffic to 100KB/s (800Kbps)
#RelayBandwidthBurst 200 KBytes # But allow bursts up to 200KB (1600Kb)
## Use these to restrict the maximum traffic per day, week, or month.
## Note that this threshold applies separately to sent and received bytes,
## not to their sum: setting "40 GB" may allow up to 80 GB total before
## hibernating.
##
## Set a maximum of 40 gigabytes each way per period.
#AccountingMax 40 GBytes
## Each period starts daily at midnight (AccountingMax is per day)
#AccountingStart day 00:00
## Each period starts on the 3rd of the month at 15:00 (AccountingMax
## is per month)
#AccountingStart month 3 15:00
## Administrative contact information for this relay or bridge. This line
## can be used to contact you if your relay or bridge is misconfigured or
## something else goes wrong. Note that we archive and publish all
## descriptors containing these lines and that Google indexes them, so
## spammers might also collect them. You may want to obscure the fact that
## it's an email address and/or generate a new address for this purpose.
##
## If you are running multiple relays, you MUST set this option.
##
#ContactInfo Random Person <nobody AT example dot com>
## You might also include your PGP or GPG fingerprint if you have one:
#ContactInfo 0xFFFFFFFF Random Person <nobody AT example dot com>
## Uncomment this to mirror directory information for others. Please do
## if you have enough bandwidth.
#DirPort 9030 # what port to advertise for directory connections
## If you want to listen on a port other than the one advertised in
## DirPort (e.g. to advertise 80 but bind to 9091), you can do it as
## follows. below too. You'll need to do ipchains or other port
## forwarding yourself to make this work.
#DirPort 80 NoListen
#DirPort 127.0.0.1:9091 NoAdvertise
## Uncomment to return an arbitrary blob of html on your DirPort. Now you
## can explain what Tor is if anybody wonders why your IP address is
## contacting them. See contrib/tor-exit-notice.html in Tor's source
## distribution for a sample.
#DirPortFrontPage /usr/local/etc/tor/tor-exit-notice.html
## Uncomment this if you run more than one Tor relay, and add the identity
## key fingerprint of each Tor relay you control, even if they're on
## different networks. You declare it here so Tor clients can avoid
## using more than one of your relays in a single circuit. See
## https://www.torproject.org/docs/faq#MultipleRelays
## However, you should never include a bridge's fingerprint here, as it would
## break its concealability and potentially reveal its IP/TCP address.
##
## If you are running multiple relays, you MUST set this option.
##
## Note: do not use MyFamily on bridge relays.
#MyFamily $keyid,$keyid,...
## Uncomment this if you want your relay to be an exit, with the default
## exit policy (or whatever exit policy you set below).
## (If ReducedExitPolicy, ExitPolicy, or IPv6Exit are set, relays are exits.
## If none of these options are set, relays are non-exits.)
#ExitRelay 1
## Uncomment this if you want your relay to allow IPv6 exit traffic.
## (Relays do not allow any exit traffic by default.)
#IPv6Exit 1
## Uncomment this if you want your relay to be an exit, with a reduced set
## of exit ports.
#ReducedExitPolicy 1
## Uncomment these lines if you want your relay to be an exit, with the
## specified set of exit IPs and ports.
##
## A comma-separated list of exit policies. They're considered first
## to last, and the first match wins.
##
## If you want to allow the same ports on IPv4 and IPv6, write your rules
## using accept/reject *. If you want to allow different ports on IPv4 and
## IPv6, write your IPv6 rules using accept6/reject6 *6, and your IPv4 rules
## using accept/reject *4.
##
## If you want to _replace_ the default exit policy, end this with either a
## reject *:* or an accept *:*. Otherwise, you're _augmenting_ (prepending to)
## the default exit policy. Leave commented to just use the default, which is
## described in the man page or at
## https://www.torproject.org/documentation.html
##
## Look at https://www.torproject.org/faq-abuse.html#TypicalAbuses
## for issues you might encounter if you use the default exit policy.
##
## If certain IPs and ports are blocked externally, e.g. by your firewall,
## you should update your exit policy to reflect this -- otherwise Tor
## users will be told that those destinations are down.
##
## For security, by default Tor rejects connections to private (local)
## networks, including to the configured primary public IPv4 and IPv6 addresses,
## and any public IPv4 and IPv6 addresses on any interface on the relay.
## See the man page entry for ExitPolicyRejectPrivate if you want to allow
## "exit enclaving".
##
#ExitPolicy accept *:6660-6667,reject *:* # allow irc ports on IPv4 and IPv6 but no more
#ExitPolicy accept *:119 # accept nntp ports on IPv4 and IPv6 as well as default exit policy
#ExitPolicy accept *4:119 # accept nntp ports on IPv4 only as well as default exit policy
#ExitPolicy accept6 *6:119 # accept nntp ports on IPv6 only as well as default exit policy
#ExitPolicy reject *:* # no exits allowed
## Bridge relays (or "bridges") are Tor relays that aren't listed in the
## main directory. Since there is no complete public list of them, even an
## ISP that filters connections to all the known Tor relays probably
## won't be able to block all the bridges. Also, websites won't treat you
## differently because they won't know you're running Tor. If you can
## be a real relay, please do; but if not, be a bridge!
##
## Warning: when running your Tor as a bridge, make sure than MyFamily is
## NOT configured.
#BridgeRelay 1
## By default, Tor will advertise your bridge to users through various
## mechanisms like https://bridges.torproject.org/. If you want to run
## a private bridge, for example because you'll give out your bridge
## address manually to your friends, uncomment this line:
#PublishServerDescriptor 0
## Configuration options can be imported from files or folders using the %include
## option with the value being a path. If the path is a file, the options from the
## file will be parsed as if they were written where the %include option is. If
## the path is a folder, all files on that folder will be parsed following lexical
## order. Files starting with a dot are ignored. Files on subfolders are ignored.
## The %include option can be used recursively.
#%include /etc/torrc.d/
#%include /etc/torrc.custom
#HTTPTunnelPort 8118

View File

@@ -1,3 +1,3 @@
import sys
sys.tracebacklimit = None
sys.tracebacklimit = None # type: ignore

492
cashu/wallet/cli.py Executable file → Normal file
View File

@@ -3,30 +3,60 @@
import asyncio
import base64
import json
import math
import os
import sys
import threading
import time
import urllib.parse
from datetime import datetime
from functools import wraps
from itertools import groupby
from operator import itemgetter
from os import listdir
from os.path import isdir, join
from typing import Dict, List
import click
from loguru import logger
import cashu.core.bolt11 as bolt11
from cashu.core.base import Proof
from cashu.core.bolt11 import Invoice, decode
from cashu.core.helpers import fee_reserve, sum_proofs
from cashu.core.helpers import sum_proofs
from cashu.core.migrations import migrate_databases
from cashu.core.settings import CASHU_DIR, DEBUG, ENV_FILE, LIGHTNING, MINT_URL, VERSION
from cashu.core.settings import (
CASHU_DIR,
DEBUG,
ENV_FILE,
LIGHTNING,
MINT_URL,
NOSTR_PRIVATE_KEY,
NOSTR_RELAYS,
SOCKS_HOST,
SOCKS_PORT,
TOR,
VERSION,
)
from cashu.nostr.nostr.client.client import NostrClient
from cashu.nostr.nostr.event import Event
from cashu.nostr.nostr.key import PublicKey
from cashu.tor.tor import TorProxy
from cashu.wallet import migrations
from cashu.wallet.crud import get_reserved_proofs, get_unused_locks
from cashu.wallet.crud import (
get_keyset,
get_lightning_invoices,
get_reserved_proofs,
get_unused_locks,
)
from cashu.wallet.wallet import Wallet as Wallet
from .cli_helpers import (
get_mint_wallet,
print_mint_balances,
proofs_to_token,
redeem_multimint,
token_from_lnbits_link,
verify_mints,
)
async def init_wallet(wallet: Wallet):
"""Performs migrations and loads proofs from db."""
@@ -52,9 +82,24 @@ class NaturalOrderGroup(click.Group):
)
@click.pass_context
def cli(ctx, host: str, walletname: str):
if TOR and not TorProxy().check_platform():
error_str = "Your settings say TOR=true but the built-in Tor bundle is not supported on your system. You have two options: Either install Tor manually and set TOR=FALSE and SOCKS_HOST=localhost and SOCKS_PORT=9050 in your Cashu config (recommended). Or turn off Tor by setting TOR=false (not recommended). Cashu will not work until you edit your config file accordingly."
error_str += "\n\n"
if ENV_FILE:
error_str += f"Edit your Cashu config file here: {ENV_FILE}"
env_path = ENV_FILE
else:
error_str += (
f"Ceate a new Cashu config file here: {os.path.join(CASHU_DIR, '.env')}"
)
env_path = os.path.join(CASHU_DIR, ".env")
error_str += f'\n\nYou can turn off Tor with this command: echo "TOR=FALSE" >> {env_path}'
raise Exception(error_str)
# configure logger
logger.remove()
logger.add(sys.stderr, level="DEBUG" if DEBUG else "INFO")
ctx.ensure_object(dict)
ctx.obj["HOST"] = host
ctx.obj["WALLET_NAME"] = walletname
@@ -73,26 +118,55 @@ def coro(f):
return wrapper
@cli.command("pay", help="Pay Lightning invoice.")
@click.argument("invoice", type=str)
@click.option(
"--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool
)
@click.pass_context
@coro
async def pay(ctx, invoice: str, yes: bool):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
wallet.status()
amount, fees = await wallet.get_pay_amount_with_fees(invoice)
if not yes:
click.confirm(
f"Pay {amount - fees} sat ({amount} sat incl. fees)?",
abort=True,
default=True,
)
print(f"Paying Lightning invoice ...")
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, invoice)
wallet.status()
@cli.command("invoice", help="Create Lighting invoice.")
@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):
async def invoice(ctx, amount: int, hash: str):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
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:
invoice = await wallet.request_mint(amount)
if invoice.pr:
print(f"Pay invoice to mint {amount} sat:")
print("")
print(f"Invoice: {r['pr']}")
print(f"Invoice: {invoice.pr}")
print("")
print(
f"Execute this command if you abort the check:\ncashu invoice {amount} --hash {r['hash']}"
f"Execute this command if you abort the check:\ncashu invoice {amount} --hash {invoice.hash}"
)
check_until = time.time() + 5 * 60 # check for five minutes
print("")
@@ -105,7 +179,7 @@ async def mint(ctx, amount: int, hash: str):
while time.time() < check_until and not paid:
time.sleep(3)
try:
await wallet.mint(amount, r["hash"])
await wallet.mint(amount, invoice.hash)
paid = True
print(" Invoice paid.")
except Exception as e:
@@ -119,116 +193,296 @@ async def mint(ctx, amount: int, hash: str):
return
@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 wallet.load_mint()
wallet.status()
decoded_invoice: Invoice = bolt11.decode(invoice)
# check if it's an internal payment
fees = (await wallet.check_fees(invoice))["fee"]
amount = math.ceil(
(decoded_invoice.amount_msat + fees * 1000) / 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, invoice)
wallet.status()
@cli.command("balance", help="Balance.")
@click.option(
"--verbose",
"-v",
default=False,
is_flag=True,
help="Show pending tokens as well.",
type=bool,
)
@click.pass_context
@coro
async def balance(ctx):
async def balance(ctx, verbose):
wallet: Wallet = ctx.obj["WALLET"]
keyset_balances = wallet.balance_per_keyset()
if len(keyset_balances) > 1:
print(f"You have balances in {len(keyset_balances)} keysets:")
print("")
for k, v in keyset_balances.items():
print(
f"Keyset: {k or 'undefined'} Balance: {v['balance']} sat (available: {v['available']} sat)"
)
print("")
print(
f"Balance: {wallet.balance} sat (available: {wallet.available_balance} sat in {len([p for p in wallet.proofs if not p.reserved])} tokens)"
if verbose:
# show balances per keyset
keyset_balances = wallet.balance_per_keyset()
if len(keyset_balances) > 1:
print(f"You have balances in {len(keyset_balances)} keysets:")
print("")
for k, v in keyset_balances.items():
print(
f"Keyset: {k} - Balance: {v['available']} sat (pending: {v['balance']-v['available']} sat)"
)
print("")
await print_mint_balances(ctx, wallet)
if verbose:
print(
f"Balance: {wallet.available_balance} sat (pending: {wallet.balance-wallet.available_balance} sat) in {len([p for p in wallet.proofs if not p.reserved])} tokens"
)
else:
print(f"Balance: {wallet.available_balance} sat")
async def nostr_send(ctx, amount: int, pubkey: str, verbose: bool, yes: bool):
"""
Sends tokens via nostr.
"""
wallet = await get_mint_wallet(ctx)
await wallet.load_proofs()
_, send_proofs = await wallet.split_to_send(
wallet.proofs, amount, set_reserved=True
)
token = await wallet.serialize_proofs(send_proofs)
print("")
print(token)
if not yes:
print("")
click.confirm(
f"Send {amount} sat to nostr pubkey {pubkey}?",
abort=True,
default=True,
)
# we only use ephemeral private keys for sending
client = NostrClient(relays=NOSTR_RELAYS)
if verbose:
print(f"Your ephemeral nostr private key: {client.private_key.hex()}")
await asyncio.sleep(1)
client.dm(token, PublicKey(bytes.fromhex(pubkey)))
print(f"Token sent to {pubkey}")
client.close()
@cli.command("send", help="Send coins.")
@click.argument("amount", type=int)
@click.option("--lock", "-l", default=None, help="Lock coins (P2SH).", type=str)
@click.pass_context
@coro
async def send(ctx, amount: int, lock: str):
async def send(ctx, amount: int, lock: str, legacy: bool):
"""
Prints token to send to stdout.
"""
if lock and len(lock) < 22:
print("Error: lock has to be at least 22 characters long.")
return
p2sh = False
if lock and len(lock.split("P2SH:")) == 2:
p2sh = True
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
wallet.status()
_, send_proofs = await wallet.split_to_send(wallet.proofs, amount, lock)
await wallet.set_reserved(send_proofs, reserved=True)
coin = await wallet.serialize_proofs(
send_proofs, hide_secrets=True if lock and not p2sh else False
wallet = await get_mint_wallet(ctx)
await wallet.load_proofs()
_, send_proofs = await wallet.split_to_send(
wallet.proofs, amount, lock, set_reserved=True
)
print(coin)
token = await wallet.serialize_proofs(
send_proofs,
include_mints=True,
)
print(token)
if legacy:
print("")
print(
"Legacy token without mint information for older clients. This token can only be be received by wallets who use the mint the token is issued from:"
)
print("")
token = await wallet.serialize_proofs(
send_proofs,
legacy=True,
)
print(token)
wallet.status()
@cli.command("receive", help="Receive coins.")
@click.argument("coin", type=str)
@click.option("--lock", "-l", default=None, help="Unlock coins.", type=str)
@cli.command("send", help="Send tokens.")
@click.argument("amount", type=int)
@click.option(
"--nostr",
"-n",
help="Send to nostr pubkey",
type=str,
)
@click.option("--lock", "-l", default=None, help="Lock tokens (P2SH).", type=str)
@click.option(
"--legacy",
default=False,
is_flag=True,
help="Print legacy token without mint information.",
type=bool,
)
@click.option(
"--verbose",
"-v",
default=False,
is_flag=True,
help="Show more information.",
type=bool,
)
@click.option(
"--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool
)
@click.pass_context
@coro
async def receive(ctx, coin: str, lock: str):
async def send_command(
ctx,
amount: int,
nostr: str,
lock: str,
legacy: bool,
verbose: bool,
yes: bool,
):
if nostr is None:
await send(ctx, amount, lock, legacy)
else:
await nostr_send(ctx, amount, nostr, verbose, yes)
async def receive(ctx, token: str, lock: str):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
wallet.status()
# check for P2SH locks
if lock:
# load the script and signature of this address from the database
assert len(lock.split("P2SH:")) == 2, Exception(
"lock has wrong format. Expected P2SH:<address>."
)
address_split = lock.split("P2SH:")[1]
p2shscripts = await get_unused_locks(address_split, db=wallet.db)
assert len(p2shscripts) == 1, Exception("lock not found.")
script = p2shscripts[0].script
signature = p2shscripts[0].signature
script, signature = p2shscripts[0].script, p2shscripts[0].signature
else:
script, signature = None, None
proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin))]
_, _ = await wallet.redeem(proofs, scnd_script=script, scnd_siganture=signature)
# deserialize token
# ----- backwards compatibility -----
# we support old tokens (< 0.7) without mint information and (W3siaWQ...)
# new tokens (>= 0.7) with multiple mint support (eyJ0b2...)
try:
# backwards compatibility: tokens without mint information
# supports tokens of the form W3siaWQiOiJH
# if it's an lnbits https:// link with a token as an argument, speacial treatment
token, url = token_from_lnbits_link(token)
# assume W3siaWQiOiJH.. token
# next line trows an error if the desirialization with the old format doesn't
# work and we can assume it's the new format
proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))]
# we take the proofs parsed from the old format token and produce a new format token with it
token = await proofs_to_token(wallet, proofs, url)
except:
pass
# ----- receive token -----
# deserialize token
dtoken = json.loads(base64.urlsafe_b64decode(token))
assert "tokens" in dtoken, Exception("no proofs in token")
includes_mint_info: bool = "mints" in dtoken and dtoken.get("mints") is not None
# if there is a `mints` field in the token
# we check whether the token has mints that we don't know yet
# and ask the user if they want to trust the new mitns
if includes_mint_info:
# we ask the user to confirm any new mints the tokens may include
await verify_mints(ctx, dtoken)
# redeem tokens with new wallet instances
await redeem_multimint(ctx, dtoken, script, signature)
# reload main wallet so the balance updates
await wallet.load_proofs()
else:
# no mint information present, we extract the proofs and use wallet's default mint
proofs = [Proof(**p) for p in dtoken["tokens"]]
_, _ = await wallet.redeem(proofs, script, signature)
wallet.status()
@cli.command("burn", help="Burn spent coins.")
@click.argument("coin", required=False, type=str)
@click.option("--all", "-a", default=False, is_flag=True, help="Burn all spent coins.")
async def receive_nostr(ctx, verbose: bool):
if NOSTR_PRIVATE_KEY is None:
print(
"Warning: No nostr private key set! You don't have NOSTR_PRIVATE_KEY set in your .env file. I will create a random private key for this session but I will not remember it."
)
print("")
client = NostrClient(privatekey_hex=NOSTR_PRIVATE_KEY, relays=NOSTR_RELAYS)
print(f"Your nostr public key: {client.public_key.hex()}")
if verbose:
print(f"Your nostr private key (do not share!): {client.private_key.hex()}")
await asyncio.sleep(2)
def get_token_callback(event: Event, decrypted_content):
if verbose:
print(
f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content}"
)
try:
# call the receive method
asyncio.run(receive(ctx, decrypted_content, ""))
except Exception as e:
pass
t = threading.Thread(
target=client.get_dm,
args=(
client.public_key,
get_token_callback,
),
name="Nostr DM",
)
t.start()
@cli.command("receive", help="Receive tokens.")
@click.argument("token", type=str, default="")
@click.option("--lock", "-l", default=None, help="Unlock tokens.", type=str)
@click.option(
"--force", "-f", default=False, is_flag=True, help="Force check on all coins."
"--nostr", "-n", default=False, is_flag=True, help="Receive tokens via nostr."
)
@click.option(
"--verbose",
"-v",
help="Display more information.",
is_flag=True,
default=False,
type=bool,
)
@click.pass_context
@coro
async def burn(ctx, coin: str, all: bool, force: bool):
async def receive_cli(ctx, token: str, lock: str, nostr: bool, verbose: bool):
wallet: Wallet = ctx.obj["WALLET"]
wallet.status()
if token:
await receive(ctx, token, lock)
elif nostr:
await receive_nostr(ctx, verbose)
else:
print("Error: enter token or use the flag --nostr.")
@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 wallet.load_mint()
if not (all or coin or force) or (coin and all):
if not (all or token or force) or (token and all):
print(
"Error: enter a coin or use --all to burn all pending coins or --force to check all coins."
"Error: enter a token or use --all to burn all pending tokens or --force to check all tokens."
)
return
if all:
@@ -239,40 +493,36 @@ async def burn(ctx, coin: str, all: bool, force: bool):
proofs = wallet.proofs
else:
# check only the specified ones
proofs = [
Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin))
]
proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))]
wallet.status()
await wallet.invalidate(proofs)
wallet.status()
@cli.command("pending", help="Show pending coins.")
@cli.command("pending", help="Show pending tokens.")
@click.pass_context
@coro
async def pending(ctx):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
reserved_proofs = await get_reserved_proofs(wallet.db)
if len(reserved_proofs):
print(f"--------------------------\n")
sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id"))
sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) # type: ignore
for i, (key, value) in enumerate(
groupby(sorted_proofs, key=itemgetter("send_id"))
):
grouped_proofs = list(value)
coin = await wallet.serialize_proofs(grouped_proofs)
coin_hidden_secret = await wallet.serialize_proofs(
grouped_proofs, hide_secrets=True
)
token = await wallet.serialize_proofs(grouped_proofs)
token_hidden_secret = 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"#{i} Amount: {sum_proofs(grouped_proofs)} sat Time: {reserved_date} ID: {key}\n"
)
print(f"With secret: {coin}\n\nSecretless: {coin_hidden_secret}\n")
print(f"{token}\n")
print(f"--------------------------\n")
print("To remove all spent tokens use: cashu burn -a")
wallet.status()
@@ -284,16 +534,16 @@ async def lock(ctx):
p2shscript = await wallet.create_p2sh_lock()
txin_p2sh_address = p2shscript.address
print("---- Pay to script hash (P2SH) ----\n")
print("Use a lock to receive coins that only you can unlock.")
print("Use a lock to receive tokens that only you can unlock.")
print("")
print(f"Public receiving lock: P2SH:{txin_p2sh_address}")
print("")
print(
f"Anyone can send coins to this lock:\n\ncashu send <amount> --lock P2SH:{txin_p2sh_address}"
f"Anyone can send tokens to this lock:\n\ncashu send <amount> --lock P2SH:{txin_p2sh_address}"
)
print("")
print(
f"Only you can receive coins from this lock:\n\ncashu receive <coin> --lock P2SH:{txin_p2sh_address}\n"
f"Only you can receive tokens from this lock:\n\ncashu receive <token> --lock P2SH:{txin_p2sh_address}\n"
)
@@ -311,7 +561,7 @@ async def locks(ctx):
print(f"Script: {l.script}")
print(f"Signature: {l.signature}")
print("")
print(f"Receive: cashu receive <coin> --lock P2SH:{l.address}")
print(f"Receive: cashu receive <token> --lock P2SH:{l.address}")
print("")
print(f"--------------------------\n")
else:
@@ -319,6 +569,41 @@ async def locks(ctx):
return True
@cli.command("invoices", help="List of all pending invoices.")
@click.pass_context
@coro
async def invoices(ctx):
wallet: Wallet = ctx.obj["WALLET"]
invoices = await get_lightning_invoices(db=wallet.db)
if len(invoices):
print("")
print(f"--------------------------\n")
for invoice in invoices:
print(f"Paid: {invoice.paid}")
print(f"Incoming: {invoice.amount > 0}")
print(f"Amount: {abs(invoice.amount)}")
if invoice.hash:
print(f"Hash: {invoice.hash}")
if invoice.preimage:
print(f"Preimage: {invoice.preimage}")
if invoice.time_created:
d = datetime.utcfromtimestamp(
int(float(invoice.time_created))
).strftime("%Y-%m-%d %H:%M:%S")
print(f"Created: {d}")
if invoice.time_paid:
d = datetime.utcfromtimestamp(int(float(invoice.time_paid))).strftime(
"%Y-%m-%d %H:%M:%S"
)
print(f"Paid: {d}")
print("")
print(f"Payment request: {invoice.pr}")
print("")
print(f"--------------------------\n")
else:
print("No invoices found.")
@cli.command("wallets", help="List of all available wallets.")
@click.pass_context
@coro
@@ -349,10 +634,19 @@ async def wallets(ctx):
@coro
async def info(ctx):
print(f"Version: {VERSION}")
print(f"Debug: {DEBUG}")
print(f"Wallet: {ctx.obj['WALLET_NAME']}")
if DEBUG:
print(f"Debug: {DEBUG}")
print(f"Cashu dir: {CASHU_DIR}")
if ENV_FILE:
print(f"Settings: {ENV_FILE}")
print(f"Wallet: {ctx.obj['WALLET_NAME']}")
print(f"Mint URL: {MINT_URL}")
if TOR:
print(f"Tor enabled: {TOR}")
if NOSTR_PRIVATE_KEY:
client = NostrClient(privatekey_hex=NOSTR_PRIVATE_KEY, connect=False)
print(f"Nostr public key: {client.public_key.hex()}")
print(f"Nostr relays: {NOSTR_RELAYS}")
if SOCKS_HOST:
print(f"Socks proxy: {SOCKS_HOST}:{SOCKS_PORT}")
print(f"Mint URL: {ctx.obj['HOST']}")
return

172
cashu/wallet/cli_helpers.py Normal file
View File

@@ -0,0 +1,172 @@
import os
import urllib.parse
import click
from cashu.core.base import Proof, TokenJson, TokenMintJson, WalletKeyset
from cashu.core.settings import CASHU_DIR, MINT_URL
from cashu.wallet.crud import get_keyset
from cashu.wallet.wallet import Wallet as Wallet
async def verify_mints(ctx, dtoken):
trust_token_mints = True
for mint_id in dtoken.get("mints"):
for keyset in set(dtoken["mints"][mint_id]["ks"]):
mint_url = dtoken["mints"][mint_id]["url"]
# init a temporary wallet object
keyset_wallet = Wallet(
mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"])
)
# make sure that this mint supports this keyset
mint_keysets = await keyset_wallet._get_keysets(mint_url)
assert keyset in mint_keysets["keysets"], "mint does not have this keyset."
# we validate the keyset id by fetching the keys from the mint
mint_keyset = await keyset_wallet._get_keyset(mint_url, keyset)
assert keyset == mint_keyset.id, Exception("keyset not valid.")
# we check the db whether we know this mint already and ask the user if not
mint_keysets = await get_keyset(mint_url=mint_url, db=keyset_wallet.db)
if mint_keysets is None:
# we encountered a new mint and ask for a user confirmation
trust_token_mints = False
print("")
print("Warning: Tokens are from a mint you don't know yet.")
print("\n")
print(f"Mint URL: {mint_url}")
print(f"Mint keyset: {keyset}")
print("\n")
click.confirm(
f"Do you trust this mint and want to receive the tokens?",
abort=True,
default=True,
)
trust_token_mints = True
assert trust_token_mints, Exception("Aborted!")
async def redeem_multimint(ctx, dtoken, script, signature):
# we get the mint information in the token and load the keys of each mint
# we then redeem the tokens for each keyset individually
for mint_id in dtoken.get("mints"):
for keyset in set(dtoken["mints"][mint_id]["ks"]):
mint_url = dtoken["mints"][mint_id]["url"]
# init a temporary wallet object
keyset_wallet = Wallet(
mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"])
)
# load the keys
await keyset_wallet.load_mint(keyset_id=keyset)
# redeem proofs of this keyset
redeem_proofs = [
Proof(**p) for p in dtoken["tokens"] if Proof(**p).id == keyset
]
_, _ = await keyset_wallet.redeem(
redeem_proofs, scnd_script=script, scnd_siganture=signature
)
async def print_mint_balances(ctx, wallet, show_mints=False):
# get balances per mint
mint_balances = await wallet.balance_per_minturl()
# if we have a balance on a non-default mint, we show its URL
keysets = [k for k, v in wallet.balance_per_keyset().items()]
for k in keysets:
ks = await get_keyset(id=str(k), db=wallet.db)
if ks and ks.mint_url != ctx.obj["HOST"]:
show_mints = True
# or we have a balance on more than one mint
# show balances per mint
if len(mint_balances) > 1 or show_mints:
print(f"You have balances in {len(mint_balances)} mints:")
print("")
for i, (k, v) in enumerate(mint_balances.items()):
print(
f"Mint {i+1}: Balance: {v['available']} sat (pending: {v['balance']-v['available']} sat) URL: {k}"
)
print("")
async def get_mint_wallet(ctx):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
mint_balances = await wallet.balance_per_minturl()
if len(mint_balances) > 1:
await print_mint_balances(ctx, wallet, show_mints=True)
mint_nr_str = (
input(f"Select mint [1-{len(mint_balances)}, press enter for default 1]: ")
or "1"
)
if not mint_nr_str.isdigit():
raise Exception("invalid input.")
mint_nr = int(mint_nr_str)
else:
mint_nr = 1
mint_url = list(mint_balances.keys())[mint_nr - 1]
# load this mint_url into a wallet
mint_wallet = Wallet(mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"]))
mint_keysets: WalletKeyset = await get_keyset(mint_url=mint_url, db=mint_wallet.db) # type: ignore
# load the keys
await mint_wallet.load_mint(keyset_id=mint_keysets.id)
return mint_wallet
# LNbits token link parsing
# can extract minut URL from LNbits token links like:
# https://lnbits.server/cashu/wallet?mint_id=aMintId&recv_token=W3siaWQiOiJHY2...
def token_from_lnbits_link(link):
url, token = "", ""
if len(link.split("&recv_token=")) == 2:
# extract URL params
params = urllib.parse.parse_qs(link.split("?")[1])
# extract URL
if "mint_id" in params:
url = (
link.split("?")[0].split("/wallet")[0]
+ "/api/v1/"
+ params["mint_id"][0]
)
# extract token
token = params["recv_token"][0]
return token, url
else:
return link, ""
async def proofs_to_token(wallet, proofs, url: str):
"""
Ingests proofs and
"""
# and add url and keyset id to token
token: TokenJson = await wallet._make_token(proofs, include_mints=False)
token.mints = {}
# get keysets of proofs
keysets = list(set([p.id for p in proofs]))
assert keysets is not None, "no keysets"
# check whether we know the mint urls for these proofs
for k in keysets:
ks = await get_keyset(id=k, db=wallet.db)
url = ks.mint_url if ks is not None else None
url = url or (
input(f"Enter mint URL (press enter for default {MINT_URL}): ") or MINT_URL
)
token.mints[url] = TokenMintJson(url=url, ks=keysets) # type: ignore
token_serialized = await wallet._serialize_token_base64(token)
return token_serialized

View File

@@ -1,7 +1,7 @@
import time
from typing import Any, List, Optional
from cashu.core.base import KeyBase, P2SHScript, Proof, WalletKeyset
from cashu.core.base import Invoice, KeyBase, P2SHScript, Proof, WalletKeyset
from cashu.core.db import Connection, Database
@@ -31,7 +31,7 @@ async def get_proofs(
SELECT * from proofs
"""
)
return [Proof.from_row(r) for r in rows]
return [Proof(**dict(r)) for r in rows]
async def get_reserved_proofs(
@@ -45,7 +45,7 @@ async def get_reserved_proofs(
WHERE reserved
"""
)
return [Proof.from_row(r) for r in rows]
return [Proof(**r) for r in rows]
async def invalidate_proof(
@@ -93,7 +93,7 @@ async def update_proof_reserved(
clauses.append("time_reserved = ?")
values.append(int(time.time()))
await (conn or db).execute(
await (conn or db).execute( # type: ignore
f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?",
(*values, str(proof.secret)),
)
@@ -155,14 +155,14 @@ async def get_unused_locks(
if clause:
where = f"WHERE {' AND '.join(clause)}"
rows = await (conn or db).fetchall(
rows = await (conn or db).fetchall( # type: ignore
f"""
SELECT * from p2sh
{where}
""",
tuple(args),
)
return [P2SHScript.from_row(r) for r in rows]
return [P2SHScript(**r) for r in rows]
async def update_p2sh_used(
@@ -176,7 +176,7 @@ async def update_p2sh_used(
clauses.append("used = ?")
values.append(used)
await (conn or db).execute(
await (conn or db).execute( # type: ignore
f"UPDATE proofs SET {', '.join(clauses)} WHERE address = ?",
(*values, str(p2sh.address)),
)
@@ -189,7 +189,7 @@ async def store_keyset(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
await (conn or db).execute( # type: ignore
"""
INSERT INTO keysets
(id, mint_url, valid_from, valid_to, first_seen, active)
@@ -198,22 +198,22 @@ async def store_keyset(
(
keyset.id,
mint_url or keyset.mint_url,
keyset.valid_from,
keyset.valid_to,
keyset.first_seen,
keyset.valid_from or int(time.time()),
keyset.valid_to or int(time.time()),
keyset.first_seen or int(time.time()),
True,
),
)
async def get_keyset(
id: str = None,
mint_url: str = None,
id: str = "",
mint_url: str = "",
db: Database = None,
conn: Optional[Connection] = None,
):
clauses = []
values = []
values: List[Any] = []
clauses.append("active = ?")
values.append(True)
if id:
@@ -226,11 +226,111 @@ async def get_keyset(
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone(
row = await (conn or db).fetchone( # type: ignore
f"""
SELECT * from keysets
{where}
""",
tuple(values),
)
return WalletKeyset.from_row(row) if row is not None else None
return WalletKeyset(**row) if row is not None else None
async def store_lightning_invoice(
db: Database,
invoice: Invoice,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
f"""
INSERT INTO invoices
(amount, pr, hash, preimage, paid, time_created, time_paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
invoice.amount,
invoice.pr,
invoice.hash,
invoice.preimage,
invoice.paid,
invoice.time_created,
invoice.time_paid,
),
)
async def get_lightning_invoice(
db: Database,
hash: str = None,
conn: Optional[Connection] = None,
):
clauses = []
values: List[Any] = []
if hash:
clauses.append("hash = ?")
values.append(hash)
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone(
f"""
SELECT * from invoices
{where}
""",
tuple(values),
)
return Invoice(**row)
async def get_lightning_invoices(
db: Database,
paid: bool = None,
conn: Optional[Connection] = None,
):
clauses: List[Any] = []
values: List[Any] = []
if paid is not None:
clauses.append("paid = ?")
values.append(paid)
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall(
f"""
SELECT * from invoices
{where}
""",
tuple(values),
)
return [Invoice(**r) for r in rows]
async def update_lightning_invoice(
db: Database,
hash: str,
paid: bool,
time_paid: int = None,
conn: Optional[Connection] = None,
):
clauses = []
values: List[Any] = []
clauses.append("paid = ?")
values.append(paid)
if time_paid:
clauses.append("time_paid = ?")
values.append(time_paid)
await (conn or db).execute(
f"UPDATE invoices SET {', '.join(clauses)} WHERE hash = ?",
(
*values,
hash,
),
)

View File

@@ -107,8 +107,8 @@ async def m005_wallet_keysets(db: Database):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS keysets (
id TEXT NOT NULL,
mint_url TEXT NOT NULL,
id TEXT,
mint_url TEXT,
valid_from TIMESTAMP DEFAULT {db.timestamp_now},
valid_to TIMESTAMP DEFAULT {db.timestamp_now},
first_seen TIMESTAMP DEFAULT {db.timestamp_now},
@@ -119,18 +119,28 @@ async def m005_wallet_keysets(db: Database):
);
"""
)
# await db.execute(
# f"""
# CREATE TABLE IF NOT EXISTS mint_pubkeys (
# id TEXT NOT NULL,
# amount INTEGER NOT NULL,
# pubkey TEXT NOT NULL,
# UNIQUE (id, pubkey)
# );
# """
# )
await db.execute("ALTER TABLE proofs ADD COLUMN id TEXT")
await db.execute("ALTER TABLE proofs_used ADD COLUMN id TEXT")
async def m006_invoices(db: Database):
"""
Stores Lightning invoices.
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS invoices (
amount INTEGER NOT NULL,
pr TEXT NOT NULL,
hash TEXT,
preimage TEXT,
paid BOOL DEFAULT FALSE,
time_created TIMESTAMP DEFAULT {db.timestamp_now},
time_paid TIMESTAMP DEFAULT {db.timestamp_now},
UNIQUE (hash)
);
"""
)

View File

@@ -1,26 +1,33 @@
import base64
import json
import math
import secrets as scrts
import time
import uuid
from itertools import groupby
from typing import Dict, List
from typing import Dict, List, Optional
import requests
from loguru import logger
import cashu.core.b_dhke as b_dhke
import cashu.core.bolt11 as bolt11
from cashu.core.base import (
BlindedMessage,
BlindedSignature,
CheckFeesRequest,
CheckRequest,
Invoice,
MeltRequest,
MintRequest,
P2SHScript,
Proof,
SplitRequest,
TokenJson,
TokenMintJson,
WalletKeyset,
)
from cashu.core.bolt11 import Invoice as InvoiceBolt11
from cashu.core.db import Database
from cashu.core.helpers import sum_proofs
from cashu.core.script import (
@@ -30,52 +37,53 @@ from cashu.core.script import (
step2_carol_sign_tx,
)
from cashu.core.secp import PublicKey
from cashu.core.settings import DEBUG
from cashu.core.settings import DEBUG, SOCKS_HOST, SOCKS_PORT, TOR, VERSION
from cashu.core.split import amount_split
from cashu.tor.tor import TorProxy
from cashu.wallet.crud import (
get_keyset,
get_proofs,
invalidate_proof,
secret_used,
store_keyset,
store_lightning_invoice,
store_p2sh,
store_proof,
update_lightning_invoice,
update_proof_reserved,
)
class LedgerAPI:
keys: Dict[int, str]
keys: Dict[int, PublicKey]
keyset: str
tor: TorProxy
db: Database
def __init__(self, url):
self.url = url
async def _get_keys(self, url):
resp = requests.get(url + "/keys").json()
keys = resp
assert len(keys), Exception("did not receive any keys")
keyset_keys = {
int(amt): PublicKey(bytes.fromhex(val), raw=True)
for amt, val in keys.items()
}
keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url)
return keyset
def _set_requests(self):
s = requests.Session()
s.headers.update({"Client-version": VERSION})
if DEBUG:
s.verify = False
socks_host, socks_port = None, None
if TOR and TorProxy().check_platform():
self.tor = TorProxy(timeout=True)
self.tor.run_daemon(verbose=True)
socks_host, socks_port = "localhost", 9050
else:
socks_host, socks_port = SOCKS_HOST, SOCKS_PORT
async def _get_keysets(self, url):
keysets = requests.get(url + "/keysets").json()
assert len(keysets), Exception("did not receive any keysets")
return keysets
@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
if socks_host and socks_port:
proxies = {
"http": f"socks5://{socks_host}:{socks_port}",
"https": f"socks5://{socks_host}:{socks_port}",
}
s.proxies.update(proxies)
s.headers.update({"User-Agent": scrts.token_urlsafe(8)})
return s
def _construct_proofs(
self, promises: List[BlindedSignature], secrets: List[str], rs: List[str]
@@ -94,21 +102,42 @@ class LedgerAPI:
proofs.append(proof)
return proofs
@staticmethod
def raise_on_error(resp_dict):
if "error" in resp_dict:
raise Exception("Mint Error: {}".format(resp_dict["error"]))
@staticmethod
def _generate_secret(randombits=128):
"""Returns base64 encoded random string."""
return scrts.token_urlsafe(randombits // 8)
async def _load_mint(self):
async def _load_mint(self, keyset_id: str = ""):
"""
Loads the current keys and the active keyset of the map.
Loads the public keys of the mint. Either gets the keys for the specified
`keyset_id` or loads the most recent one from the mint.
Gets and the active keyset ids of the mint and stores in `self.keysets`.
"""
assert len(
self.url
), "Ledger not initialized correctly: mint URL not specified yet. "
# get current keyset
keyset = await self._get_keys(self.url)
# get all active keysets
if keyset_id:
# get requested keyset
keyset = await self._get_keyset(self.url, keyset_id)
else:
# get current keyset
keyset = await self._get_keys(self.url)
# store current keyset
assert len(keyset.public_keys) > 0, "did not receive keys from mint."
# check if current keyset is in db
keyset_local: Optional[WalletKeyset] = await get_keyset(keyset.id, db=self.db)
if keyset_local is None:
await store_keyset(keyset=keyset, db=self.db)
# get all active keysets of this mint
mint_keysets = []
try:
keysets_resp = await self._get_keysets(self.url)
@@ -118,26 +147,12 @@ class LedgerAPI:
pass
self.keysets = mint_keysets if len(mint_keysets) else [keyset.id]
# store current keyset
assert len(keyset.public_keys) > 0, "did not receive keys from mint."
# check if current keyset is in db
keyset_local: WalletKeyset = await get_keyset(keyset.id, db=self.db)
if keyset_local is None:
await store_keyset(keyset=keyset, db=self.db)
logger.debug(f"Mint keysets: {self.keysets}")
logger.debug(f"Current mint keyset: {keyset.id}")
self.keys = keyset.public_keys
self.keyset_id = keyset.id
def request_mint(self, amount):
"""Requests a mint from the server and returns Lightning invoice."""
r = requests.get(self.url + "/mint", params={"amount": amount})
r.raise_for_status()
return r.json()
@staticmethod
def _construct_outputs(amounts: List[int], secrets: List[str]):
"""Takes a list of amounts and secrets and returns outputs.
@@ -167,29 +182,82 @@ class LedgerAPI:
return [f"{secret}:{self._generate_secret()}" for i in range(n)]
return [f"{i}:{secret}" for i in range(n)]
"""
ENDPOINTS
"""
async def _get_keys(self, url: str):
self.s = self._set_requests()
resp = self.s.get(
url + "/keys",
)
resp.raise_for_status()
keys = resp.json()
assert len(keys), Exception("did not receive any keys")
keyset_keys = {
int(amt): PublicKey(bytes.fromhex(val), raw=True)
for amt, val in keys.items()
}
keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url)
return keyset
async def _get_keyset(self, url: str, keyset_id: str):
"""
keyset_id is base64, needs to be urlsafe-encoded.
"""
self.s = self._set_requests()
keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_")
resp = self.s.get(
url + f"/keys/{keyset_id_urlsafe}",
)
resp.raise_for_status()
keys = resp.json()
assert len(keys), Exception("did not receive any keys")
keyset_keys = {
int(amt): PublicKey(bytes.fromhex(val), raw=True)
for amt, val in keys.items()
}
keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url)
return keyset
async def _get_keysets(self, url: str):
self.s = self._set_requests()
resp = self.s.get(
url + "/keysets",
)
resp.raise_for_status()
keysets = resp.json()
assert len(keysets), Exception("did not receive any keysets")
return keysets
def request_mint(self, amount):
"""Requests a mint from the server and returns Lightning invoice."""
self.s = self._set_requests()
resp = self.s.get(self.url + "/mint", params={"amount": amount})
resp.raise_for_status()
return_dict = resp.json()
self.raise_on_error(return_dict)
return Invoice(amount=amount, pr=return_dict["pr"], hash=return_dict["hash"])
async def mint(self, amounts, payment_hash=None):
"""Mints new coins and returns a proof of promise."""
secrets = [self._generate_secret() for s in range(len(amounts))]
await self._check_used_secrets(secrets)
payloads, rs = self._construct_outputs(amounts, secrets)
resp = requests.post(
self.s = self._set_requests()
resp = self.s.post(
self.url + "/mint",
json=payloads.dict(),
params={"payment_hash": payment_hash},
)
resp.raise_for_status()
try:
promises_list = resp.json()
except:
raise Exception("Unkown mint error.")
if "error" in promises_list:
raise Exception("Error: {}".format(promises_list["error"]))
promises_list = resp.json()
self.raise_on_error(promises_list)
promises = [BlindedSignature.from_dict(p) for p in promises_list]
promises = [BlindedSignature(**p) for p in promises_list]
return self._construct_proofs(promises, secrets, rs)
async def split(self, proofs, amount, scnd_secret: str = None):
async def split(self, proofs, amount, scnd_secret: Optional[str] = None):
"""Consume proofs and create new promises based on amount split.
If scnd_secret is None, random secrets will be generated for the tokens to keep (frst_outputs)
and the promises to send (scnd_outputs).
@@ -223,6 +291,7 @@ class LedgerAPI:
payloads, rs = self._construct_outputs(amounts, secrets)
split_payload = SplitRequest(proofs=proofs, amount=amount, outputs=payloads)
# construct payload
def _splitrequest_include_fields(proofs):
"""strips away fields from the model that aren't necessary for the /split"""
proofs_include = {"id", "amount", "secret", "C", "script"}
@@ -232,19 +301,17 @@ class LedgerAPI:
"proofs": {i: proofs_include for i in range(len(proofs))},
}
resp = requests.post(
self.s = self._set_requests()
resp = self.s.post(
self.url + "/split",
json=split_payload.dict(include=_splitrequest_include_fields(proofs)),
)
resp.raise_for_status()
try:
promises_dict = resp.json()
except:
raise Exception("Unkown mint error.")
if "error" in promises_dict:
raise Exception("Mint 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"]]
promises_dict = resp.json()
self.raise_on_error(promises_dict)
promises_fst = [BlindedSignature(**p) for p in promises_dict["fst"]]
promises_snd = [BlindedSignature(**p) for p in promises_dict["snd"]]
# Construct proofs from promises (i.e., unblind signatures)
frst_proofs = self._construct_proofs(
promises_fst, secrets[: len(promises_fst)], rs[: len(promises_fst)]
@@ -256,29 +323,44 @@ class LedgerAPI:
return frst_proofs, scnd_proofs
async def check_spendable(self, proofs: List[Proof]):
"""
Cheks whether the secrets in proofs are already spent or not and returns a list of booleans.
"""
payload = CheckRequest(proofs=proofs)
resp = requests.post(
def _check_spendable_include_fields(proofs):
"""strips away fields from the model that aren't necessary for the /split"""
return {
"proofs": {i: {"secret"} for i in range(len(proofs))},
}
self.s = self._set_requests()
resp = self.s.post(
self.url + "/check",
json=payload.dict(),
json=payload.dict(include=_check_spendable_include_fields(proofs)),
)
resp.raise_for_status()
return_dict = resp.json()
self.raise_on_error(return_dict)
return return_dict
async def check_fees(self, payment_request: str):
"""Checks whether the Lightning payment is internal."""
payload = CheckFeesRequest(pr=payment_request)
resp = requests.post(
self.s = self._set_requests()
resp = self.s.post(
self.url + "/checkfees",
json=payload.dict(),
)
resp.raise_for_status()
return_dict = resp.json()
self.raise_on_error(return_dict)
return return_dict
async def pay_lightning(self, proofs: List[Proof], invoice: str):
"""
Accepts proofs and a lightning invoice to pay in exchange.
"""
payload = MeltRequest(proofs=proofs, invoice=invoice)
def _meltequest_include_fields(proofs):
@@ -290,13 +372,14 @@ class LedgerAPI:
"proofs": {i: proofs_include for i in range(len(proofs))},
}
resp = requests.post(
self.s = self._set_requests()
resp = self.s.post(
self.url + "/melt",
json=payload.dict(include=_meltequest_include_fields(proofs)),
)
resp.raise_for_status()
return_dict = resp.json()
self.raise_on_error(return_dict)
return return_dict
@@ -309,8 +392,8 @@ class Wallet(LedgerAPI):
self.proofs: List[Proof] = []
self.name = name
async def load_mint(self):
await super()._load_mint()
async def load_mint(self, keyset_id: str = ""):
await super()._load_mint(keyset_id)
async def load_proofs(self):
self.proofs = await get_proofs(db=self.db)
@@ -323,23 +406,42 @@ class Wallet(LedgerAPI):
def _get_proofs_per_keyset(proofs: List[Proof]):
return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)}
async def request_mint(self, amount):
return super().request_mint(amount)
async def _get_proofs_per_minturl(self, proofs: List[Proof]):
ret = {}
for id in set([p.id for p in proofs]):
if id is None:
continue
keyset: WalletKeyset = await get_keyset(id=id, db=self.db)
if keyset.mint_url not in ret:
ret[keyset.mint_url] = [p for p in proofs if p.id == id]
else:
ret[keyset.mint_url].extend([p for p in proofs if p.id == id])
return ret
async def mint(self, amount: int, payment_hash: str = None):
async def request_mint(self, amount):
invoice = super().request_mint(amount)
invoice.time_created = int(time.time())
await store_lightning_invoice(db=self.db, invoice=invoice)
return invoice
async def mint(self, amount: int, payment_hash: Optional[str] = None):
split = amount_split(amount)
proofs = await super().mint(split, payment_hash)
if proofs == []:
raise Exception("received no proofs.")
await self._store_proofs(proofs)
if payment_hash:
await update_lightning_invoice(
db=self.db, hash=payment_hash, paid=True, time_paid=int(time.time())
)
self.proofs += proofs
return proofs
async def redeem(
self,
proofs: List[Proof],
scnd_script: str = None,
scnd_siganture: str = None,
scnd_script: Optional[str] = None,
scnd_siganture: Optional[str] = None,
):
if scnd_script and scnd_siganture:
logger.debug(f"Unlock script: {scnd_script}")
@@ -352,7 +454,7 @@ class Wallet(LedgerAPI):
self,
proofs: List[Proof],
amount: int,
scnd_secret: str = None,
scnd_secret: Optional[str] = None,
):
assert len(proofs) > 0, ValueError("no proofs provided.")
frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret)
@@ -373,21 +475,79 @@ class Wallet(LedgerAPI):
status = await super().pay_lightning(proofs, invoice)
if status["paid"] == True:
await self.invalidate(proofs)
invoice_obj = Invoice(
amount=-sum_proofs(proofs),
pr=invoice,
preimage=status.get("preimage"),
paid=True,
time_paid=time.time(),
)
await store_lightning_invoice(db=self.db, invoice=invoice_obj)
else:
raise Exception("could not pay invoice.")
return status["paid"]
@staticmethod
async def serialize_proofs(proofs: List[Proof], hide_secrets=False):
if hide_secrets:
proofs_serialized = [p.to_dict_no_secret() for p in proofs]
else:
proofs_serialized = [p.to_dict() for p in proofs]
token = base64.urlsafe_b64encode(
json.dumps(proofs_serialized).encode()
).decode()
async def _make_token(self, proofs: List[Proof], include_mints=True):
"""
Takes list of proofs and produces a TokenJson by looking up
the keyset id and mint URLs from the database.
"""
# build token
token = TokenJson(tokens=proofs)
# add mint information to the token, if requested
if include_mints:
# hold information about the mint
mints: Dict[str, TokenMintJson] = dict()
# iterate through all proofs and add their keyset to `mints`
for proof in proofs:
if proof.id:
# load the keyset from the db
keyset = await get_keyset(id=proof.id, db=self.db)
if keyset and keyset.mint_url:
# TODO: replace this with a mint pubkey
placeholder_mint_id = keyset.mint_url
if placeholder_mint_id not in mints:
# mint information
id = TokenMintJson(
url=keyset.mint_url,
ks=[keyset.id],
)
mints[placeholder_mint_id] = id
else:
# if a mint has multiple keysets, append to the existing list
if keyset.id not in mints[placeholder_mint_id].ks:
mints[placeholder_mint_id].ks.append(keyset.id)
if len(mints) > 0:
token.mints = mints
return token
async def _serialize_token_base64(self, token: TokenJson):
"""
Takes a TokenJson and serializes it in urlsafe_base64.
"""
# encode the token as a base64 string
token_base64 = base64.urlsafe_b64encode(
json.dumps(token.to_dict()).encode()
).decode()
return token_base64
async def serialize_proofs(
self, proofs: List[Proof], include_mints=True, legacy=False
):
"""
Produces sharable token with proofs and mint information.
"""
if legacy:
proofs_serialized = [p.to_dict() for p in proofs]
return base64.urlsafe_b64encode(
json.dumps(proofs_serialized).encode()
).decode()
token = await self._make_token(proofs, include_mints)
return await self._serialize_token_base64(token)
async def _select_proofs_to_send(self, proofs: List[Proof], amount_to_send: int):
"""
Selects proofs that can be used with the current mint.
@@ -408,16 +568,44 @@ class Wallet(LedgerAPI):
send_proofs.append(sorted_proofs[len(send_proofs)])
return send_proofs
async def split_to_send(self, proofs: List[Proof], amount, scnd_secret: str = None):
async def get_pay_amount_with_fees(self, invoice: str):
"""
Decodes the amount from a Lightning invoice and returns the
total amount (amount+fees) to be paid.
"""
decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice)
# check if it's an internal payment
fees = int((await self.check_fees(invoice))["fee"])
amount = math.ceil((decoded_invoice.amount_msat + fees * 1000) / 1000) # 1% fee
return amount, fees
async def split_to_pay(self, invoice: str):
"""
Splits proofs such that a Lightning invoice can be paid.
"""
amount, _ = await self.get_pay_amount_with_fees(invoice)
_, send_proofs = await self.split_to_send(self.proofs, amount)
return send_proofs
async def split_to_send(
self,
proofs: List[Proof],
amount,
scnd_secret: Optional[str] = None,
set_reserved: bool = False,
):
"""Like self.split but only considers non-reserved tokens."""
if scnd_secret:
logger.debug(f"Spending conditions: {scnd_secret}")
spendable_proofs = await self._select_proofs_to_send(proofs, amount)
if sum_proofs(spendable_proofs) < amount:
raise Exception("balance too low.")
return await self.split(
keep_proofs, send_proofs = await self.split(
[p for p in spendable_proofs if not p.reserved], amount, scnd_secret
)
if set_reserved:
await self.set_reserved(send_proofs, reserved=True)
return keep_proofs, send_proofs
async def set_reserved(self, proofs: List[Proof], reserved: bool):
"""Mark a proof as reserved to avoid reuse or delete marking."""
@@ -468,9 +656,10 @@ class Wallet(LedgerAPI):
return sum_proofs([p 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)"
)
# print(
# f"Balance: {self.balance} sat (available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)"
# )
print(f"Balance: {self.available_balance} sat")
def balance_per_keyset(self):
return {
@@ -481,5 +670,16 @@ class Wallet(LedgerAPI):
for key, proofs in self._get_proofs_per_keyset(self.proofs).items()
}
async def balance_per_minturl(self):
balances = await self._get_proofs_per_minturl(self.proofs)
balances_return = {
key: {
"balance": sum_proofs(proofs),
"available": sum_proofs([p for p in proofs if not p.reserved]),
}
for key, proofs in balances.items()
}
return dict(sorted(balances_return.items(), key=lambda item: item[1]["available"], reverse=True)) # type: ignore
def proof_amounts(self):
return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])]

View File

@@ -1,7 +1,7 @@
# Notation
Sending user: `Alice`
Receivung user: `Carol`
Sending user: `Alice`<br>
Receiving user: `Carol`<br>
Mint: `Bob`
## Bob (mint)
@@ -15,11 +15,11 @@ Mint: `Bob`
- `T` blinded message
- `Z` proof (unblinded signature)
# Blind Diffie-Hellmann key exchange (BDH)
# Blind Diffie-Hellman key exchange (BDH)
- Mint `Bob` publishes `K = kG`
- `Alice` picks secret `x` and computes `Y = hash_to_point(x)`
- `Alice` picks secret `x` and computes `Y = hash_to_curve(x)`
- `Alice` sends to `Bob`: `T = Y + rG` with `r` being a random nonce
- `Bob` sends back to `Alice` blinded key: `Q = kT` (these two steps are the DH key exchange)
- `Alice` can calculate the unblinded key as `Q - rK = kY + krG - krG = kY = Z`
- Alice can take the pair `(x, Z)` as a token and can send it to `Carol`.
- `Carol` can send `(x, Z)` to `Bob` who then checks that `k*hash_to_point(x) == Z`, and if so treats it as a valid spend of a token, adding `x` to the list of spent secrets.
- `Carol` can send `(x, Z)` to `Bob` who then checks that `k*hash_to_curve(x) == Z`, and if so treats it as a valid spend of a token, adding `x` to the list of spent secrets.

93
docs/specs/00.md Normal file
View File

@@ -0,0 +1,93 @@
# NUT-0 - Notation and Models
Sending user: `Alice`
Receiving user: `Carol`
Mint: `Bob`
## Bob (mint)
- `k` private key of mint (one for each amount)
- `K` public key of mint
- `Q` promise (blinded signature)
## Alice (user)
- `x` random string (secret message), corresponds to point `Y` on curve
- `r` private key (blinding factor)
- `T` blinded message
- `Z` proof (unblinded signature)
# Blind Diffie-Hellmann key exchange (BDHKE)
- Mint `Bob` publishes `K = kG`
- `Alice` picks secret `x` and computes `Y = hash_to_curve(x)`
- `Alice` sends to `Bob`: `T = Y + rG` with `r` being a random nonce
- `Bob` sends back to `Alice` blinded key: `Q = kT` (these two steps are the DH key exchange)
- `Alice` can calculate the unblinded key as `Q - rK = kY + krG - krG = kY = Z`
- Alice can take the pair `(x, Z)` as a token and can send it to `Carol`.
- `Carol` can send `(x, Z)` to `Bob` who then checks that `k*hash_to_curve(x) == Z`, and if so treats it as a valid spend of a token, adding `x` to the list of spent secrets.
## 0.1 - Models
### `BlindedMessage`
A encrypted ("blinded") secret and an amount sent from `Alice` to `Bob`.
```json
{
"amount": int,
"B_": str
}
```
### `BlindedSignature`
A signature on the `BlindedMessage` sent from `Bob` to `Alice`.
```json
{
"amount": int,
"C_": str,
"id": str | None
}
```
### `Proof`
A `Proof` is also called a `Token` and has the following form:
```json
{
"amount": int,
"secret": str,
"C": str,
"id": None | str,
"script": P2SHScript | None,
}
```
### `Proofs`
A list of `Proof`'s. In general, this will be used for most operations instead of a single `Proof`. `Proofs` can be serialized (see Methods/Serialization [TODO: Link Serialization])
## 0.2 - Methods
### Serialization of `Proofs`
To send and receive `Proofs`, wallets serialize them in a `base64_urlsafe` format.
Example:
```json
[
{
"id": "DSAl9nvvyfva",
"amount": 8,
"secret": "DbRKIya0etdwI5sFAN0AXQ",
"C": "02df7f2fc29631b71a1db11c163b0b1cb40444aa2b3d253d43b68d77a72ed2d625"
},
{
"id": "DSAl9nvvyfva",
"amount": 16,
"secret": "d_PPc5KpuAB2M60WYAW5-Q",
"C": "0270e0a37f7a0b21eab43af751dd3c03f61f04c626c0448f603f1d1f5ae5a7d7e6"
}
```
becomes
```
W3siaWQiOiAiRFNBbDludnZ5ZnZhIiwgImFtb3VudCI6IDgsICJzZWNyZXQiOiAiRGJSS0l5YTBldGR3STVzRkFOMEFYUSIsICJDIjogIjAyZGY3ZjJmYzI5NjMxYjcxYTFkYjExYzE2M2IwYjFjYjQwNDQ0YWEyYjNkMjUzZDQzYjY4ZDc3YTcyZWQyZDYyNSJ9LCB7ImlkIjogIkRTQWw5bnZ2eWZ2YSIsICJhbW91bnQiOiAxNiwgInNlY3JldCI6ICJkX1BQYzVLcHVBQjJNNjBXWUFXNS1RIiwgIkMiOiAiMDI3MGUwYTM3ZjdhMGIyMWVhYjQzYWY3NTFkZDNjMDNmNjFmMDRjNjI2YzA0NDhmNjAzZjFkMWY1YWU1YTdkN2U2In1d
```

60
docs/specs/01.md Normal file
View File

@@ -0,0 +1,60 @@
# NUT-1 - Mint public key exchange
This describes the basic exchange of the public mint keys that the wallet user `Alice` uses to unblind `Bob`'s signature.
## Description
Wallet user `Alice` receives public keys from mint `Bob` via `GET /keys` and stores them in a key-value store like a dictionary. The set of all public keys for each supported amount is called a *keyset*.
Mint `Bob` responds with his *active* [keyset][02]. The active keyset is the keyset a mint currently uses to sign promises with. The active keyset can change over time, for example due to key rotation. A mint MAY support older keysets indefinetely. Note that a mint can support multiple keysets at the same time but will only respond with the active keyset on the endpoint `GET /keys`. A wallet can ask for the keys of a specific (non-active) keyset by using the endpint `GET /keys/{keyset_id}` (see #2 [TODO: Link #2]).
See [TODO: Link #2] for how a wallet deals with multiple keysets.
Keysets are received as a JSON of the form `{<amount_1> : <mint_pubkey_1>, <amount_2> : ...}` for each `<amount_i>` of the amounts the mint `Bob` supports and the corresponding public key `<mint_pubkey_1>`, that is `K_i` (see #0 [TODO: Link #0]).
## Example
Request of `Alice`:
```http
GET https://mint.host:3338/keys
```
With curl:
```bash
curl -X GET https://mint.host:3338/keys
```
Response of `Bob`:
```json
{
"1": "03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc",
"2": "03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de",
"4": "02648eccfa4c026960966276fa5a4cae46ce0fd432211a4f449bf84f13aa5f8303",
"8": "02fdfd6796bfeac490cbee12f778f867f0a2c68f6508d17c649759ea0dc3547528",
...
}
```
[00]: 00.md
[01]: 02.md
[03]: 03.md
[04]: 04.md
[05]: 05.md
[06]: 06.md
[07]: 07.md
[08]: 08.md
[09]: 09.md
[10]: 10.md
[11]: 11.md
[12]: 12.md
[13]: 13.md
[14]: 14.md
[15]: 15.md
[16]: 16.md
[17]: 17.md
[18]: 18.md
[19]: 19.md
[20]: 20.md

79
docs/specs/02.md Normal file
View File

@@ -0,0 +1,79 @@
# NUT-2 - Keysets and keyset ID
A keyset is a set of public keys that the mint `Bob` generates and shares with its users. It refers to the set of public keys that each correspond to the amount values that the mint supports (e.g. 1, 2, 4, 8, ...) respectively.
## Requesting mint keyset IDs
A mint can have multiple keysets at the same time but **MUST** have only one *active* keyset (see #1 [TODO: Link #1]). A wallet can ask the mint for all active keyset IDs via the `GET /keysets` endpoint. A wallet **CAN** request the list of active keyset IDs from the mint upon startup and, if it does so, **MUST** choose only tokens from its database that have a keyset ID supported by the mint to interact with it.
This is useful in the case a wallet interacts with multiple mints. That way, a wallet always knows which tokens it can use with the mint it is currently interacting with.
## Example
Request of `Alice`:
```http
GET https://mint.host:3338/keysets
```
With curl:
```bash
curl -X GET https://mint.host:3338/keysets
```
Response of `Bob`:
```json
{
"keysets": [
"DSAl9nvvyfva",
...
]
}
```
## 2.1 - Generating a keyset
A keyset is generated by the mint by a single seed `s` from which the private keys `k_i` are derived, with `i` being the index of the amount value. The derivation for the elements `k_i` goes like this:
```python
for i in range(MAX_ORDER):
k_i = HASH_SHA256(s + D + i)[:32]
```
Here, `MAX_ORDER` refers to the order of the maximum token value that the mint supports, i.e., `2^MAX_ORDER`. Typically, `MAX_ORDER = 64`. `D` refers to a derivation path that is chosen by the mint. The derivation path can be used to rotate keys over time or to service multiple parallel mints with a single instance. `i` is the string representation of the index of the amount value, i.e., `0`, `1`, and so on.
## 2.2 - Keyset ID
A keyset ID is an identifier for a specific keyset. It can be derived by anyone who knows the set of public keys of a mint. The keyset ID **CAN** be stored in a Cashu token [TODO: Link to definition of token] such that the token can be used to identify which mint or keyset it was generated from.
### 2.2.1 - Storing the keyset ID in a token
A wallet can use the keyset ID in a token to recognize a mint it was issued by. For example, a wallet might store the `MINT_URL` together with the `keyset_id` in its database the first time it receives a keyset from that mint. That way, a wallet can know which mint to contact when it receives a token with a `keyset_id` of a mint that it has interacted with before.
[TODO: V2 tokens include the `MINT_URL` to enable the first contact when a wallet recieves a token from a mint it has never met before.]
### 2.2.2 - Deriving the keyset ID
The mint and the wallets of its users can derive a keyset ID from the keyset of the mint. To derive the keyset ID of a keyset, execute the following steps:
```
1 - sort keyset by amount
2 - concatenate all (sorted) public keys to one string
3 - HASH_SHA256 the concatenated public keys
4 - take the first 12 characters of the hash
```
An example implementation in Python:
```python
def derive_keyset_id(keys: Dict[int, PublicKey]):
"""Deterministic derivation keyset_id from set of public keys."""
sorted_keys = dict(sorted(keys.items()))
pubkeys_concat = "".join([p.serialize().hex() for _, p in sorted_keys.items()])
return base64.b64encode(
hashlib.sha256((pubkeys_concat).encode("utf-8")).digest()
).decode()[:12]
```

30
docs/specs/03.md Normal file
View File

@@ -0,0 +1,30 @@
# NUT-3 - Request mint
Minting tokens is a two-step process: requesting a mint and minting the tokens. Here, we describe the first step. A wallet requests the minting of tokens in exchange for paying a bolt11 Lightning invoice (typically generated by the mint to add funds to its reserves, and typically paid with another Lightning wallet).
To request the minting of tokens, a wallet `Alice` sends a `GET /mint&amount=<amount_sat>` request with the requested amount `<amount_sat>` in satoshis. The mint `Bob` then responds with a Lightning invoice.
## Example
Request of `Alice`:
```http
GET https://mint.host:3338/mint&amount=1000
```
With curl:
```bash
curl -X GET https://mint.host:3338/mint&amount=1000
```
Response of `Bob`:
```json
{
"pr": "lnbc100n1p3kdrv5sp5lpdxzghe5j67q...",
"hash": "67d1d9ea6ada225c115418671b64a..."
}
```
with `pr` being the bolt11 payment request and `hash` the hash of the invoice. A wallet **MUST** store the `hash` and `amount_sat` in its database to later request the tokens upon paying the invoice. A wallet **SHOULD** then present the payment request (for example via QR code) to the user such that they can pay the invoice with another Lightning wallet. After the user has paid the invoice, a wallet **MUST** continue with #4 - Minting tokens [TODO: Link to #4].

94
docs/specs/04.md Normal file
View File

@@ -0,0 +1,94 @@
# NUT-4 - Mint tokens
After requesting a mint (see #3 [TODO: Link]) and paying the invoice that was returned by the mint, a wallet proceeds with requesting tokens from the mint in return for paying the invoice.
For that, a wallet sends a `POST /mint&payment_hash=<hash>` request with a JSON body to the mint. The body **MUST** include `BlindedMessages` that are worth a maximum of `<amount_sat>` [TODO: Refer to BlindedMessages]. If successful (i.e. the invoice has been previously paid and the `BlindedMessages` are valid), the mint responds with `Promises` [TODO: Link Promises].
## Example
Request of `Alice`:
```http
POST https://mint.host:3338/mint&payment_hash=67d1d9ea6ada225c115418671b64a
```
With the data being of the form `MintRequest`:
```json
{
"blinded_messages":
[
BlindedMessage,
...
]
}
```
With curl:
```bash
curl -X POST https://mint.host:3338/mint&payment_hash=67d1d9ea6ada225c115418671b64a -d \
{
"blinded_messages":
[
{
"amount": 2,
"B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239"
},
{
"amount": 8,
"B_": "03b54ab451b15005f2c64d38fc512fca695914c8fd5094ee044e5724ad41fda247"
}
]
}
```
Response of `Bob`:
If the invoice was successfully paid, `Bob` responds with a `PostMintResponse` which is essentially a list of `BlindedSignature`'s [TODO: Link PostMintResponse]
```json
{
"promises":
[
{
"id": "DSAl9nvvyfva",
"amount": 2,
"C_": "03e61daa438fc7bcc53f6920ec6c8c357c24094fb04c1fc60e2606df4910b21ffb"
},
{
"id": "DSAl9nvvyfva",
"amount": 8,
"C_": "03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de"
},
]
}
```
If the invoice was not paid yet, `Bob` responds with an error. In that case, `Alice` **CAN** repeat the same response until the Lightning invoice is settled.
## Unblinding signatures
Upon receiving the `PostMintResponse` with the list of `BlindedSignature`'s from the mint `Bob`, a wallet `Alice` **MUST** then unblind the `BlindedSignature`'s from `Bob` (see #0 Notation [TODO: Link to unblinding]) to generate a list of `Proof`'s. A `Proof` is effectively an ecash `Token` and can later be used to redeem the token. The wallet **MUST** store the `Proof` in its database.
A list multiple `Proof`'s is called `Proofs` and has the form:
```json
{
"proofs" :
[
{
"id": "DSAl9nvvyfva",
"amount": 2,
"secret": "S+tDfc1Lfsrb06zaRdVTed6Izg",
"C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3"
},
{
...
}
]
}
```

59
docs/specs/05.md Normal file
View File

@@ -0,0 +1,59 @@
# NUT-5 - Melting tokens
Melting tokens is the opposite of minting them (see #4): the wallet `Alice` sends `Proofs` to the mint `Bob` together with a bolt11 Lightning invoice that `Alice` wants to be paid. To melt tokens, `Alice` sends a `POST /melt` request with a JSON body to the mint. The `Proofs` included in the request will be burned by the mint and the mint will pay the invoice in exchange.
`Alice`'s request **MUST** include a `MeltRequest` ([TODO: Link MeltRequest]) JSON body with `Proofs` that have at least the amount of the invoice to be paid.
## Example
**Request** of `Alice`:
```http
POST https://mint.host:3338/melt
```
With the data being of the form `MeltRequest`:
```json
{
"proofs":
[
Proof,
...
],
"invoice": str
}
```
With curl:
```bash
curl -X POST https://mint.host:3338/mint&payment_hash=67d1d9ea6ada225c115418671b64a -d \
{
"proofs" :
[
{
"id": "DSAl9nvvyfva",
"amount": 2,
"secret": "S+tDfc1Lfsrb06zaRdVTed6Izg",
"C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3"
},
{
...
}
],
"invoice": "lnbc100n1p3kdrv5sp5lpdxzghe5j67q..."
}
```
**Response** `PostMeltResponse` from `Bob`:
```json
{
"paid": true,
"preimage": "da225c115418671b64a67d1d9ea6a..."
}
```
Only if the `paid==true`, the wallet `Alice` **MUST** delete the `Proofs` from her database (or move them to a history). If `paid==false`, `Alice` **CAN** repeat the same multiple times until the payment is successful.

68
docs/specs/06.md Normal file
View File

@@ -0,0 +1,68 @@
# NUT-6 - Split tokens
The split operation is the most important component of the Cashu system. The wallet `Alice` can use it to redeem tokens (i.e. receive new ones in return) that she received from `Carol`, or she can split her own tokens to a target amount she needs to send to `Carol`, if she does not have the necessary amounts to compose the target amount in her wallet already.
The basic idea is that `Alice` sends `Bob` a set of `Proof`'s and a set of `BlindedMessage`'s with an equal amount. Additionally, she specifies the `amount` at which she would like to have the split.
## 6.1 - Split to send
To make this more clear, we make an example of a typical case of sending tokens from `Alice` to `Carol`:
`Alice` has 64 satoshis in her wallet, composed of two tokens, one worth 32 sats and another two worth 16 sats. She wants to send `Carol` 40 sats but does not have the necessary tokens to combine them to reach the exact target amount of 40 sats. `Alice` requests a split from the mint. For that, she sends the mint `Bob` her tokens (`Proofs`) worth `[32, 16, 16]` and asks for a split at amount 40. The mint will then return her new tokens with the amounts `[32, 8, 16, 8]`. Notice that the first two tokens can now be combined to 40 sats. The original tokens that `Alice` sent to `Bob` are now invalidated.
## 6.2 - Split to receive
Another case of how split can be useful becomes apparent if we follow up the example above where `Alice` split her tokens ready to be sent to `Carol`. `Carol` can receive these tokens, which means to invalidate the tokens she receives and redeem them for new ones, using the same mechanism. Only if `Carol` redeems them for new tokens that only she can spend, `Alice` can't double-spend them anymore and this simple transaction can be considered settled. `Carol` requests a split of the tokens (`Proofs`) worth `[32, 8]` at the amount `40` (the total amount) to receive back new tokens with the same total amount.
## Example
**Request** of `Alice`:
```http
POST https://mint.host:3338/split
```
With the data being of the form `SplitRequest`:
```json
{
"proofs": Proofs,
"outputs": MintRequest,
"amount": int
}
```
With curl:
```bash
curl -X POST https://mint.host:3338/split -d \
{
"proofs":
[
{
"id": "DSAl9nvvyfva",
"amount": 2,
"secret": "S+tDfc1Lfsrb06zaRdVTed6Izg",
"C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3"
},
{
...
}
],
"outputs":{
"blinded_messages":
[
{
"amount": 2,
"B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239"
},
{
...
}
]
},
"amount": 40
}
```
If successful, `Bob` will respond

36
docs/specs/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Cashu NUTs (Notation, Usage, and Terminology)
| Number | Description | Wallets |
|----------|-------------------------------------------------------------|---------|
| [00][00] | Notation and Models | Python-CLI, Feni, LNbits
| [01][01] | Mint public keys | Python-CLI, Feni, LNbits
| [02][02] | Keysets and keyset IDs | Python-CLI, Feni, LNbits
| [03][03] | Requesting a mint | Python-CLI, Feni, LNbits
| [04][04] | Mint tokens | Python-CLI, Feni, LNbits
| [05][05] | Melt tokens | Python-CLI, Feni, LNbits
| [06][06] | Split tokens | Python-CLI, Feni, LNbits
[00]: 00.md
[01]: 01.md
[02]: 02.md
[03]: 03.md
[04]: 04.md
[05]: 05.md
[06]: 06.md
[07]: 07.md
[08]: 08.md
[09]: 09.md
[10]: 10.md
[11]: 11.md
[12]: 12.md
[13]: 13.md
[14]: 14.md
[15]: 15.md
[16]: 16.md
[17]: 17.md
[18]: 18.md
[19]: 19.md
[20]: 20.md

View File

@@ -17,12 +17,12 @@ Mint: `Bob`
# Blind Diffie-Hellmann key exchange (BDH)
- Mint `Bob` publishes `K = kG`
- `Alice` picks secret `x` and computes `Y = hash_to_point(x)`
- `Alice` picks secret `x` and computes `Y = hash_to_curve(x)`
- `Alice` sends to `Bob`: `T = Y + rG` with `r` being a random nonce
- `Bob` sends back to `Alice` blinded key: `Q = kT` (these two steps are the DH key exchange)
- `Alice` can calculate the unblinded key as `Q - rK = kY + krG - krG = kY = Z`
- Alice can take the pair `(x, Z)` as a token and can send it to `Carol`.
- `Carol` can send `(x, Z)` to `Bob` who then checks that `k*hash_to_point(x) == Z`, and if so treats it as a valid spend of a token, adding `x` to the list of spent secrets.
- `Carol` can send `(x, Z)` to `Bob` who then checks that `k*hash_to_curve(x) == Z`, and if so treats it as a valid spend of a token, adding `x` to the list of spent secrets.
# Cashu client protocol
@@ -38,7 +38,7 @@ Mint: `Bob`
- `Alice` pays bolt11 invoice `payment_request` using a Bitcoin Lightning wallet.
### Step 2: Request tokens
- To request tokens of value `amount : int`, `Alice` decomposes `amount` into a sum of values of `2^n`, e.g. `13` is `amounts : List[int] = [1, 4, 8]`. This can be easily done by representing `amount` as binary and using each binary digit that is `1` as part of the sum, e.g. `8` would be `1101` wich is `2^0 + 2^2 + 2^3`. In this example, `Alice` will request `N = len(amounts) = 3` tokens.
- To request tokens of value `amount : int`, `Alice` decomposes `amount` into a sum of values of `2^n`, e.g. `13` is `amounts : List[int] = [1, 4, 8]`. This can be easily done by representing `amount` as binary and using each binary digit that is `1` as part of the sum, e.g. `13` would be `1101` wich is `2^0 + 2^2 + 2^3`. In this example, `Alice` will request `N = len(amounts) = 3` tokens.
- `Alice` generates a random secret string `x_i` of `128` random bits with `i \in [0,..,N-1]`for each of the `N` requested tokens and encodes them in `base64`. [*TODO: remove index i*]
- `Alice` remembers `x` for the construction of the proof in Step 5.
@@ -122,4 +122,4 @@ Here we describe how `Alice` can request from `Bob` to make a Lightning payment
# Todo:
- Call subsections 1. and 1.2 etc so they can be referenced
- Define objets like `MintRequest` and `SplitRequests` once when they appear and reuse them.
- Clarify whether a `TOKEN` is a single Proof or a list of Proofs
- Clarify whether a `TOKEN` is a single Proof or a list of Proofs

2
mypy.ini Normal file
View File

@@ -0,0 +1,2 @@
[mypy]
exclude = cashu/nostr

452
poetry.lock generated
View File

@@ -1,6 +1,6 @@
[[package]]
name = "anyio"
version = "3.6.1"
version = "3.6.2"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
@@ -14,21 +14,23 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16)"]
trio = ["trio (>=0.16,<0.22)"]
[[package]]
name = "attrs"
version = "22.1.0"
version = "22.2.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.6"
[package.extras]
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
dev = ["attrs[docs,tests]"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
tests = ["attrs[tests-no-zope]", "zope.interface"]
tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=0.971,<0.990)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
tests_no_zope = ["cloudpickle", "hypothesis", "mypy (>=0.971,<0.990)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
[[package]]
name = "bech32"
@@ -48,11 +50,11 @@ python-versions = "*"
[[package]]
name = "black"
version = "22.8.0"
version = "22.12.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.6.2"
python-versions = ">=3.7"
[package.dependencies]
click = ">=8.0.0"
@@ -71,7 +73,7 @@ uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2022.9.24"
version = "2022.12.7"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
@@ -113,11 +115,44 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "colorama"
version = "0.4.5"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "coverage"
version = "7.0.1"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "cryptography"
version = "38.0.4"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.12"
[package.extras]
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
sdist = ["setuptools-rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"]
[[package]]
name = "ecdsa"
@@ -152,6 +187,17 @@ django = ["dj-database-url", "dj-email-url", "django-cache-url"]
lint = ["flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)"]
tests = ["dj-database-url", "dj-email-url", "django-cache-url", "pytest"]
[[package]]
name = "exceptiongroup"
version = "1.1.0"
description = "Backport of PEP 654 (exception groups)"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.83.0"
@@ -167,19 +213,16 @@ starlette = "0.19.1"
[package.extras]
all = ["email_validator (>=1.1.1,<2.0.0)", "itsdangerous (>=1.1.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "requests (>=2.24.0,<3.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "pre-commit (>=2.17.0,<3.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.2)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.5.0)"]
doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.5.0)"]
test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.3.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "email_validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "orjson (>=3.2.1,<4.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-dataclasses (==0.6.5)", "types-orjson (==3.6.2)", "types-ujson (==4.2.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"]
[[package]]
name = "h11"
version = "0.14.0"
version = "0.12.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
python-versions = ">=3.6"
[[package]]
name = "idna"
@@ -191,7 +234,7 @@ python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "5.0.0"
version = "5.2.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
@@ -202,7 +245,7 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
perf = ["ipython"]
testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
@@ -216,17 +259,17 @@ python-versions = "*"
[[package]]
name = "isort"
version = "5.10.1"
version = "5.11.4"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
python-versions = ">=3.7.0"
[package.extras]
colors = ["colorama (>=0.4.3,<0.5.0)"]
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
pipfile-deprecated-finder = ["pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements_deprecated_finder = ["pip-api", "pipreqs"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]]
name = "loguru"
@@ -245,7 +288,7 @@ dev = ["Sphinx (>=4.1.1)", "black (>=19.10b0)", "colorama (>=0.3.4)", "docutils
[[package]]
name = "marshmallow"
version = "3.18.0"
version = "3.19.0"
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
category = "main"
optional = false
@@ -255,9 +298,9 @@ python-versions = ">=3.7"
packaging = ">=17.0"
[package.extras]
dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.9.11)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"]
docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.1.1)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"]
lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.9.11)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)"]
dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"]
docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.3.0)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"]
lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)"]
tests = ["pytest", "pytz", "simplejson"]
[[package]]
@@ -300,18 +343,15 @@ attrs = ">=19.2.0"
[[package]]
name = "packaging"
version = "21.3"
version = "22.0"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
python-versions = ">=3.7"
[[package]]
name = "pathspec"
version = "0.10.1"
version = "0.10.3"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
@@ -319,15 +359,15 @@ python-versions = ">=3.7"
[[package]]
name = "platformdirs"
version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
version = "2.6.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"]
test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"]
test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
[[package]]
name = "pluggy"
@@ -344,14 +384,6 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycparser"
version = "2.21"
@@ -360,6 +392,14 @@ category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pycryptodomex"
version = "3.16.0"
description = "Cryptographic library for Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pydantic"
version = "1.10.2"
@@ -376,19 +416,16 @@ dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
name = "PySocks"
version = "1.7.1"
description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
category = "main"
optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pytest"
version = "7.1.3"
version = "7.2.0"
description = "pytest: simple powerful testing with Python"
category = "main"
optional = false
@@ -397,12 +434,12 @@ python-versions = ">=3.7"
[package.dependencies]
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
tomli = ">=1.0.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
@@ -422,6 +459,21 @@ typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""}
[package.extras]
testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
[[package]]
name = "pytest-cov"
version = "4.0.0"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
[[package]]
name = "python-bitcoinlib"
version = "0.11.2"
@@ -484,6 +536,19 @@ python-versions = "*"
[package.dependencies]
cffi = ">=1.3.0"
[[package]]
name = "setuptools"
version = "65.6.3"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "six"
version = "1.16.0"
@@ -571,7 +636,7 @@ python-versions = ">=3.6"
[[package]]
name = "typing-extensions"
version = "4.3.0"
version = "4.4.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
@@ -579,11 +644,11 @@ python-versions = ">=3.7"
[[package]]
name = "urllib3"
version = "1.26.12"
version = "1.26.13"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
@@ -606,6 +671,30 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"]
[[package]]
name = "websocket-client"
version = "1.3.3"
description = "WebSocket client for Python with low level API options"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"]
optional = ["python-socks", "wsaccel"]
test = ["websockets"]
[[package]]
name = "wheel"
version = "0.38.4"
description = "A built-package format for Python"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
test = ["pytest (>=3.0.0)"]
[[package]]
name = "win32-setctime"
version = "1.1.0"
@@ -619,29 +708,29 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[[package]]
name = "zipp"
version = "3.8.1"
version = "3.11.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"]
testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "b4e980ee90226bab07750b1becc8c69df7752f6d168d200a79c782aa1efe61da"
content-hash = "f7238d56229c2e957585fc22331529facc751262430698369fe5a00396344401"
[metadata.files]
anyio = [
{file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"},
{file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"},
{file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
{file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
]
attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
{file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
{file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
]
bech32 = [
{file = "bech32-1.2.0-py3-none-any.whl", hash = "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"},
@@ -653,33 +742,22 @@ bitstring = [
{file = "bitstring-3.1.9.tar.gz", hash = "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7"},
]
black = [
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"},
{file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"},
{file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"},
{file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"},
{file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"},
{file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"},
{file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"},
{file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"},
{file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"},
{file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"},
{file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"},
{file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"},
{file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"},
{file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"},
{file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"},
{file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"},
{file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"},
{file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"},
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
{file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"},
{file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"},
{file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"},
{file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"},
{file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"},
{file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"},
{file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"},
{file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"},
{file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"},
{file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"},
{file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"},
{file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"},
]
certifi = [
{file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"},
{file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"},
{file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
{file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
]
cffi = [
{file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
@@ -756,8 +834,89 @@ click = [
{file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
]
colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
coverage = [
{file = "coverage-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3695c4f4750bca943b3e1f74ad4be8d29e4aeab927d50772c41359107bd5d5c"},
{file = "coverage-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa6a5a224b7f4cfb226f4fc55a57e8537fcc096f42219128c2c74c0e7d0953e1"},
{file = "coverage-7.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74f70cd92669394eaf8d7756d1b195c8032cf7bbbdfce3bc489d4e15b3b8cf73"},
{file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b66bb21a23680dee0be66557dc6b02a3152ddb55edf9f6723fa4a93368f7158d"},
{file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d87717959d4d0ee9db08a0f1d80d21eb585aafe30f9b0a54ecf779a69cb015f6"},
{file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:854f22fa361d1ff914c7efa347398374cc7d567bdafa48ac3aa22334650dfba2"},
{file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e414dc32ee5c3f36544ea466b6f52f28a7af788653744b8570d0bf12ff34bc0"},
{file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6c5ad996c6fa4d8ed669cfa1e8551348729d008a2caf81489ab9ea67cfbc7498"},
{file = "coverage-7.0.1-cp310-cp310-win32.whl", hash = "sha256:691571f31ace1837838b7e421d3a09a8c00b4aac32efacb4fc9bd0a5c647d25a"},
{file = "coverage-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:89caf4425fe88889e2973a8e9a3f6f5f9bbe5dd411d7d521e86428c08a873a4a"},
{file = "coverage-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63d56165a7c76265468d7e0c5548215a5ba515fc2cba5232d17df97bffa10f6c"},
{file = "coverage-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f943a3b2bc520102dd3e0bb465e1286e12c9a54f58accd71b9e65324d9c7c01"},
{file = "coverage-7.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:830525361249dc4cd013652b0efad645a385707a5ae49350c894b67d23fbb07c"},
{file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd1b9c5adc066db699ccf7fa839189a649afcdd9e02cb5dc9d24e67e7922737d"},
{file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00c14720b8b3b6c23b487e70bd406abafc976ddc50490f645166f111c419c39"},
{file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6d55d840e1b8c0002fce66443e124e8581f30f9ead2e54fbf6709fb593181f2c"},
{file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:66b18c3cf8bbab0cce0d7b9e4262dc830e93588986865a8c78ab2ae324b3ed56"},
{file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:12a5aa77783d49e05439fbe6e6b427484f8a0f9f456b46a51d8aac022cfd024d"},
{file = "coverage-7.0.1-cp311-cp311-win32.whl", hash = "sha256:b77015d1cb8fe941be1222a5a8b4e3fbca88180cfa7e2d4a4e58aeabadef0ab7"},
{file = "coverage-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb992c47cb1e5bd6a01e97182400bcc2ba2077080a17fcd7be23aaa6e572e390"},
{file = "coverage-7.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e78e9dcbf4f3853d3ae18a8f9272111242531535ec9e1009fa8ec4a2b74557dc"},
{file = "coverage-7.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60bef2e2416f15fdc05772bf87db06c6a6f9870d1db08fdd019fbec98ae24a9"},
{file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9823e4789ab70f3ec88724bba1a203f2856331986cd893dedbe3e23a6cfc1e4e"},
{file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9158f8fb06747ac17bd237930c4372336edc85b6e13bdc778e60f9d685c3ca37"},
{file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:486ee81fa694b4b796fc5617e376326a088f7b9729c74d9defa211813f3861e4"},
{file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1285648428a6101b5f41a18991c84f1c3959cee359e51b8375c5882fc364a13f"},
{file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2c44fcfb3781b41409d0f060a4ed748537557de9362a8a9282182fafb7a76ab4"},
{file = "coverage-7.0.1-cp37-cp37m-win32.whl", hash = "sha256:d6814854c02cbcd9c873c0f3286a02e3ac1250625cca822ca6bc1018c5b19f1c"},
{file = "coverage-7.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f66460f17c9319ea4f91c165d46840314f0a7c004720b20be58594d162a441d8"},
{file = "coverage-7.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b373c9345c584bb4b5f5b8840df7f4ab48c4cbb7934b58d52c57020d911b856"},
{file = "coverage-7.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d3022c3007d3267a880b5adcf18c2a9bf1fc64469b394a804886b401959b8742"},
{file = "coverage-7.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92651580bd46519067e36493acb394ea0607b55b45bd81dd4e26379ed1871f55"},
{file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cfc595d2af13856505631be072835c59f1acf30028d1c860b435c5fc9c15b69"},
{file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b4b3a4d9915b2be879aff6299c0a6129f3d08a775d5a061f503cf79571f73e4"},
{file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b6f22bb64cc39bcb883e5910f99a27b200fdc14cdd79df8696fa96b0005c9444"},
{file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72d1507f152abacea81f65fee38e4ef3ac3c02ff8bc16f21d935fd3a8a4ad910"},
{file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a79137fc99815fff6a852c233628e735ec15903cfd16da0f229d9c4d45926ab"},
{file = "coverage-7.0.1-cp38-cp38-win32.whl", hash = "sha256:b3763e7fcade2ff6c8e62340af9277f54336920489ceb6a8cd6cc96da52fcc62"},
{file = "coverage-7.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:09f6b5a8415b6b3e136d5fec62b552972187265cb705097bf030eb9d4ffb9b60"},
{file = "coverage-7.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:978258fec36c154b5e250d356c59af7d4c3ba02bef4b99cda90b6029441d797d"},
{file = "coverage-7.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19ec666533f0f70a0993f88b8273057b96c07b9d26457b41863ccd021a043b9a"},
{file = "coverage-7.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfded268092a84605f1cc19e5c737f9ce630a8900a3589e9289622db161967e9"},
{file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bcfb1d8ac94af886b54e18a88b393f6a73d5959bb31e46644a02453c36e475"},
{file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b4a923cc7566bbc7ae2dfd0ba5a039b61d19c740f1373791f2ebd11caea59"},
{file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aec2d1515d9d39ff270059fd3afbb3b44e6ec5758af73caf18991807138c7118"},
{file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c20cfebcc149a4c212f6491a5f9ff56f41829cd4f607b5be71bb2d530ef243b1"},
{file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fd556ff16a57a070ce4f31c635953cc44e25244f91a0378c6e9bdfd40fdb249f"},
{file = "coverage-7.0.1-cp39-cp39-win32.whl", hash = "sha256:b9ea158775c7c2d3e54530a92da79496fb3fb577c876eec761c23e028f1e216c"},
{file = "coverage-7.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:d1991f1dd95eba69d2cd7708ff6c2bbd2426160ffc73c2b81f617a053ebcb1a8"},
{file = "coverage-7.0.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:3dd4ee135e08037f458425b8842d24a95a0961831a33f89685ff86b77d378f89"},
{file = "coverage-7.0.1.tar.gz", hash = "sha256:a4a574a19eeb67575a5328a5760bbbb737faa685616586a9f9da4281f940109c"},
]
cryptography = [
{file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:2fa36a7b2cc0998a3a4d5af26ccb6273f3df133d61da2ba13b3286261e7efb70"},
{file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:1f13ddda26a04c06eb57119caf27a524ccae20533729f4b1e4a69b54e07035eb"},
{file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2ec2a8714dd005949d4019195d72abed84198d877112abb5a27740e217e0ea8d"},
{file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50a1494ed0c3f5b4d07650a68cd6ca62efe8b596ce743a5c94403e6f11bf06c1"},
{file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a10498349d4c8eab7357a8f9aa3463791292845b79597ad1b98a543686fb1ec8"},
{file = "cryptography-38.0.4-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:10652dd7282de17990b88679cb82f832752c4e8237f0c714be518044269415db"},
{file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bfe6472507986613dc6cc00b3d492b2f7564b02b3b3682d25ca7f40fa3fd321b"},
{file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce127dd0a6a0811c251a6cddd014d292728484e530d80e872ad9806cfb1c5b3c"},
{file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:53049f3379ef05182864d13bb9686657659407148f901f3f1eee57a733fb4b00"},
{file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8a4b2bdb68a447fadebfd7d24855758fe2d6fecc7fed0b78d190b1af39a8e3b0"},
{file = "cryptography-38.0.4-cp36-abi3-win32.whl", hash = "sha256:1d7e632804a248103b60b16fb145e8df0bc60eed790ece0d12efe8cd3f3e7744"},
{file = "cryptography-38.0.4-cp36-abi3-win_amd64.whl", hash = "sha256:8e45653fb97eb2f20b8c96f9cd2b3a0654d742b47d638cf2897afbd97f80fa6d"},
{file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca57eb3ddaccd1112c18fc80abe41db443cc2e9dcb1917078e02dfa010a4f353"},
{file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:c9e0d79ee4c56d841bd4ac6e7697c8ff3c8d6da67379057f29e66acffcd1e9a7"},
{file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0e70da4bdff7601b0ef48e6348339e490ebfb0cbe638e083c9c41fb49f00c8bd"},
{file = "cryptography-38.0.4-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:998cd19189d8a747b226d24c0207fdaa1e6658a1d3f2494541cb9dfbf7dcb6d2"},
{file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67461b5ebca2e4c2ab991733f8ab637a7265bb582f07c7c88914b5afb88cb95b"},
{file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4eb85075437f0b1fd8cd66c688469a0c4119e0ba855e3fef86691971b887caf6"},
{file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3178d46f363d4549b9a76264f41c6948752183b3f587666aff0555ac50fd7876"},
{file = "cryptography-38.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6391e59ebe7c62d9902c24a4d8bcbc79a68e7c4ab65863536127c8a9cd94043b"},
{file = "cryptography-38.0.4-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:78e47e28ddc4ace41dd38c42e6feecfdadf9c3be2af389abbfeef1ff06822285"},
{file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fb481682873035600b5502f0015b664abc26466153fab5c6bc92c1ea69d478b"},
{file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4367da5705922cf7070462e964f66e4ac24162e22ab0a2e9d31f1b270dd78083"},
{file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b4cad0cea995af760f82820ab4ca54e5471fc782f70a007f31531957f43e9dee"},
{file = "cryptography-38.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:80ca53981ceeb3241998443c4964a387771588c4e4a5d92735a493af868294f9"},
{file = "cryptography-38.0.4.tar.gz", hash = "sha256:175c1a818b87c9ac80bb7377f5520b7f31b3ef2a0004e2420319beadedb67290"},
]
ecdsa = [
{file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"},
@@ -767,37 +926,41 @@ environs = [
{file = "environs-9.5.0-py2.py3-none-any.whl", hash = "sha256:1e549569a3de49c05f856f40bce86979e7d5ffbbc4398e7f338574c220189124"},
{file = "environs-9.5.0.tar.gz", hash = "sha256:a76307b36fbe856bdca7ee9161e6c466fd7fcffc297109a118c59b54e27e30c9"},
]
exceptiongroup = [
{file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
{file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
]
fastapi = [
{file = "fastapi-0.83.0-py3-none-any.whl", hash = "sha256:694a2b6c2607a61029a4be1c6613f84d74019cb9f7a41c7a475dca8e715f9368"},
{file = "fastapi-0.83.0.tar.gz", hash = "sha256:96eb692350fe13d7a9843c3c87a874f0d45102975257dd224903efd6c0fde3bd"},
]
h11 = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
]
idna = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
]
importlib-metadata = [
{file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"},
{file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"},
{file = "importlib_metadata-5.2.0-py3-none-any.whl", hash = "sha256:0eafa39ba42bf225fc00e67f701d71f85aead9f878569caf13c3724f704b970f"},
{file = "importlib_metadata-5.2.0.tar.gz", hash = "sha256:404d48d62bba0b7a77ff9d405efd91501bef2e67ff4ace0bed40a0cf28c3c7cd"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
{file = "isort-5.11.4-py3-none-any.whl", hash = "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b"},
{file = "isort-5.11.4.tar.gz", hash = "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6"},
]
loguru = [
{file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"},
{file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
]
marshmallow = [
{file = "marshmallow-3.18.0-py3-none-any.whl", hash = "sha256:35e02a3a06899c9119b785c12a22f4cda361745d66a71ab691fd7610202ae104"},
{file = "marshmallow-3.18.0.tar.gz", hash = "sha256:6804c16114f7fce1f5b4dadc31f4674af23317fcc7f075da21e35c1a35d781f7"},
{file = "marshmallow-3.19.0-py3-none-any.whl", hash = "sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b"},
{file = "marshmallow-3.19.0.tar.gz", hash = "sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78"},
]
mypy = [
{file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"},
@@ -833,29 +996,53 @@ outcome = [
{file = "outcome-1.2.0.tar.gz", hash = "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672"},
]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
{file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"},
{file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
]
pathspec = [
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
{file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"},
{file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
{file = "platformdirs-2.6.0-py3-none-any.whl", hash = "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca"},
{file = "platformdirs-2.6.0.tar.gz", hash = "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
pycryptodomex = [
{file = "pycryptodomex-3.16.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b3d04c00d777c36972b539fb79958790126847d84ec0129fce1efef250bfe3ce"},
{file = "pycryptodomex-3.16.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e5a670919076b71522c7d567a9043f66f14b202414a63c3a078b5831ae342c03"},
{file = "pycryptodomex-3.16.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:ce338a9703f54b2305a408fc9890eb966b727ce72b69f225898bb4e9d9ed3f1f"},
{file = "pycryptodomex-3.16.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:a1c0ae7123448ecb034c75c713189cb00ebe2d415b11682865b6c54d200d9c93"},
{file = "pycryptodomex-3.16.0-cp27-cp27m-win32.whl", hash = "sha256:8851585ff19871e5d69e1790f4ca5f6fd1699d6b8b14413b472a4c0dbc7ea780"},
{file = "pycryptodomex-3.16.0-cp27-cp27m-win_amd64.whl", hash = "sha256:8dd2d9e3c617d0712ed781a77efd84ea579e76c5f9b2a4bc0b684ebeddf868b2"},
{file = "pycryptodomex-3.16.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2ad9bb86b355b6104796567dd44c215b3dc953ef2fae5e0bdfb8516731df92cf"},
{file = "pycryptodomex-3.16.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:e25a2f5667d91795f9417cb856f6df724ccdb0cdd5cbadb212ee9bf43946e9f8"},
{file = "pycryptodomex-3.16.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:b0789a8490114a2936ed77c87792cfe77582c829cb43a6d86ede0f9624ba8aa3"},
{file = "pycryptodomex-3.16.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0da835af786fdd1c9930994c78b23e88d816dc3f99aa977284a21bbc26d19735"},
{file = "pycryptodomex-3.16.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:22aed0868622d95179217c298e37ed7410025c7b29dac236d3230617d1e4ed56"},
{file = "pycryptodomex-3.16.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1619087fb5b31510b0b0b058a54f001a5ffd91e6ffee220d9913064519c6a69d"},
{file = "pycryptodomex-3.16.0-cp35-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:70288d9bfe16b2fd0d20b6c365db614428f1bcde7b20d56e74cf88ade905d9eb"},
{file = "pycryptodomex-3.16.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7993d26dae4d83b8f4ce605bb0aecb8bee330bb3c95475ef06f3694403621e71"},
{file = "pycryptodomex-3.16.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:1cda60207be8c1cf0b84b9138f9e3ca29335013d2b690774a5e94678ff29659a"},
{file = "pycryptodomex-3.16.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:04610536921c1ec7adba158ef570348550c9f3a40bc24be9f8da2ef7ab387981"},
{file = "pycryptodomex-3.16.0-cp35-abi3-win32.whl", hash = "sha256:daa67f5ebb6fbf1ee9c90decaa06ca7fc88a548864e5e484d52b0920a57fe8a5"},
{file = "pycryptodomex-3.16.0-cp35-abi3-win_amd64.whl", hash = "sha256:231dc8008cbdd1ae0e34645d4523da2dbc7a88c325f0d4a59635a86ee25b41dd"},
{file = "pycryptodomex-3.16.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:4dbbe18cc232b5980c7633972ae5417d0df76fe89e7db246eefd17ef4d8e6d7a"},
{file = "pycryptodomex-3.16.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:893f8a97d533c66cc3a56e60dd3ed40a3494ddb4aafa7e026429a08772f8a849"},
{file = "pycryptodomex-3.16.0-pp27-pypy_73-win32.whl", hash = "sha256:6a465e4f856d2a4f2a311807030c89166529ccf7ccc65bef398de045d49144b6"},
{file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba57ac7861fd2c837cdb33daf822f2a052ff57dd769a2107807f52a36d0e8d38"},
{file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f2b971a7b877348a27dcfd0e772a0343fb818df00b74078e91c008632284137d"},
{file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e2453162f473c1eae4826eb10cd7bce19b5facac86d17fb5f29a570fde145abd"},
{file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:0ba28aa97cdd3ff5ed1a4f2b7f5cd04e721166bd75bd2b929e2734433882b583"},
{file = "pycryptodomex-3.16.0.tar.gz", hash = "sha256:e9ba9d8ed638733c9e95664470b71d624a6def149e2db6cc52c1aca5a6a2df1d"},
]
pydantic = [
{file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"},
{file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"},
@@ -894,18 +1081,23 @@ pydantic = [
{file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"},
{file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
PySocks = [
{file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"},
{file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
{file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"},
]
pytest = [
{file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"},
{file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"},
{file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
]
pytest-asyncio = [
{file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"},
{file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"},
]
pytest-cov = [
{file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"},
{file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"},
]
python-bitcoinlib = [
{file = "python-bitcoinlib-0.11.2.tar.gz", hash = "sha256:61ba514e0d232cc84741e49862dcedaf37199b40bba252a17edc654f63d13f39"},
{file = "python_bitcoinlib-0.11.2-py3-none-any.whl", hash = "sha256:78bd4ee717fe805cd760dfdd08765e77b7c7dbef4627f8596285e84953756508"},
@@ -947,6 +1139,10 @@ secp256k1 = [
{file = "secp256k1-0.14.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c9e7c024ff17e9b9d7c392bb2a917da231d6cb40ab119389ff1f51dca10339a4"},
{file = "secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397"},
]
setuptools = [
{file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"},
{file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
@@ -1030,22 +1226,30 @@ typed-ast = [
{file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
]
typing-extensions = [
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
]
urllib3 = [
{file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
{file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"},
{file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"},
]
uvicorn = [
{file = "uvicorn-0.18.3-py3-none-any.whl", hash = "sha256:0abd429ebb41e604ed8d2be6c60530de3408f250e8d2d84967d85ba9e86fe3af"},
{file = "uvicorn-0.18.3.tar.gz", hash = "sha256:9a66e7c42a2a95222f76ec24a4b754c158261c4696e683b9dadc72b590e0311b"},
]
websocket-client = [
{file = "websocket-client-1.3.3.tar.gz", hash = "sha256:d58c5f284d6a9bf8379dab423259fe8f85b70d5fa5d2916d5791a84594b122b1"},
{file = "websocket_client-1.3.3-py3-none-any.whl", hash = "sha256:5d55652dc1d0b3c734f044337d929aaf83f4f9138816ec680c1aefefb4dc4877"},
]
wheel = [
{file = "wheel-0.38.4-py3-none-any.whl", hash = "sha256:b60533f3f5d530e971d6737ca6d58681ee434818fab630c83a734bb10c083ce8"},
{file = "wheel-0.38.4.tar.gz", hash = "sha256:965f5259b566725405b05e7cf774052044b1ed30119b5d586b2703aafe8719ac"},
]
win32-setctime = [
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
]
zipp = [
{file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"},
{file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"},
{file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"},
{file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"},
]

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "cashu"
version = "0.3.2"
version = "0.7.0"
description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"]
license = "MIT"
@@ -22,6 +22,13 @@ bitstring = "^3.1.9"
secp256k1 = "^0.14.0"
sqlalchemy-aio = "^0.17.0"
python-bitcoinlib = "^0.11.2"
h11 = "0.12.0"
PySocks = "^1.7.1"
cryptography = "^38.0.4"
websocket-client = "1.3.3"
pycryptodomex = "^3.16.0"
setuptools = "^65.6.3"
wheel = "^0.38.4"
[tool.poetry.dev-dependencies]
black = {version = "^22.8.0", allow-prereleases = true}
@@ -31,6 +38,7 @@ isort = "^5.10.1"
mypy = "^0.971"
black = {version = "^22.8.0", allow-prereleases = true}
isort = "^5.10.1"
pytest-cov = "^4.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@@ -1,43 +1,48 @@
anyio==3.6.1 ; python_version >= "3.7" and python_version < "4.0"
attrs==22.1.0 ; python_version >= "3.7" and python_version < "4.0"
anyio==3.6.2 ; python_version >= "3.7" and python_version < "4.0"
attrs==22.2.0 ; python_version >= "3.7" and python_version < "4.0"
bech32==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
bitstring==3.1.9 ; python_version >= "3.7" and python_version < "4.0"
certifi==2022.9.24 ; python_version >= "3.7" and python_version < "4.0"
certifi==2022.12.7 ; python_version >= "3.7" and python_version < "4.0"
cffi==1.15.1 ; python_version >= "3.7" and python_version < "4.0"
charset-normalizer==2.0.12 ; python_version >= "3.7" and python_version < "4.0"
click==8.0.4 ; python_version >= "3.7" and python_version < "4.0"
colorama==0.4.5 ; python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" or python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
colorama==0.4.6 ; python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" or python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
cryptography==38.0.4 ; python_version >= "3.7" and python_version < "4.0"
ecdsa==0.18.0 ; python_version >= "3.7" and python_version < "4.0"
environs==9.5.0 ; python_version >= "3.7" and python_version < "4.0"
exceptiongroup==1.1.0 ; python_version >= "3.7" and python_version < "3.11"
fastapi==0.83.0 ; python_version >= "3.7" and python_version < "4.0"
h11==0.14.0 ; python_version >= "3.7" and python_version < "4.0"
h11==0.12.0 ; python_version >= "3.7" and python_version < "4.0"
idna==3.4 ; python_version >= "3.7" and python_version < "4.0"
importlib-metadata==4.12.0 ; python_version >= "3.7" and python_version < "3.8"
importlib-metadata==5.2.0 ; python_version >= "3.7" and python_version < "3.8"
iniconfig==1.1.1 ; python_version >= "3.7" and python_version < "4.0"
loguru==0.6.0 ; python_version >= "3.7" and python_version < "4.0"
marshmallow==3.18.0 ; python_version >= "3.7" and python_version < "4.0"
marshmallow==3.19.0 ; python_version >= "3.7" and python_version < "4.0"
outcome==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
packaging==21.3 ; python_version >= "3.7" and python_version < "4.0"
packaging==22.0 ; python_version >= "3.7" and python_version < "4.0"
pluggy==1.0.0 ; python_version >= "3.7" and python_version < "4.0"
py==1.11.0 ; python_version >= "3.7" and python_version < "4.0"
pycparser==2.21 ; python_version >= "3.7" and python_version < "4.0"
pycryptodomex==3.16.0 ; python_version >= "3.7" and python_version < "4.0"
pydantic==1.10.2 ; python_version >= "3.7" and python_version < "4.0"
pyparsing==3.0.9 ; python_version >= "3.7" and python_version < "4.0"
pysocks==1.7.1 ; python_version >= "3.7" and python_version < "4.0"
pytest-asyncio==0.19.0 ; python_version >= "3.7" and python_version < "4.0"
pytest==7.1.3 ; python_version >= "3.7" and python_version < "4.0"
pytest==7.2.0 ; python_version >= "3.7" and python_version < "4.0"
python-bitcoinlib==0.11.2 ; python_version >= "3.7" and python_version < "4.0"
python-dotenv==0.21.0 ; python_version >= "3.7" and python_version < "4.0"
represent==1.6.0.post0 ; python_version >= "3.7" and python_version < "4.0"
requests==2.27.1 ; python_version >= "3.7" and python_version < "4.0"
secp256k1==0.14.0 ; python_version >= "3.7" and python_version < "4.0"
setuptools==65.6.3 ; python_version >= "3.7" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.7" and python_version < "4.0"
sniffio==1.3.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy-aio==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy==1.3.24 ; python_version >= "3.7" and python_version < "4.0"
starlette==0.19.1 ; python_version >= "3.7" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
typing-extensions==4.3.0 ; python_version >= "3.7" and python_version < "4.0"
urllib3==1.26.12 ; python_version >= "3.7" and python_version < "4"
tomli==2.0.1 ; python_version >= "3.7" and python_version < "3.11"
typing-extensions==4.4.0 ; python_version >= "3.7" and python_version < "4.0"
urllib3==1.26.13 ; python_version >= "3.7" and python_version < "4.0"
uvicorn==0.18.3 ; python_version >= "3.7" and python_version < "4.0"
websocket-client==1.3.3 ; python_version >= "3.7" and python_version < "4.0"
wheel==0.38.4 ; python_version >= "3.7" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
zipp==3.8.1 ; python_version >= "3.7" and python_version < "3.8"
zipp==3.11.0 ; python_version >= "3.7" and python_version < "3.8"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]}
setuptools.setup(
name="cashu",
version="0.3.2",
version="0.7.0",
description="Ecash wallet and mint with Bitcoin Lightning support",
long_description=long_description,
long_description_content_type="text/markdown",

5
tests/test_core.py Normal file
View File

@@ -0,0 +1,5 @@
from cashu.core.split import amount_split
def test_get_output_split():
assert amount_split(13) == [1, 4, 8]

106
tests/test_crypto.py Normal file
View File

@@ -0,0 +1,106 @@
import pytest
from cashu.core.b_dhke import hash_to_curve, step1_alice, step2_bob, step3_alice
from cashu.core.secp import PrivateKey, PublicKey
def test_hash_to_curve():
result = hash_to_curve(
bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000000"
)
)
assert (
result.serialize().hex()
== "0266687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925"
)
result = hash_to_curve(
bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001"
)
)
assert (
result.serialize().hex()
== "02ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5"
)
def test_hash_to_curve_iteration():
"""This input causes multiple rounds of the hash_to_curve algorithm."""
result = hash_to_curve(
bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000002"
)
)
assert (
result.serialize().hex()
== "02076c988b353fcbb748178ecb286bc9d0b4acf474d4ba31ba62334e46c97c416a"
)
def test_step1():
""""""
B_, blinding_factor = step1_alice(
"test_message",
blinding_factor=bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001"
), # 32 bytes
)
assert (
B_.serialize().hex()
== "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2"
)
assert blinding_factor.private_key == bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001"
)
def test_step2():
B_, _ = step1_alice(
"test_message",
blinding_factor=bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001"
), # 32 bytes
)
a = PrivateKey(
privkey=bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001"
),
raw=True,
)
C_ = B_.mult(a)
assert (
C_.serialize().hex()
== "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2"
)
def test_step3():
# C = C_ - A.mult(r)
C_ = PublicKey(
bytes.fromhex(
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2"
),
raw=True,
)
r = PrivateKey(
privkey=bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001"
)
)
A = PublicKey(
pubkey=b"\x02"
+ bytes.fromhex(
"0000000000000000000000000000000000000000000000000000000000000001",
),
raw=True,
)
C = step3_alice(C_, r, A)
assert (
C.serialize().hex()
== "03c724d7e6a5443b39ac8acf11f40420adc4f99a02e7cc1b57703d9391f6d129cd"
)

111
tests/test_mint.py Normal file
View File

@@ -0,0 +1,111 @@
from typing import List
import pytest
import pytest_asyncio
from cashu.core.base import BlindedMessage, Proof
from cashu.core.migrations import migrate_databases
SERVER_ENDPOINT = "http://localhost:3338"
import os
from cashu.core.db import Database
from cashu.core.settings import MAX_ORDER
from cashu.mint import migrations
from cashu.mint.ledger import Ledger
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
assert exc.args[0] == msg, Exception(
f"Expected error: {msg}, got: {exc.args[0]}"
)
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert [p.amount for p in proofs] == expected
async def start_mint_init(ledger):
await migrate_databases(ledger.db, migrations)
await ledger.load_used_proofs()
await ledger.init_keysets()
@pytest_asyncio.fixture(scope="function")
async def ledger():
db_file = "data/mint/test.sqlite3"
if os.path.exists(db_file):
os.remove(db_file)
ledger = Ledger(
db=Database("test", "data/mint"),
seed="TEST_PRIVATE_KEY",
derivation_path="0/0/0/0",
lightning=None,
)
await start_mint_init(ledger)
yield ledger
@pytest.mark.asyncio
async def test_keysets(ledger: Ledger):
assert len(ledger.keysets.keysets)
assert len(ledger.keysets.get_ids())
assert ledger.keyset.id == "XQM1wwtQbOXE"
@pytest.mark.asyncio
async def test_get_keyset(ledger: Ledger):
keyset = ledger.get_keyset()
assert type(keyset) == dict
assert len(keyset) == MAX_ORDER
@pytest.mark.asyncio
async def test_mint(ledger: Ledger):
blinded_messages_mock = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
)
]
promises = await ledger.mint(blinded_messages_mock)
assert len(promises)
assert promises[0].amount == 8
assert (
promises[0].C_
== "032dfadd74bb3abba8170ecbae5401507e384eafd312defda94148fa37314c0ef0"
)
@pytest.mark.asyncio
async def test_mint_invalid_blinded_message(ledger: Ledger):
blinded_messages_mock_invalid_key = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bff02d7fa2365379b0840afe249a7a9d71237",
)
]
await assert_err(
ledger.mint(blinded_messages_mock_invalid_key), "invalid public key"
)
@pytest.mark.asyncio
async def test_generate_promises(ledger: Ledger):
blinded_messages_mock = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
)
]
promises = await ledger._generate_promises(blinded_messages_mock)
assert (
promises[0].C_
== "032dfadd74bb3abba8170ecbae5401507e384eafd312defda94148fa37314c0ef0"
)

22
tests/test_tor.py Normal file
View File

@@ -0,0 +1,22 @@
import pytest
import requests
from cashu.tor.tor import TorProxy
# @pytest.mark.skip
def test_tor_setup():
s = requests.Session()
tor = TorProxy(timeout=False)
tor.run_daemon()
socks_host, socks_port = "localhost", 9050
proxies = {
"http": f"socks5://{socks_host}:{socks_port}",
"https": f"socks5://{socks_host}:{socks_port}",
}
s.proxies.update(proxies)
resp = s.get("https://google.com")
resp.raise_for_status()

View File

@@ -1,11 +1,15 @@
import time
from re import S
from typing import List
import pytest
import pytest_asyncio
from cashu.core.helpers import async_unwrap
from cashu.core.base import Proof
from cashu.core.helpers import async_unwrap, sum_proofs
from cashu.core.migrations import migrate_databases
from cashu.core.settings import MAX_ORDER
from cashu.wallet import migrations
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2
@@ -22,139 +26,237 @@ async def assert_err(f, msg):
)
def assert_amt(proofs, expected):
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert [p["amount"] for p in proofs] == expected
assert [p.amount for p in proofs] == expected
@pytest.mark.asyncio
async def run_test():
@pytest_asyncio.fixture(scope="function")
async def wallet1():
wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1", "wallet1")
await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint()
wallet1.status()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2():
wallet2 = Wallet2(SERVER_ENDPOINT, "data/wallet2", "wallet2")
await migrate_databases(wallet2.db, migrations)
await wallet2.load_mint()
wallet2.status()
yield wallet2
proofs = []
# Mint a proof of promise. We obtain a proof for 64 coins
proofs += await wallet1.mint(64)
print(proofs)
@pytest.mark.asyncio
async def test_get_keys(wallet1: Wallet):
assert len(wallet1.keys) == MAX_ORDER
keyset = await wallet1._get_keys(wallet1.url)
assert type(keyset.id) == str
assert len(keyset.id) > 0
@pytest.mark.asyncio
async def test_get_keyset(wallet1: Wallet):
assert len(wallet1.keys) == MAX_ORDER
# ket's get the keys first so we can get a keyset ID that we use later
keys1 = await wallet1._get_keys(wallet1.url)
# gets the keys of a specific keyset
keys2 = await wallet1._get_keyset(wallet1.url, keys1.id)
assert len(keys1.public_keys) == len(keys2.public_keys)
@pytest.mark.asyncio
async def test_get_keysets(wallet1: Wallet):
keyset = await wallet1._get_keysets(wallet1.url)
assert type(keyset) == dict
assert type(keyset["keysets"]) == list
assert len(keyset["keysets"]) > 0
assert keyset["keysets"][-1] == wallet1.keyset_id
@pytest.mark.asyncio
async def test_mint(wallet1: Wallet):
await wallet1.mint(64)
assert wallet1.balance == 64
wallet1.status()
# Mint an odd amount (not in 2^n)
proofs += await wallet1.mint(63)
assert wallet1.balance == 64 + 63
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(wallet1.proofs, 65)
assert wallet1.balance == 63 + 64
wallet1.status()
@pytest.mark.asyncio
async def test_split(wallet1: Wallet):
await wallet1.mint(64)
p1, p2 = await wallet1.split(wallet1.proofs, 20)
assert wallet1.balance == 64
assert sum_proofs(p1) == 44
assert [p.amount for p in p1] == [4, 8, 32]
assert sum_proofs(p2) == 20
assert [p.amount for p in p2] == [4, 16]
assert all([p.id == wallet1.keyset_id for p in p1])
assert all([p.id == wallet1.keyset_id for p in p2])
# Error: We try to double-spend by providing a valid proof twice
await assert_err(
wallet1.split(wallet1.proofs + proofs, 20),
f"Mint Error: tokens already spent. Secret: {proofs[0]['secret']}",
@pytest.mark.asyncio
async def test_split_to_send(wallet1: Wallet):
await wallet1.mint(64)
keep_proofs, spendable_proofs = await wallet1.split_to_send(
wallet1.proofs, 32, set_reserved=True
)
assert wallet1.balance == 63 + 64
wallet1.status()
get_spendable = await wallet1._get_spendable_proofs(wallet1.proofs)
assert keep_proofs == get_spendable
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(wallet1.proofs, 20)
# we expect 44 and 20 -> [4, 8, 32], [4, 16]
print(w1_frst_proofs)
print(w1_scnd_proofs)
# assert [p["amount"] for p in w1_frst_proofs] == [4, 8, 32]
assert [p["amount"] for p in w1_scnd_proofs] == [4, 16]
assert wallet1.balance == 63 + 64
wallet1.status()
assert sum_proofs(spendable_proofs) == 32
assert wallet1.balance == 64
assert wallet1.available_balance == 32
# Error: We try to double-spend and it fails
@pytest.mark.asyncio
async def test_split_more_than_balance(wallet1: Wallet):
await wallet1.mint(64)
await assert_err(
wallet1.split([proofs[0]], 10),
f"Mint Error: tokens already spent. Secret: {proofs[0]['secret']}",
wallet1.split(wallet1.proofs, 128),
"Mint Error: split amount is higher than the total sum.",
)
assert wallet1.balance == 64
@pytest.mark.asyncio
async def test_split_to_send_more_than_balance(wallet1: Wallet):
await wallet1.mint(64)
await assert_err(
wallet1.split_to_send(wallet1.proofs, 128, set_reserved=True),
"balance too low.",
)
assert wallet1.balance == 64
assert wallet1.available_balance == 64
@pytest.mark.asyncio
async def test_double_spend(wallet1: Wallet):
doublespend = await wallet1.mint(64)
await wallet1.split(wallet1.proofs, 20)
await assert_err(
wallet1.split(doublespend, 20),
f"Mint Error: tokens already spent. Secret: {doublespend[0]['secret']}",
)
assert wallet1.balance == 64
assert wallet1.available_balance == 64
@pytest.mark.asyncio
async def test_duplicate_proofs_double_spent(wallet1: Wallet):
doublespend = await wallet1.mint(64)
await assert_err(
wallet1.split(wallet1.proofs + doublespend, 20),
"Mint Error: proofs already pending.",
)
assert wallet1.balance == 64
assert wallet1.available_balance == 64
@pytest.mark.asyncio
async def test_send_and_redeem(wallet1: Wallet, wallet2: Wallet):
await wallet1.mint(64)
_, spendable_proofs = await wallet1.split_to_send(
wallet1.proofs, 32, set_reserved=True
)
await wallet2.redeem(spendable_proofs)
assert wallet2.balance == 32
assert wallet1.balance == 64
assert wallet1.available_balance == 32
await wallet1.invalidate(spendable_proofs)
assert wallet1.balance == 32
assert wallet1.available_balance == 32
@pytest.mark.asyncio
async def test_split_invalid_amount(wallet1: Wallet):
await wallet1.mint(64)
await assert_err(
wallet1.split(wallet1.proofs, -1),
"Mint Error: invalid split amount: -1",
)
assert wallet1.balance == 63 + 64
wallet1.status()
# Redeem the tokens in wallet2
w2_frst_proofs, w2_scnd_proofs = await wallet2.redeem(w1_scnd_proofs)
print(w2_frst_proofs)
print(w2_scnd_proofs)
assert wallet1.balance == 63 + 64
assert wallet2.balance == 20
wallet2.status()
# wallet1 invalidates his proofs
await wallet1.invalidate(w1_scnd_proofs)
assert wallet1.balance == 63 + 64 - 20
wallet1.status()
w1_frst_proofs2, w1_scnd_proofs2 = await wallet1.split(w1_frst_proofs, 5)
# we expect 15 and 5 -> [1, 2, 4, 8], [1, 4]
print(w1_frst_proofs2)
print(w1_scnd_proofs2)
assert wallet1.balance == 63 + 64 - 20
wallet1.status()
# Error: We try to double-spend and it fails
await assert_err(
wallet1.split(w1_scnd_proofs, 5),
f"Mint Error: tokens already spent. Secret: {w1_scnd_proofs[0]['secret']}",
)
assert wallet1.balance == 63 + 64 - 20
wallet1.status()
assert wallet1.proof_amounts() == [1, 2, 4, 4, 32, 64]
assert wallet2.proof_amounts() == [4, 16]
# manipulate the proof amount
# w1_frst_proofs2_manipulated = w1_frst_proofs2.copy()
# w1_frst_proofs2_manipulated[0]["amount"] = 123
# await assert_err(
# wallet1.split(w1_frst_proofs2_manipulated, 20),
# "Error: 123",
# )
# try to split an invalid amount
await assert_err(
wallet1.split(w1_scnd_proofs, -500),
"Mint Error: invalid split amount: -500",
)
# mint with secrets
@pytest.mark.asyncio
async def test_split_with_secret(wallet1: Wallet):
await wallet1.mint(64)
secret = f"asdasd_{time.time()}"
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(
wallet1.proofs, 65, scnd_secret=secret
wallet1.proofs, 32, scnd_secret=secret
)
# check if index prefix is in secret
assert w1_scnd_proofs[0].secret == "0:" + secret
# p2sh test
p2shscript = await wallet2.create_p2sh_lock()
txin_p2sh_address = p2shscript.address
lock = f"P2SH:{txin_p2sh_address}"
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock)
_, _ = await wallet2.redeem(
send_proofs, scnd_script=p2shscript.script, scnd_siganture=p2shscript.signature
)
@pytest.mark.asyncio
async def test_redeem_without_secret(wallet1: Wallet):
await wallet1.mint(64)
# strip away the secrets
w1_scnd_proofs_manipulated = w1_scnd_proofs.copy()
w1_scnd_proofs_manipulated = wallet1.proofs.copy()
for p in w1_scnd_proofs_manipulated:
p.secret = ""
await assert_err(
wallet2.redeem(w1_scnd_proofs_manipulated),
wallet1.redeem(w1_scnd_proofs_manipulated),
"Mint Error: no secret in proof.",
)
if __name__ == "__main__":
async_unwrap(run_test())
@pytest.mark.asyncio
async def no_test_p2sh(wallet1: Wallet, wallet2: Wallet):
await wallet1.mint(64)
# p2sh test
p2shscript = await wallet1.create_p2sh_lock()
txin_p2sh_address = p2shscript.address
lock = f"P2SH:{txin_p2sh_address}"
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock)
assert send_proofs[0].secret.startswith("P2SH:")
frst_proofs, scnd_proofs = await wallet2.redeem(
send_proofs, scnd_script=p2shscript.script, scnd_siganture=p2shscript.signature
)
assert len(frst_proofs) == 0
assert len(scnd_proofs) == 1
assert sum_proofs(scnd_proofs) == 8
assert wallet2.balance == 8
def test():
async_unwrap(run_test())
@pytest.mark.asyncio
async def test_p2sh_receive_wrong_script(wallet1: Wallet, wallet2: Wallet):
await wallet1.mint(64)
# p2sh test
p2shscript = await wallet1.create_p2sh_lock()
txin_p2sh_address = p2shscript.address
lock = f"P2SH:{txin_p2sh_address}"
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock)
wrong_script = "asad" + p2shscript.script
await assert_err(
wallet2.redeem(
send_proofs, scnd_script=wrong_script, scnd_siganture=p2shscript.signature
),
"Mint Error: ('Script verification failed:', VerifyScriptError('scriptPubKey returned false'))",
)
assert wallet2.balance == 0
@pytest.mark.asyncio
async def test_p2sh_receive_wrong_signature(wallet1: Wallet, wallet2: Wallet):
await wallet1.mint(64)
# p2sh test
p2shscript = await wallet1.create_p2sh_lock()
txin_p2sh_address = p2shscript.address
lock = f"P2SH:{txin_p2sh_address}"
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock)
wrong_signature = "asda" + p2shscript.signature
await assert_err(
wallet2.redeem(
send_proofs, scnd_script=p2shscript.script, scnd_siganture=wrong_signature
),
"Mint Error: ('Script evaluation failed:', EvalScriptError('EvalScript: OP_RETURN called'))",
)
assert wallet2.balance == 0