Nostr-p2nip5 (#110)

* move cli

* set_requests decorator

* fix wrapper

* refactor nostr.py

* ignore coroutine unpack error

* nostr lib 0.8

* make format
This commit is contained in:
calle
2023-02-25 12:27:33 +01:00
committed by GitHub
parent 5ec2c3604e
commit 46eeb2c36e
11 changed files with 175 additions and 127 deletions

View File

@@ -115,7 +115,7 @@ cashu info
Returns: Returns:
```bash ```bash
Version: 0.9.2 Version: 0.9.3
Debug: False Debug: False
Cashu dir: /home/user/.cashu Cashu dir: /home/user/.cashu
Wallet: wallet Wallet: wallet

View File

@@ -66,4 +66,4 @@ NOSTR_RELAYS = env.list(
) )
MAX_ORDER = 64 MAX_ORDER = 64
VERSION = "0.9.2" VERSION = "0.9.3"

View File

@@ -1,3 +1,3 @@
from .cli import cli from .cli.cli import cli
cli() cli()

View File

@@ -5,7 +5,6 @@ import base64
import json import json
import os import os
import sys import sys
import threading
import time import time
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
@@ -39,12 +38,9 @@ from cashu.nostr.nostr.client.client import NostrClient
from cashu.tor.tor import TorProxy from cashu.tor.tor import TorProxy
from cashu.wallet import migrations from cashu.wallet import migrations
from cashu.wallet.crud import ( from cashu.wallet.crud import (
get_keyset,
get_lightning_invoices, get_lightning_invoices,
get_nostr_last_check_timestamp,
get_reserved_proofs, get_reserved_proofs,
get_unused_locks, get_unused_locks,
set_nostr_last_check_timestamp,
) )
from cashu.wallet.wallet import Wallet as Wallet from cashu.wallet.wallet import Wallet as Wallet
@@ -52,12 +48,11 @@ from .cli_helpers import (
get_mint_wallet, get_mint_wallet,
print_mint_balances, print_mint_balances,
proofs_to_serialized_tokenv2, proofs_to_serialized_tokenv2,
receive_nostr,
redeem_multimint, redeem_multimint,
send_nostr,
token_from_lnbits_link, token_from_lnbits_link,
verify_mints, verify_mints,
) )
from .nostr import receive_nostr, send_nostr
async def init_wallet(wallet: Wallet): async def init_wallet(wallet: Wallet):
@@ -144,7 +139,7 @@ async def pay(ctx: Context, invoice: str, yes: bool):
if wallet.available_balance < amount: if wallet.available_balance < amount:
print("Error: Balance too low.") print("Error: Balance too low.")
return 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) await wallet.pay_lightning(send_proofs, invoice)
wallet.status() wallet.status()

View File

@@ -1,7 +1,4 @@
import asyncio
import os import os
import threading
import time
import urllib.parse import urllib.parse
from typing import List from typing import List
@@ -12,14 +9,7 @@ from loguru import logger
from cashu.core.base import Proof, TokenV2, TokenV2Mint, WalletKeyset from cashu.core.base import Proof, TokenV2, TokenV2Mint, WalletKeyset
from cashu.core.helpers import sum_proofs from cashu.core.helpers import sum_proofs
from cashu.core.settings import CASHU_DIR, MINT_URL, NOSTR_PRIVATE_KEY, NOSTR_RELAYS from cashu.core.settings import CASHU_DIR, MINT_URL, NOSTR_PRIVATE_KEY, NOSTR_RELAYS
from cashu.nostr.nostr.client.client import NostrClient from cashu.wallet.crud import get_keyset
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.wallet import Wallet as Wallet 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.mints.append(TokenV2Mint(url=url, ids=keysets))
token_serialized = await wallet._serialize_token_base64(token) token_serialized = await wallet._serialize_token_base64(token)
return token_serialized 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()

125
cashu/wallet/cli/nostr.py Normal file
View File

@@ -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()

View File

