Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main' into keyset-balance-views
Browse files Browse the repository at this point in the history
  • Loading branch information
lollerfirst committed Dec 15, 2024
2 parents 95c1156 + d98d166 commit 84e8ceb
Show file tree
Hide file tree
Showing 52 changed files with 1,864 additions and 769 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ MINT_INFO_DESCRIPTION="The short mint description"
MINT_INFO_DESCRIPTION_LONG="A long mint description that can be a long piece of text."
MINT_INFO_CONTACT=[["email","[email protected]"], ["twitter","@me"], ["nostr", "npub..."]]
MINT_INFO_MOTD="Message to users"
MINT_INFO_ICON_URL="https://mint.host/icon.jpg"
MINT_INFO_URLS=["https://mint.host", "http://mint8gv0sq5ul602uxt2fe0t80e3c2bi9fy0cxedp69v1vat6ruj81wv.onion"]

MINT_PRIVATE_KEY=supersecretprivatekey
# This is used to derive your mint's private keys. Store it securely.
# MINT_PRIVATE_KEY=<openssl rand -hex 32>

# Increment derivation path to rotate to a new keyset
# Example: m/0'/0'/0' -> m/0'/0'/1'
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
jobs:
checks:
uses: ./.github/workflows/checks.yml

tests:
strategy:
fail-fast: false
Expand All @@ -25,6 +26,22 @@ jobs:
poetry-version: ${{ matrix.poetry-version }}
mint-only-deprecated: ${{ matrix.mint-only-deprecated }}
mint-database: ${{ matrix.mint-database }}

tests_redis_cache:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.10"]
poetry-version: ["1.7.1"]
mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"]
uses: ./.github/workflows/tests_redis_cache.yml
with:
os: ${{ matrix.os }}
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
mint-database: ${{ matrix.mint-database }}

regtest:
uses: ./.github/workflows/regtest.yml
strategy:
Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/tests_redis_cache.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: tests_redis_cache

on:
workflow_call:
inputs:
python-version:
default: "3.10.4"
type: string
poetry-version:
default: "1.7.1"
type: string
mint-database:
default: ""
type: string
os:
default: "ubuntu-latest"
type: string
mint-only-deprecated:
default: "false"
type: string

jobs:
poetry:
name: Run (db ${{ inputs.mint-database }}, deprecated api ${{ inputs.mint-only-deprecated }})
runs-on: ${{ inputs.os }}
steps:
- name: Start PostgreSQL service
if: contains(inputs.mint-database, 'postgres')
run: |
docker run -d --name postgres -e POSTGRES_USER=cashu -e POSTGRES_PASSWORD=cashu -e POSTGRES_DB=cashu -p 5432:5432 postgres:latest
until docker exec postgres pg_isready; do sleep 1; done
- name: Checkout repository
uses: actions/checkout@v2

- name: Prepare environment
uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
poetry-version: ${{ inputs.poetry-version }}

- name: Start Redis service
run: |
docker compose -f docker/docker-compose.yml up -d redis
- name: Run tests
env:
MINT_BACKEND_BOLT11_SAT: FakeWallet
WALLET_NAME: test_wallet
MINT_HOST: localhost
MINT_PORT: 3337
MINT_TEST_DATABASE: ${{ inputs.mint-database }}
TOR: false
MINT_REDIS_CACHE_ENABLED: true
MINT_REDIS_CACHE_URL: redis://localhost:6379
run: |
poetry run pytest tests/test_mint_api_cache.py -v --cov=mint --cov-report=xml
- name: Stop and clean up Docker Compose
run: |
docker compose -f docker/docker-compose.yml down
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
6 changes: 5 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ repos:
rev: v1.6.0
hooks:
- id: mypy
args: [--ignore-missing, --check-untyped-defs]
args:
- --ignore-missing
- --check-untyped-defs
additional_dependencies:
- types-redis
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ This command runs the mint on your local computer. Skip this step if you want to
## Docker

```
docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.2 poetry run mint
docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.3 poetry run mint
```

## From this repository
Expand Down
11 changes: 11 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ class MintQuote(LedgerEvent):
paid_time: Union[int, None] = None
expiry: Optional[int] = None
mint: Optional[str] = None
privkey: Optional[str] = None
pubkey: Optional[str] = None

