Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into dlc
Browse files Browse the repository at this point in the history
  • Loading branch information
lollerfirst committed Jul 18, 2024
2 parents 41ee5e1 + 040ee12 commit ea56b57
Show file tree
Hide file tree
Showing 13 changed files with 103 additions and 58 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Cashu Nutshell

**Cashu Nutshell is a Chaumian Ecash wallet and mint for Bitcoin Lightning. Cashu Nutshell is the reference implementation in Python.**
**Nutshell is a Chaumian Ecash wallet and mint for Bitcoin Lightning based on the Cashu protocol.**

<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/cashubtc/nutshell"><img alt="Coverage" src="https://img.shields.io/codecov/c/gh/cashubtc/nutshell"></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.*

Cashu is an Ecash implementation based on David Wagner's variant of Chaumian blinding ([protocol specs](https://github.com/cashubtc/nuts)). Token logic based on [minicash](https://github.com/phyro/minicash) ([description](https://gist.github.com/phyro/935badc682057f418842c72961cf096c)) which implements a [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406).
Cashu is a free and open-source [Ecash protocol](https://github.com/cashubtc/nuts) based on David Wagner's variant of Chaumian blinding called [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406).

<p align="center">
<a href="#the-cashu-protocol">Cashu protocol</a> ·
Expand All @@ -18,21 +18,22 @@ 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 of Nutshell
### Feature overview

- Bitcoin Lightning support
- Standalone Cashu CLI wallet and mint server
- Wallet and mint library to include in Python projects
- Bitcoin Lightning support (LND, CLN, et al.)
- Full support for the Cashu protocol [specifications](https://github.com/cashubtc/nuts)
- Standalone CLI wallet and mint server
- Wallet and mint library you can include in other Python projects
- PostgreSQL and SQLite
- Wallet with builtin Tor
- Use multiple mints in one wallet
- Send and receive tokens on nostr
- Use multiple mints in a single wallet

### Advanced features
- Deterministic wallet with seed phrase backup
- Programmable ecash with, e.g., Pay-to-Pubkey support
- Programmable ecash: P2PK and HTLCs
- Wallet and mint support for keyset rotations
- DLEQ proofs for offline transactions
- Send and receive tokens on nostr

## The Cashu protocol
Different Cashu clients and mints use the same protocol to achieve interoperability. See the [documentation page](https://docs.cashu.space/) for more information on other projects. If you are interested in developing on your own Cashu project, please refer to the protocol specs [protocol specs](https://github.com/cashubtc/nuts).
Expand Down Expand Up @@ -197,3 +198,8 @@ You can run the tests with
```bash
poetry run pytest tests
```


# Contributing

Developers are invited to contribute to Nutshell. Please see the [contribution guide](CONTRIBUTING.md).
17 changes: 16 additions & 1 deletion cashu/core/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any, Dict, List, Optional, Union

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, root_validator

from .base import (
BlindedMessage,
Expand Down Expand Up @@ -48,6 +48,21 @@ class GetInfoResponse(BaseModel):
def supports(self, nut: int) -> Optional[bool]:
return nut in self.nuts if self.nuts else None

# BEGIN DEPRECATED: NUT-06 contact field change
# NUT-06 PR: https://github.com/cashubtc/nuts/pull/117
@root_validator(pre=True)
def preprocess_deprecated_contact_field(cls, values):
if "contact" in values and values["contact"]:
if isinstance(values["contact"][0], list):
values["contact"] = [
MintInfoContact(method=method, info=info)
for method, info in values["contact"]
if method and info
]
return values

# END DEPRECATED: NUT-06 contact field change


class Nut15MppSupport(BaseModel):
method: str
Expand Down
2 changes: 1 addition & 1 deletion cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class MintInformation(CashuSettings):
mint_info_name: str = Field(default="Cashu mint")
mint_info_description: str = Field(default=None)
mint_info_description_long: str = Field(default=None)
mint_info_contact: List[List[str]] = Field(default=[["", ""]])
mint_info_contact: List[List[str]] = Field(default=[])
mint_info_motd: str = Field(default=None)


Expand Down
17 changes: 17 additions & 0 deletions cashu/mint/db/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from ...core.base import Proof, ProofSpentState, ProofState
from ...core.db import Connection, Database
from ...core.errors import TokenAlreadySpentError
from ..crud import LedgerCrud


Expand Down Expand Up @@ -74,3 +75,19 @@ async def get_proofs_states(
)
)
return states

async def _verify_proofs_spendable(
self, proofs: List[Proof], conn: Optional[Connection] = None
):
"""Checks the database to see if any of the proofs are already spent.
Args:
proofs (List[Proof]): Proofs to verify
conn (Optional[Connection]): Database connection to use. Defaults to None.
Raises:
TokenAlreadySpentError: If any of the proofs are already spent
"""
async with self.db.get_connection(conn) as conn:
if not len(await self._get_proofs_spent([p.Y for p in proofs], conn)) == 0:
raise TokenAlreadySpentError()
33 changes: 19 additions & 14 deletions cashu/mint/db/write.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import random
from typing import List, Optional, Union

from loguru import logger
Expand All @@ -18,54 +17,60 @@
)
from ..crud import LedgerCrud
from ..events.events import LedgerEventManager
from .read import DbReadHelper


class DbWriteHelper:
db: Database
crud: LedgerCrud
events: LedgerEventManager
db_read: DbReadHelper

def __init__(
self, db: Database, crud: LedgerCrud, events: LedgerEventManager
self,
db: Database,
crud: LedgerCrud,
events: LedgerEventManager,
db_read: DbReadHelper,
) -> None:
self.db = db
self.crud = crud
self.events = events
self.db_read = db_read

async def _set_proofs_pending(
async def _verify_spent_proofs_and_set_pending(
self, proofs: List[Proof], quote_id: Optional[str] = None
) -> None:
"""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.
"""
Method to check if proofs are already spent. If they are not spent, we check if they are pending.
If they are not pending, we set them as pending.
Args:
proofs (List[Proof]): Proofs to add to pending table.
quote_id (Optional[str]): Melt quote ID. If it is not set, we assume the pending tokens to be from a swap.
Raises:
Exception: At least one proof already in pending table.
TransactionError: If any one of the proofs is already spent or pending.
"""
# first we check whether these proofs are pending already
random_id = random.randint(0, 1000000)
try:
logger.debug("trying to set proofs pending")
logger.trace(f"get_connection: random_id: {random_id}")
logger.trace("_verify_spent_proofs_and_set_pending acquiring lock")
async with self.db.get_connection(
lock_table="proofs_pending",
lock_timeout=1,
) as conn:
logger.trace(f"get_connection: got connection {random_id}")
logger.trace("checking whether proofs are already spent")
await self.db_read._verify_proofs_spendable(proofs, conn)
logger.trace("checking whether proofs are already pending")
await self._validate_proofs_pending(proofs, conn)
for p in proofs:
logger.trace(f"crud: setting proof {p.Y} as PENDING")
await self.crud.set_proof_pending(
proof=p, db=self.db, quote_id=quote_id, conn=conn
)
logger.trace(f"crud: set proof {p.Y} as PENDING")
logger.trace("_verify_spent_proofs_and_set_pending released lock")
except Exception as e:
logger.error(f"Failed to set proofs pending: {e}")
raise TransactionError(f"Failed to set proofs pending: {str(e)}")
logger.trace("_set_proofs_pending released lock")
raise e
for p in proofs:
await self.events.submit(ProofState(Y=p.Y, state=ProofSpentState.pending))

Expand Down
14 changes: 8 additions & 6 deletions cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,14 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:

# specify which websocket features are supported
# these two are supported by default
websocket_features: List[Dict[str, Union[str, List[str]]]] = []
websocket_features: Dict[str, List[Dict[str, Union[str, List[str]]]]] = {
"supported": []
}
# we check the backend to see if "bolt11_mint_quote" is supported as well
for method, unit_dict in self.backends.items():
if method == Method["bolt11"]:
for unit in unit_dict.keys():
websocket_features.append(
websocket_features["supported"].append(
{
"method": method.name,
"unit": unit.name,
Expand All @@ -104,11 +106,11 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
)
if unit_dict[unit].supports_incoming_payment_stream:
supported_features: List[str] = list(
websocket_features[-1]["commands"]
websocket_features["supported"][-1]["commands"]
)
websocket_features["supported"][-1]["commands"] = (
supported_features + ["bolt11_mint_quote"]
)
websocket_features[-1]["commands"] = supported_features + [
"bolt11_mint_quote"
]

if websocket_features:
mint_features[WEBSOCKETS_NUT] = websocket_features
Expand Down
8 changes: 5 additions & 3 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def __init__(
self.backends = backends
self.pubkey = derive_pubkey(self.seed)
self.db_read = DbReadHelper(self.db, self.crud)
self.db_write = DbWriteHelper(self.db, self.crud, self.events)
self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read)

# ------- STARTUP -------

Expand Down Expand Up @@ -905,7 +905,9 @@ async def melt(
await self.verify_inputs_and_outputs(proofs=proofs)

# set proofs to pending to avoid race conditions
await self.db_write._set_proofs_pending(proofs, quote_id=melt_quote.quote)
await self.db_write._verify_spent_proofs_and_set_pending(
proofs, quote_id=melt_quote.quote
)
try:
# settle the transaction internally if there is a mint quote with the same payment request
melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs)
Expand Down Expand Up @@ -985,7 +987,7 @@ async def swap(
logger.trace("swap called")
# verify spending inputs, outputs, and spending conditions
await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs)
await self.db_write._set_proofs_pending(proofs)
await self.db_write._verify_spent_proofs_and_set_pending(proofs)
try:
async with self.db.get_connection(lock_table="proofs_pending") as conn:
await self._invalidate_proofs(proofs=proofs, conn=conn)
Expand Down
4 changes: 3 additions & 1 deletion cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ async def info() -> GetInfoResponse:
logger.trace("> GET /v1/info")
mint_features = ledger.mint_features()
contact_info = [
MintInfoContact(method=m, info=i) for m, i in settings.mint_info_contact
MintInfoContact(method=m, info=i)
for m, i in settings.mint_info_contact
if m and i
]
return GetInfoResponse(
name=settings.mint_info_name,
Expand Down
7 changes: 0 additions & 7 deletions cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
NoSecretInProofsError,
NotAllowedError,
SecretTooLongError,
TokenAlreadySpentError,
TransactionError,
TransactionUnitError,
)
Expand Down Expand Up @@ -67,12 +66,6 @@ async def verify_inputs_and_outputs(
# Verify inputs
if not proofs:
raise TransactionError("no proofs provided.")
# Verify proofs are spendable
if (
not len(await self.db_read._get_proofs_spent([p.Y for p in proofs], conn))
== 0
):
raise TokenAlreadySpentError()
# Verify amounts of inputs
if not all([self._verify_amount(p.amount) for p in proofs]):
raise TransactionError("invalid amount.")
Expand Down
5 changes: 3 additions & 2 deletions cashu/wallet/mint_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ def supports_websocket_mint_quote(self, method: Method, unit: Unit) -> bool:
if not self.nuts or not self.supports_nut(WEBSOCKETS_NUT):
return False
websocket_settings = self.nuts[WEBSOCKETS_NUT]
if not websocket_settings:
if not websocket_settings or "supported" not in websocket_settings:
return False
for entry in websocket_settings:
websocket_supported = websocket_settings["supported"]
for entry in websocket_supported:
if entry["method"] == method.name and entry["unit"] == unit.name:
if "bolt11_mint_quote" in entry["commands"]:
return True
Expand Down
26 changes: 14 additions & 12 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ async def get_connection():


@pytest.mark.asyncio
async def test_db_set_proofs_pending_race_condition(wallet: Wallet, ledger: Ledger):
async def test_db_verify_spent_proofs_and_set_pending_race_condition(
wallet: Wallet, ledger: Ledger
):
# fill wallet
invoice = await wallet.request_mint(64)
await pay_if_regtest(invoice.bolt11)
Expand All @@ -193,8 +195,8 @@ async def test_db_set_proofs_pending_race_condition(wallet: Wallet, ledger: Ledg

await assert_err_multiple(
asyncio.gather(
ledger.db_write._set_proofs_pending(wallet.proofs),
ledger.db_write._set_proofs_pending(wallet.proofs),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
),
[
"failed to acquire database lock",
Expand All @@ -204,7 +206,7 @@ async def test_db_set_proofs_pending_race_condition(wallet: Wallet, ledger: Ledg


@pytest.mark.asyncio
async def test_db_set_proofs_pending_delayed_no_race_condition(
async def test_db_verify_spent_proofs_and_set_pending_delayed_no_race_condition(
wallet: Wallet, ledger: Ledger
):
# fill wallet
Expand All @@ -213,21 +215,21 @@ async def test_db_set_proofs_pending_delayed_no_race_condition(
await wallet.mint(64, id=invoice.id)
assert wallet.balance == 64

async def delayed_set_proofs_pending():
async def delayed_verify_spent_proofs_and_set_pending():
await asyncio.sleep(0.1)
await ledger.db_write._set_proofs_pending(wallet.proofs)
await ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs)

await assert_err(
asyncio.gather(
ledger.db_write._set_proofs_pending(wallet.proofs),
delayed_set_proofs_pending(),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
delayed_verify_spent_proofs_and_set_pending(),
),
"proofs are pending",
)


@pytest.mark.asyncio
async def test_db_set_proofs_pending_no_race_condition_different_proofs(
async def test_db_verify_spent_proofs_and_set_pending_no_race_condition_different_proofs(
wallet: Wallet, ledger: Ledger
):
# fill wallet
Expand All @@ -238,8 +240,8 @@ async def test_db_set_proofs_pending_no_race_condition_different_proofs(
assert len(wallet.proofs) == 2

asyncio.gather(
ledger.db_write._set_proofs_pending(wallet.proofs[:1]),
ledger.db_write._set_proofs_pending(wallet.proofs[1:]),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs[:1]),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs[1:]),
)


Expand Down Expand Up @@ -300,6 +302,6 @@ async def test_db_lock_table(wallet: Wallet, ledger: Ledger):
async with ledger.db.connect(lock_table="proofs_pending", lock_timeout=0.1) as conn:
assert isinstance(conn, Connection)
await assert_err(
ledger.db_write._set_proofs_pending(wallet.proofs),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
"failed to acquire database lock",
)
2 changes: 1 addition & 1 deletion tests/test_mint_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def test_mint_proofs_pending(wallet1: Wallet, ledger: Ledger):
[s.state == ProofSpentState.unspent for s in proofs_states_before_split.states]
)

await ledger.db_write._set_proofs_pending(proofs)
await ledger.db_write._verify_spent_proofs_and_set_pending(proofs)

proof_states = await wallet1.check_proof_state(proofs)
assert all([s.state == ProofSpentState.pending for s in proof_states.states])
Expand Down
Loading

0 comments on commit ea56b57

Please sign in to comment.