@@ -59,20 +59,17 @@ from cashu.wallet.crud import (
) )
class LedgerAPI: def async_set_requests(func):
keys: Dict[int, PublicKey] """
keyset: str Decorator that wraps around any async class method of LedgerAPI that makes
tor: TorProxy API calls. Sets some HTTP headers and starts a Tor instance if none is
db: Database already running and and sets local proxy to use it.
"""
def __init__(self, url): async def wrapper(self, *args, **kwargs):
self.url = url self.s.headers.update({"Client-version": VERSION})
def _set_requests(self):
s = requests.Session()
s.headers.update({"Client-version": VERSION})
if DEBUG: if DEBUG:
s.verify = False self.s.verify = False
socks_host, socks_port = None, None socks_host, socks_port = None, None
if TOR and TorProxy().check_platform(): if TOR and TorProxy().check_platform():
self.tor = TorProxy(timeout=True) self.tor = TorProxy(timeout=True)
@@ -86,9 +83,28 @@ class LedgerAPI:
"http": f"socks5://{socks_host}:{socks_port}", "http": f"socks5://{socks_host}:{socks_port}",
"https": f"socks5://{socks_host}:{socks_port}", "https": f"socks5://{socks_host}:{socks_port}",
} }
s.proxies.update(proxies) self.s.proxies.update(proxies)
s.headers.update({"User-Agent": scrts.token_urlsafe(8)}) self.s.headers.update({"User-Agent": scrts.token_urlsafe(8)})
return s 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( def _construct_proofs(
self, promises: List[BlindedSignature], secrets: List[str], rs: List[str] self, promises: List[BlindedSignature], secrets: List[str], rs: List[str]
@@ -193,8 +209,8 @@ class LedgerAPI:
ENDPOINTS ENDPOINTS
""" """
@async_set_requests
async def _get_keys(self, url: str): async def _get_keys(self, url: str):
self.s = self._set_requests()
resp = self.s.get( resp = self.s.get(
url + "/keys", url + "/keys",
) )
@@ -208,11 +224,11 @@ class LedgerAPI:
keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url) keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url)
return keyset return keyset
@async_set_requests
async def _get_keyset(self, url: str, keyset_id: str): async def _get_keyset(self, url: str, keyset_id: str):
""" """
keyset_id is base64, needs to be urlsafe-encoded. keyset_id is base64, needs to be urlsafe-encoded.
""" """
self.s = self._set_requests()
keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_") keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_")
resp = self.s.get( resp = self.s.get(
url + f"/keys/{keyset_id_urlsafe}", url + f"/keys/{keyset_id_urlsafe}",
@@ -227,8 +243,8 @@ class LedgerAPI:
keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url) keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url)
return keyset return keyset
@async_set_requests
async def _get_keyset_ids(self, url: str): async def _get_keyset_ids(self, url: str):
self.s = self._set_requests()
resp = self.s.get( resp = self.s.get(
url + "/keysets", url + "/keysets",
) )
@@ -238,9 +254,9 @@ class LedgerAPI:
assert len(keysets.keysets), Exception("did not receive any keysets") assert len(keysets.keysets), Exception("did not receive any keysets")
return keysets.dict() return keysets.dict()
@async_set_requests
def request_mint(self, amount): def request_mint(self, amount):
"""Requests a mint from the server and returns Lightning invoice.""" """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 = self.s.get(self.url + "/mint", params={"amount": amount})
resp.raise_for_status() resp.raise_for_status()
return_dict = resp.json() return_dict = resp.json()
@@ -248,13 +264,13 @@ class LedgerAPI:
mint_response = GetMintResponse.parse_obj(return_dict) mint_response = GetMintResponse.parse_obj(return_dict)
return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash) return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash)
@async_set_requests
async def mint(self, amounts, payment_hash=None): async def mint(self, amounts, payment_hash=None):
"""Mints new coins and returns a proof of promise.""" """Mints new coins and returns a proof of promise."""
secrets = [self._generate_secret() for s in range(len(amounts))] secrets = [self._generate_secret() for s in range(len(amounts))]
await self._check_used_secrets(secrets) await self._check_used_secrets(secrets)
outputs, rs = self._construct_outputs(amounts, secrets) outputs, rs = self._construct_outputs(amounts, secrets)
outputs_payload = PostMintRequest(outputs=outputs) outputs_payload = PostMintRequest(outputs=outputs)
self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/mint", self.url + "/mint",
json=outputs_payload.dict(), json=outputs_payload.dict(),
@@ -271,6 +287,7 @@ class LedgerAPI:
return self._construct_proofs(promises, secrets, rs) return self._construct_proofs(promises, secrets, rs)
@async_set_requests
async def split(self, proofs, amount, scnd_secret: Optional[str] = None): async def split(self, proofs, amount, scnd_secret: Optional[str] = None):
"""Consume proofs and create new promises based on amount split. """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) 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))}, "proofs": {i: proofs_include for i in range(len(proofs))},
} }
self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/split", self.url + "/split",
json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore
@@ -336,6 +352,7 @@ class LedgerAPI:
return frst_proofs, scnd_proofs return frst_proofs, scnd_proofs
@async_set_requests
async def check_spendable(self, proofs: List[Proof]): 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. 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))}, "proofs": {i: {"secret"} for i in range(len(proofs))},
} }
self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/check", self.url + "/check",
json=payload.dict(include=_check_spendable_include_fields(proofs)), # type: ignore json=payload.dict(include=_check_spendable_include_fields(proofs)), # type: ignore
@@ -359,10 +375,10 @@ class LedgerAPI:
spendable = CheckSpendableResponse.parse_obj(return_dict) spendable = CheckSpendableResponse.parse_obj(return_dict)
return spendable return spendable
@async_set_requests
async def check_fees(self, payment_request: str): async def check_fees(self, payment_request: str):
"""Checks whether the Lightning payment is internal.""" """Checks whether the Lightning payment is internal."""
payload = CheckFeesRequest(pr=payment_request) payload = CheckFeesRequest(pr=payment_request)
self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/checkfees", self.url + "/checkfees",
json=payload.dict(), json=payload.dict(),
@@ -372,6 +388,7 @@ class LedgerAPI:
self.raise_on_error(return_dict) self.raise_on_error(return_dict)
return return_dict return return_dict
@async_set_requests
async def pay_lightning(self, proofs: List[Proof], invoice: str): async def pay_lightning(self, proofs: List[Proof], invoice: str):
""" """
Accepts proofs and a lightning invoice to pay in exchange. 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))}, "proofs": {i: proofs_include for i in range(len(proofs))},
} }
self.s = self._set_requests()
resp = self.s.post( resp = self.s.post(
self.url + "/melt", self.url + "/melt",
json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "cashu" name = "cashu"
version = "0.9.2" version = "0.9.3"
description = "Ecash wallet and mint." description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"] authors = ["calle <callebtc@protonmail.com>"]
license = "MIT" license = "MIT"

View File

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

View File

@@ -7,7 +7,7 @@ from click.testing import CliRunner
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.settings import VERSION from cashu.core.settings import VERSION
from cashu.wallet import migrations 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 cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT, mint from tests.conftest import SERVER_ENDPOINT, mint