@classmethod
def from_row(cls, row: Row):
Expand All @@ -436,6 +438,8 @@ def from_row(cls, row: Row):
state=MintQuoteState(row["state"]),
created_time=created_time,
paid_time=paid_time,
pubkey=row["pubkey"] if "pubkey" in row.keys() else None,
privkey=row["privkey"] if "privkey" in row.keys() else None,
)

@classmethod
Expand All @@ -458,6 +462,7 @@ def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str):
mint=mint,
expiry=mint_quote_resp.expiry,
created_time=int(time.time()),
pubkey=mint_quote_resp.pubkey,
)

@property
Expand Down Expand Up @@ -732,6 +737,12 @@ def __init__(
input_fee_ppk: Optional[int] = None,
id: str = "",
):
DEFAULT_SEED = "supersecretprivatekey"
if seed == DEFAULT_SEED:
raise Exception(
f"Seed is set to default value '{DEFAULT_SEED}'. Please change it."
)

self.derivation_path = derivation_path

if encrypted_seed and not settings.mint_seed_decryption_key:
Expand Down
7 changes: 2 additions & 5 deletions cashu/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,14 +228,11 @@ def _is_lock_exception(e):
)
else:
logger.error(f"Error in session trial: {trial} ({random_int}): {e}")
raise e
raise
finally:
logger.trace(f"Closing session trial: {trial} ({random_int})")
await session.close()
# if not inherited:
# logger.trace("Closing session")
# await session.close()
# self._connection = None

raise Exception(
f"failed to acquire database lock on {lock_table} after {timeout}s and {trial} trials ({random_int})"
)
Expand Down
16 changes: 16 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,19 @@ class QuoteNotPaidError(CashuError):

def __init__(self):
super().__init__(self.detail, code=2001)


class QuoteSignatureInvalidError(CashuError):
detail = "Signature for mint request invalid"
code = 20008

def __init__(self):
super().__init__(self.detail, code=20008)


class QuoteRequiresPubkeyError(CashuError):
detail = "Pubkey required for mint quote"
code = 20009

def __init__(self):
super().__init__(self.detail, code=20009)
2 changes: 0 additions & 2 deletions cashu/core/htlc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
class SigFlags(Enum):
# require signatures only on the inputs (default signature flag)
SIG_INPUTS = "SIG_INPUTS"
# require signatures on inputs and outputs
SIG_ALL = "SIG_ALL"


class HTLCSecret(Secret):
Expand Down
17 changes: 12 additions & 5 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class GetInfoResponse(BaseModel):
contact: Optional[List[MintInfoContact]] = None
motd: Optional[str] = None
icon_url: Optional[str] = None
urls: Optional[List[str]] = None
time: Optional[int] = None
nuts: Optional[Dict[int, Any]] = None

Expand All @@ -72,7 +73,6 @@ def preprocess_deprecated_contact_field(cls, values):
class Nut15MppSupport(BaseModel):
method: str
unit: str
mpp: bool


class GetInfoResponse_deprecated(BaseModel):
Expand Down Expand Up @@ -128,21 +128,25 @@ class PostMintQuoteRequest(BaseModel):
description: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # invoice description
pubkey: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote lock pubkey


class PostMintQuoteResponse(BaseModel):
quote: str # quote id
request: str # input payment request
paid: Optional[bool] # DEPRECATED as per NUT-04 PR #141
state: Optional[str] # state of the quote
state: Optional[str] # state of the quote (optional for backwards compat)
expiry: Optional[int] # expiry of the quote
pubkey: Optional[str] = None # NUT-20 quote lock pubkey
paid: Optional[bool] = None # DEPRECATED as per NUT-04 PR #141

@classmethod
def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse":
def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse":
to_dict = mint_quote.dict()
# turn state into string
to_dict["state"] = mint_quote.state.value
return PostMintQuoteResponse.parse_obj(to_dict)
return cls.parse_obj(to_dict)


# ------- API: MINT -------
Expand All @@ -153,6 +157,9 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage] = Field(
..., max_items=settings.mint_max_request_length
)
signature: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote signature


