From 46eeb2c36e61cf8f605f75f0c0b71a2ba276db50 Mon Sep 17 00:00:00 2001 From: calle <93376500+callebtc@users.noreply.github.com> Date: Sat, 25 Feb 2023 12:27:33 +0100 Subject: [PATCH] Nostr-p2nip5 (#110) * move cli * set_requests decorator * fix wrapper * refactor nostr.py * ignore coroutine unpack error * nostr lib 0.8 * make format --- README.md | 2 +- cashu/core/settings.py | 2 +- cashu/nostr | 2 +- cashu/wallet/__main__.py | 2 +- cashu/wallet/{ => cli}/cli.py | 9 +- cashu/wallet/{ => cli}/cli_helpers.py | 90 +------------------ cashu/wallet/cli/nostr.py | 125 ++++++++++++++++++++++++++ cashu/wallet/wallet.py | 64 ++++++++----- pyproject.toml | 2 +- setup.py | 2 +- tests/test_cli.py | 2 +- 11 files changed, 175 insertions(+), 127 deletions(-) rename cashu/wallet/{ => cli}/cli.py (99%) rename cashu/wallet/{ => cli}/cli_helpers.py (72%) create mode 100644 cashu/wallet/cli/nostr.py diff --git a/README.md b/README.md index 3de4f50..7bf0fb7 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ cashu info Returns: ```bash -Version: 0.9.2 +Version: 0.9.3 Debug: False Cashu dir: /home/user/.cashu Wallet: wallet diff --git a/cashu/core/settings.py b/cashu/core/settings.py index e2062a0..f11da43 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -66,4 +66,4 @@ NOSTR_RELAYS = env.list( ) MAX_ORDER = 64 -VERSION = "0.9.2" +VERSION = "0.9.3" diff --git a/cashu/nostr b/cashu/nostr index 2872fe3..59ddc1b 160000 --- a/cashu/nostr +++ b/cashu/nostr @@ -1 +1 @@ -Subproject commit 2872fe3c24ad0036e1bcf262790930d9a1e65d70 +Subproject commit 59ddc1b5e0cccb0038a064683c7ec10839fad76b diff --git a/cashu/wallet/__main__.py b/cashu/wallet/__main__.py index 4cafccb..3ce8c3a 100644 --- a/cashu/wallet/__main__.py +++ b/cashu/wallet/__main__.py @@ -1,3 +1,3 @@ -from .cli import cli +from .cli.cli import cli cli() diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli/cli.py similarity index 99% rename from cashu/wallet/cli.py rename to cashu/wallet/cli/cli.py index edc723f..1eed03c 100644 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli/cli.py @@ -5,7 +5,6 @@ import base64 import json import os import sys -import threading import time from datetime import datetime from functools import wraps @@ -39,12 +38,9 @@ from cashu.nostr.nostr.client.client import NostrClient from cashu.tor.tor import TorProxy from cashu.wallet import migrations from cashu.wallet.crud import ( - get_keyset, get_lightning_invoices, - get_nostr_last_check_timestamp, get_reserved_proofs, get_unused_locks, - set_nostr_last_check_timestamp, ) from cashu.wallet.wallet import Wallet as Wallet @@ -52,12 +48,11 @@ from .cli_helpers import ( get_mint_wallet, print_mint_balances, proofs_to_serialized_tokenv2, - receive_nostr, redeem_multimint, - send_nostr, token_from_lnbits_link, verify_mints, ) +from .nostr import receive_nostr, send_nostr async def init_wallet(wallet: Wallet): @@ -144,7 +139,7 @@ async def pay(ctx: Context, invoice: str, yes: bool): if wallet.available_balance < amount: print("Error: Balance too low.") return - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) # type: ignore await wallet.pay_lightning(send_proofs, invoice) wallet.status() diff --git a/cashu/wallet/cli_helpers.py b/cashu/wallet/cli/cli_helpers.py similarity index 72% rename from cashu/wallet/cli_helpers.py rename to cashu/wallet/cli/cli_helpers.py index 81a6dfb..67075bc 100644 --- a/cashu/wallet/cli_helpers.py +++ b/cashu/wallet/cli/cli_helpers.py @@ -1,7 +1,4 @@ -import asyncio import os -import threading -import time import urllib.parse from typing import List @@ -12,14 +9,7 @@ from loguru import logger from cashu.core.base import Proof, TokenV2, TokenV2Mint, WalletKeyset from cashu.core.helpers import sum_proofs from cashu.core.settings import CASHU_DIR, MINT_URL, NOSTR_PRIVATE_KEY, NOSTR_RELAYS -from cashu.nostr.nostr.client.client import NostrClient -from cashu.nostr.nostr.event import Event -from cashu.nostr.nostr.key import PublicKey -from cashu.wallet.crud import ( - get_keyset, - get_nostr_last_check_timestamp, - set_nostr_last_check_timestamp, -) +from cashu.wallet.crud import get_keyset from cashu.wallet.wallet import Wallet as Wallet @@ -212,81 +202,3 @@ async def proofs_to_serialized_tokenv2(wallet, proofs: List[Proof], url: str): token.mints.append(TokenV2Mint(url=url, ids=keysets)) token_serialized = await wallet._serialize_token_base64(token) return token_serialized - - -async def send_nostr(ctx: Context, amount: int, pubkey: str, verbose: bool, yes: bool): - """ - Sends tokens via nostr. - """ - # load a wallet for the chosen mint - 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.bech32()}") - - if pubkey.startswith("npub"): - pubkey_to = PublicKey().from_npub(pubkey) - else: - pubkey_to = PublicKey(bytes.fromhex(pubkey)) - - client.dm(token, pubkey_to) - print(f"Token sent to {pubkey}") - client.close() - - -async def receive_nostr(ctx: Context, 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(private_key=NOSTR_PRIVATE_KEY, relays=NOSTR_RELAYS) - print(f"Your nostr public key: {client.public_key.bech32()}") - if verbose: - print(f"Your nostr private key (do not share!): {client.private_key.bech32()}") - 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 - from cashu.wallet.cli import receive - - asyncio.run(receive(ctx, decrypted_content, "")) - except Exception as e: - pass - - # determine timestamp of last check so we don't scan all historical DMs - wallet: Wallet = ctx.obj["WALLET"] - last_check = await get_nostr_last_check_timestamp(db=wallet.db) - if last_check: - last_check -= 60 * 60 # 1 hour tolerance - await set_nostr_last_check_timestamp(timestamp=int(time.time()), db=wallet.db) - - t = threading.Thread( - target=client.get_dm, - args=(client.public_key, get_token_callback, {"since": last_check}), - name="Nostr DM", - ) - t.start() diff --git a/cashu/wallet/cli/nostr.py b/cashu/wallet/cli/nostr.py new file mode 100644 index 0000000..5d23854 --- /dev/null +++ b/cashu/wallet/cli/nostr.py @@ -0,0 +1,125 @@ +import asyncio +import threading +import time + +import click +from click import Context +from requests.exceptions import ConnectionError + +from cashu.core.settings import NOSTR_PRIVATE_KEY, NOSTR_RELAYS +from cashu.nostr.nostr.client.client import NostrClient +from cashu.nostr.nostr.event import Event +from cashu.nostr.nostr.key import PublicKey +from cashu.wallet.cli.cli_helpers import get_mint_wallet +from cashu.wallet.crud import ( + get_nostr_last_check_timestamp, + set_nostr_last_check_timestamp, +) +from cashu.wallet.wallet import Wallet + + +async def nip5_to_pubkey(wallet: Wallet, address: str): + """ + Retrieves the nostr public key of a NIP-05 identifier. + """ + # we will be using the requests session from the wallet + await wallet._init_s() + # now we can use it + user, host = address.split("@") + resp_dict = {} + try: + resp = wallet.s.get( + f"https://{host}/.well-known/nostr.json?name={user}", + ) + resp.raise_for_status() + except ConnectionError: + raise Exception(f"Could not connect to {host}") + except Exception as e: + raise e + resp_dict = resp.json() + assert "names" in resp_dict, Exception(f"did not receive any names from {host}") + assert user in resp_dict["names"], Exception(f"{user}@{host} not found") + pubkey = resp_dict["names"][user] + return pubkey + + +async def send_nostr(ctx: Context, amount: int, pubkey: str, verbose: bool, yes: bool): + """ + Sends tokens via nostr. + """ + # load a wallet for the chosen mint + wallet = await get_mint_wallet(ctx) + + if "@" in pubkey: + pubkey = await nip5_to_pubkey(wallet, pubkey) + + await wallet.load_proofs() + _, send_proofs = await wallet.split_to_send( + wallet.proofs, amount, set_reserved=True + ) + token = await wallet.serialize_proofs(send_proofs) + + if pubkey.startswith("npub"): + pubkey_to = PublicKey().from_npub(pubkey) + else: + pubkey_to = PublicKey(bytes.fromhex(pubkey)) + + print("") + print(token) + + if not yes: + print("") + click.confirm( + f"Send {amount} sat to {pubkey_to.bech32()}?", + abort=True, + default=True, + ) + + client = NostrClient(private_key=NOSTR_PRIVATE_KEY, relays=NOSTR_RELAYS) + if verbose and not NOSTR_PRIVATE_KEY: + # we generated a random key if none was present + print(f"Your nostr private key: {client.private_key.bech32()}") + + client.dm(token, pubkey_to) + print(f"Token sent to {pubkey_to.bech32()}") + client.close() + + +async def receive_nostr(ctx: Context, 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(private_key=NOSTR_PRIVATE_KEY, relays=NOSTR_RELAYS) + print(f"Your nostr public key: {client.public_key.bech32()}") + if verbose: + print(f"Your nostr private key (do not share!): {client.private_key.bech32()}") + 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 + from cashu.wallet.cli.cli import receive + + asyncio.run(receive(ctx, decrypted_content, "")) + except Exception as e: + pass + + # determine timestamp of last check so we don't scan all historical DMs + wallet: Wallet = ctx.obj["WALLET"] + last_check = await get_nostr_last_check_timestamp(db=wallet.db) + if last_check: + last_check -= 60 * 60 # 1 hour tolerance + await set_nostr_last_check_timestamp(timestamp=int(time.time()), db=wallet.db) + + t = threading.Thread( + target=client.get_dm, + args=(client.public_key, get_token_callback, {"since": last_check}), + name="Nostr DM", + ) + t.start() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 17ac079..4e97c1a 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -59,20 +59,17 @@ from cashu.wallet.crud import ( ) -class LedgerAPI: - keys: Dict[int, PublicKey] - keyset: str - tor: TorProxy - db: Database +def async_set_requests(func): + """ + Decorator that wraps around any async class method of LedgerAPI that makes + API calls. Sets some HTTP headers and starts a Tor instance if none is + already running and and sets local proxy to use it. + """ - def __init__(self, url): - self.url = url - - def _set_requests(self): - s = requests.Session() - s.headers.update({"Client-version": VERSION}) + async def wrapper(self, *args, **kwargs): + self.s.headers.update({"Client-version": VERSION}) if DEBUG: - s.verify = False + self.s.verify = False socks_host, socks_port = None, None if TOR and TorProxy().check_platform(): self.tor = TorProxy(timeout=True) @@ -86,9 +83,28 @@ class LedgerAPI: "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 + self.s.proxies.update(proxies) + self.s.headers.update({"User-Agent": scrts.token_urlsafe(8)}) + return await func(self, *args, **kwargs) + + return wrapper + + +class LedgerAPI: + keys: Dict[int, PublicKey] + keyset: str + tor: TorProxy + db: Database + s: requests.Session + + def __init__(self, url): + self.url = url + self.s = requests.Session() + + @async_set_requests + async def _init_s(self): + """Dummy function that can be called from outside to use LedgerAPI.s""" + return def _construct_proofs( self, promises: List[BlindedSignature], secrets: List[str], rs: List[str] @@ -193,8 +209,8 @@ class LedgerAPI: ENDPOINTS """ + @async_set_requests async def _get_keys(self, url: str): - self.s = self._set_requests() resp = self.s.get( url + "/keys", ) @@ -208,11 +224,11 @@ class LedgerAPI: keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url) return keyset + @async_set_requests 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}", @@ -227,8 +243,8 @@ class LedgerAPI: keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url) return keyset + @async_set_requests async def _get_keyset_ids(self, url: str): - self.s = self._set_requests() resp = self.s.get( url + "/keysets", ) @@ -238,9 +254,9 @@ class LedgerAPI: assert len(keysets.keysets), Exception("did not receive any keysets") return keysets.dict() + @async_set_requests 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() @@ -248,13 +264,13 @@ class LedgerAPI: mint_response = GetMintResponse.parse_obj(return_dict) return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash) + @async_set_requests 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) outputs, rs = self._construct_outputs(amounts, secrets) outputs_payload = PostMintRequest(outputs=outputs) - self.s = self._set_requests() resp = self.s.post( self.url + "/mint", json=outputs_payload.dict(), @@ -271,6 +287,7 @@ class LedgerAPI: return self._construct_proofs(promises, secrets, rs) + @async_set_requests 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) @@ -315,7 +332,6 @@ class LedgerAPI: "proofs": {i: proofs_include for i in range(len(proofs))}, } - self.s = self._set_requests() resp = self.s.post( self.url + "/split", json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore @@ -336,6 +352,7 @@ class LedgerAPI: return frst_proofs, scnd_proofs + @async_set_requests 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. @@ -348,7 +365,6 @@ class LedgerAPI: "proofs": {i: {"secret"} for i in range(len(proofs))}, } - self.s = self._set_requests() resp = self.s.post( self.url + "/check", json=payload.dict(include=_check_spendable_include_fields(proofs)), # type: ignore @@ -359,10 +375,10 @@ class LedgerAPI: spendable = CheckSpendableResponse.parse_obj(return_dict) return spendable + @async_set_requests async def check_fees(self, payment_request: str): """Checks whether the Lightning payment is internal.""" payload = CheckFeesRequest(pr=payment_request) - self.s = self._set_requests() resp = self.s.post( self.url + "/checkfees", json=payload.dict(), @@ -372,6 +388,7 @@ class LedgerAPI: self.raise_on_error(return_dict) return return_dict + @async_set_requests async def pay_lightning(self, proofs: List[Proof], invoice: str): """ Accepts proofs and a lightning invoice to pay in exchange. @@ -387,7 +404,6 @@ class LedgerAPI: "proofs": {i: proofs_include for i in range(len(proofs))}, } - self.s = self._set_requests() resp = self.s.post( self.url + "/melt", json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore diff --git a/pyproject.toml b/pyproject.toml index 7bd5c58..b9a4651 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.9.2" +version = "0.9.3" description = "Ecash wallet and mint." authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index 92bb73d..edb0b3e 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]} setuptools.setup( name="cashu", - version="0.9.2", + version="0.9.3", description="Ecash wallet and mint for Bitcoin Lightning", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e8a1db..94ab818 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,7 +7,7 @@ from click.testing import CliRunner from cashu.core.migrations import migrate_databases from cashu.core.settings import VERSION from cashu.wallet import migrations -from cashu.wallet.cli import cli +from cashu.wallet.cli.cli import cli from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT, mint