class PostMintResponse(BaseModel):
Expand Down
Empty file added cashu/core/nuts/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions cashu/core/nuts/nut20.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from hashlib import sha256
from typing import List

from ..base import BlindedMessage
from ..crypto.secp import PrivateKey, PublicKey


def generate_keypair() -> tuple[str, str]:
privkey = PrivateKey()
assert privkey.pubkey
pubkey = privkey.pubkey
return privkey.serialize(), pubkey.serialize(True).hex()


def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes:
serialized_outputs = b"".join([o.B_.encode("utf-8") for o in outputs])
msgbytes = sha256(quote_id.encode("utf-8") + serialized_outputs).digest()
return msgbytes


def sign_mint_quote(
quote_id: str,
outputs: List[BlindedMessage],
private_key: str,
) -> str:
privkey = PrivateKey(bytes.fromhex(private_key), raw=True)
msgbytes = construct_message(quote_id, outputs)
sig = privkey.schnorr_sign(msgbytes, None, raw=True)
return sig.hex()


def verify_mint_quote(
quote_id: str,
outputs: List[BlindedMessage],
public_key: str,
signature: str,
) -> bool:
pubkey = PublicKey(bytes.fromhex(public_key), raw=True)
msgbytes = construct_message(quote_id, outputs)
sig = bytes.fromhex(signature)
return pubkey.schnorr_verify(msgbytes, sig, None, raw=True)
3 changes: 3 additions & 0 deletions cashu/core/nuts.py → cashu/core/nuts/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
P2PK_NUT = 11
DLEQ_NUT = 12
DETERMINSTIC_SECRETS_NUT = 13
HTLC_NUT = 14
MPP_NUT = 15
WEBSOCKETS_NUT = 17
CACHE_NUT = 19
MINT_QUOTE_SIGNATURE_NUT = 20
52 changes: 1 addition & 51 deletions cashu/core/p2pk.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import hashlib
import time
from enum import Enum
from typing import List, Union

from loguru import logger
from typing import Union

from .crypto.secp import PrivateKey, PublicKey
from .secret import Secret, SecretKind
Expand All @@ -24,34 +21,6 @@ def from_secret(cls, secret: Secret):
# need to add it back in manually with tags=secret.tags
return cls(**secret.dict(exclude={"tags"}), tags=secret.tags)

def get_p2pk_pubkey_from_secret(self) -> List[str]:
"""Gets the P2PK pubkey from a Secret depending on the locktime.
If locktime is passed, only the refund pubkeys are returned.
Else, the pubkeys in the data field and in the 'pubkeys' tag are returned.
Args:
secret (Secret): P2PK Secret in ecash token
Returns:
str: pubkey to use for P2PK, empty string if anyone can spend (locktime passed)
"""
# the pubkey in the data field is the pubkey to use for P2PK
pubkeys: List[str] = [self.data]

# get all additional pubkeys from tags for multisig
pubkeys += self.tags.get_tag_all("pubkeys")

# check if locktime is passed and if so, only return refund pubkeys
now = time.time()
if self.locktime and self.locktime < now:
logger.trace(f"p2pk locktime ran out ({self.locktime}<{now}).")
# check tags if a refund pubkey is present.
# If yes, we demand the signature to be from the refund pubkey
return self.tags.get_tag_all("refund")

return pubkeys

@property
def locktime(self) -> Union[None, int]:
locktime = self.tags.get_tag("locktime")
Expand Down Expand Up @@ -81,22 +50,3 @@ def verify_schnorr_signature(
return pubkey.schnorr_verify(
hashlib.sha256(message).digest(), signature, None, raw=True
)


if __name__ == "__main__":
# generate keys
private_key_bytes = b"12300000000000000000000000000123"
private_key = PrivateKey(private_key_bytes, raw=True)
print(private_key.serialize())
public_key = private_key.pubkey
assert public_key
print(public_key.serialize().hex())

# sign message (=pubkey)
message = public_key.serialize()
signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message))
print(signature.hex())

# verify
pubkey_verify = PublicKey(message, raw=True)
print(public_key.ecdsa_verify(message, pubkey_verify.ecdsa_deserialize(signature)))
Loading

0 comments on commit 84e8ceb

Please sign in to comment.