Skip to content

Commit

Permalink
Merge branch 'main' into fix-error-ln-payment
Browse files Browse the repository at this point in the history
  • Loading branch information
callebtc committed Mar 5, 2025
2 parents 0e615dd + f72a3f2 commit 5c01cf2
Show file tree
Hide file tree
Showing 10 changed files with 72 additions and 44 deletions.
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ Cashu is a free and open-source [Ecash protocol](https://github.com/cashubtc/nut
- Use multiple mints in a single wallet

### Advanced features
- Deterministic wallet with seed phrase backup
- Programmable ecash: P2PK and HTLCs
- Deterministic wallet with seed phrase backup ([NUT-13](https://github.com/cashubtc/nuts/blob/main/13.md))
- Programmable ecash: P2PK and HTLCs ([NUT-10](https://github.com/cashubtc/nuts/blob/main/10.md))
- Wallet and mint support for keyset rotations
- DLEQ proofs for offline transactions
- Send and receive tokens on nostr
- DLEQ proofs for offline transactions ([NUT-12](https://github.com/cashubtc/nuts/blob/main/12.md))
- Send and receive tokens via nostr
- Optional caching using Redis ([NUT-19](https://github.com/cashubtc/nuts/blob/main/19.md))
- Optional authentication using Keycloak ([NUT-21](https://github.com/cashubtc/nuts/blob/main/21.md))

## 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 @@ -86,11 +88,11 @@ curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
echo export PATH=\"$HOME/.local/bin:$PATH\" >> ~/.bashrc
source ~/.bashrc
```
#### Poetry: Install Cashu
#### Poetry: Install Cashu Nutshell
```bash
# install cashu
git clone https://github.com/cashubtc/nutshell.git cashu
cd cashu
# install nutshell
git clone https://github.com/cashubtc/nutshell.git nutshell
cd nutshell
git checkout <latest_tag>
pyenv local 3.10.4
poetry install
Expand Down Expand Up @@ -194,6 +196,14 @@ poetry run mint

For testing, you can use Nutshell without a Lightning backend by setting `MINT_BACKEND_BOLT11_SAT=FakeWallet` in the `.env` file.

### NUT-19 Caching with Redis
To cache HTTP responses, you can either install Redis manually or use the docker compose file in `docker/docker-compose.yaml` to start Redis in a container.

Edit the `.env` file and uncomment the Redis lines:
```
MINT_REDIS_CACHE_ENABLED=TRUE
MINT_REDIS_CACHE_URL=redis://localhost:6379
```

# Running tests
To run the tests in this repository, first install the dev dependencies with
Expand Down
3 changes: 3 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,9 @@ def unit(self) -> str: ...
@abstractmethod
def unit(self, unit: str): ...

@abstractmethod
def serialize_to_dict(self, include_dleq: bool): ...


class TokenV3Token(BaseModel):
mint: Optional[str] = None
Expand Down
10 changes: 3 additions & 7 deletions cashu/lightning/clnrest.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,13 +332,9 @@ async def get_payment_quote(
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount."
assert invoice_obj.amount_msat > 0, "invoice has 0 amount."
amount_msat = invoice_obj.amount_msat
if melt_quote.is_mpp:
amount_msat = (
Amount(Unit[melt_quote.unit], melt_quote.mpp_amount)
.to(Unit.msat)
.amount
)
amount_msat = melt_quote.mpp_amount if melt_quote.is_mpp else (
invoice_obj.amount_msat
)
fees_msat = fee_reserve(amount_msat)
fees = Amount(unit=Unit.msat, amount=fees_msat)
amount = Amount(unit=Unit.msat, amount=amount_msat)
Expand Down
8 changes: 3 additions & 5 deletions cashu/lightning/lnd_grpc/lnd_grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,18 +371,16 @@ async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
# get amount from melt_quote or from bolt11
amount = (
Amount(Unit[melt_quote.unit], melt_quote.mpp_amount)
amount_msat = (
melt_quote.mpp_amount
if melt_quote.is_mpp
else None
)

invoice_obj = bolt11.decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount."

if amount:
amount_msat = amount.to(Unit.msat).amount
else:
if amount_msat is None:
amount_msat = int(invoice_obj.amount_msat)

fees_msat = fee_reserve(amount_msat)
Expand Down
11 changes: 2 additions & 9 deletions cashu/lightning/lndrest.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,19 +394,12 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
# get amount from melt_quote or from bolt11
amount = (
Amount(Unit[melt_quote.unit], melt_quote.mpp_amount)
if melt_quote.is_mpp
else None
)
amount_msat = melt_quote.mpp_amount if melt_quote.is_mpp else None

invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount."

if amount:
amount_msat = amount.to(Unit.msat).amount
else:
if amount_msat is None:
amount_msat = int(invoice_obj.amount_msat)

fees_msat = fee_reserve(amount_msat)
Expand Down
2 changes: 1 addition & 1 deletion cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ def validate_payment_quote(
if not payment_quote.checking_id:
raise Exception("quote has no checking id")
# verify that payment quote amount is as expected
if melt_quote.is_mpp and melt_quote.mpp_amount != payment_quote.amount.amount:
if melt_quote.is_mpp and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount:
raise TransactionError("quote amount not as requested")
# make sure the backend returned the amount with a correct unit
if not payment_quote.amount.unit == unit:
Expand Down
26 changes: 25 additions & 1 deletion cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import getpass
import json
import os
import time
from datetime import datetime, timezone
Expand Down Expand Up @@ -262,7 +263,10 @@ async def pay(
await wallet.load_mint()
await print_balance(ctx)
payment_hash = bolt11.decode(invoice).payment_hash
quote = await wallet.melt_quote(invoice, amount)
if amount:
# we assume `amount` to be in sats
amount_mpp_msat = amount * 1000
quote = await wallet.melt_quote(invoice, amount_mpp_msat)
logger.debug(f"Quote: {quote}")
total_amount = quote.amount + quote.fee_reserve
# estimate ecash fee for the coinselected proofs
Expand Down Expand Up @@ -760,6 +764,26 @@ async def receive_cli(
return
await print_balance(ctx)

@cli.command("decode", help="Decode a cashu token and print in JSON format.")
@click.option(
"--no-dleq", default=False, is_flag=True, help="Do not include DLEQ proofs."
)
@click.option(
"--indent", "-i", default=2, is_flag=False, help="Number of spaces to indent JSON with."
)
@click.argument("token", type=str, default="")
def decode_to_json(token: str, no_dleq: bool, indent: int):
include_dleq = not no_dleq
if token:
token_obj = deserialize_token_from_string(token)
token_json = json.dumps(
token_obj.serialize_to_dict(include_dleq),
default=lambda obj: obj.hex() if isinstance(obj, bytes) else obj,
indent=indent,
)
print(token_json)
else:
print("Error: enter a token")

@cli.command("burn", help="Burn spent tokens.")
@click.argument("token", required=False, type=str)
Expand Down
12 changes: 8 additions & 4 deletions cashu/wallet/v1_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,16 +434,17 @@ def _mintrequest_include_fields(outputs: List[BlindedMessage]):
@async_set_httpx_client
@async_ensure_mint_loaded
async def melt_quote(
self, payment_request: str, unit: Unit, amount: Optional[int] = None
self, payment_request: str, unit: Unit, amount_msat: Optional[int] = None
) -> PostMeltQuoteResponse:
"""Checks whether the Lightning payment is internal."""
invoice_obj = bolt11.decode(payment_request)
assert invoice_obj.amount_msat, "invoice must have amount"

# add mpp amount for partial melts
melt_options = None
if amount:
if amount_msat:
melt_options = PostMeltRequestOptions(
mpp=PostMeltRequestOptionMpp(amount=amount)
mpp=PostMeltRequestOptionMpp(amount=amount_msat)
)

payload = PostMeltQuoteRequest(
Expand All @@ -462,9 +463,12 @@ async def melt_quote(
payment_request
)
quote_id = f"deprecated_{uuid.uuid4()}"
amount_sat = (
amount_msat // 1000 if amount_msat else invoice_obj.amount_msat // 1000
)
return PostMeltQuoteResponse(
quote=quote_id,
amount=amount or invoice_obj.amount_msat // 1000,
amount=amount_sat,
fee_reserve=ret.fee or 0,
paid=False,
state=MeltQuoteState.unpaid.value,
Expand Down
12 changes: 6 additions & 6 deletions cashu/wallet/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,12 +400,12 @@ async def load_mint(self, keyset_id: str = "", force_old_keysets=False) -> None:
Defaults to False.
"""
logger.trace(f"Loading mint {self.url}")
await self.load_mint_keysets(force_old_keysets)
await self.activate_keyset(keyset_id)
try:
await self.load_mint_keysets(force_old_keysets)
await self.activate_keyset(keyset_id)
await self.load_mint_info(reload=True)
except Exception as e:
logger.debug(f"Could not load mint info: {e}")
logger.error(f"Could not load mint info: {e}")
pass

async def load_proofs(self, reload: bool = False, all_keysets=False) -> None:
Expand Down Expand Up @@ -701,14 +701,14 @@ async def split(
return keep_proofs, send_proofs

async def melt_quote(
self, invoice: str, amount: Optional[int] = None
self, invoice: str, amount_msat: Optional[int] = None
) -> PostMeltQuoteResponse:
"""
Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided.
"""
if amount and not self.mint_info.supports_mpp("bolt11", self.unit):
if amount_msat and not self.mint_info.supports_mpp("bolt11", self.unit):
raise Exception("Mint does not support MPP, cannot specify amount.")
melt_quote_resp = await super().melt_quote(invoice, self.unit, amount)
melt_quote_resp = await super().melt_quote(invoice, self.unit, amount_msat)
logger.debug(
f"Mint wants {self.unit.str(melt_quote_resp.fee_reserve)} as fee reserve."
)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_wallet_regtest_mpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):

async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice, amount=amount)
quote = await wallet.melt_quote(invoice, amount_msat=amount*1000)
assert quote.amount == amount
await wallet.melt(
proofs,
Expand Down Expand Up @@ -118,7 +118,7 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger
async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0):
await asyncio.sleep(delay)
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice_payment_request, amount=amount)
quote = await wallet.melt_quote(invoice_payment_request, amount_msat=amount*1000)
assert quote.amount == amount
await wallet.melt(
proofs,
Expand Down Expand Up @@ -154,5 +154,5 @@ async def test_regtest_internal_mpp_melt_quotes(wallet: Wallet, ledger: Ledger):

# try and create a multi-part melt quote
await assert_err(
wallet.melt_quote(mint_quote.request, 100), "internal mpp not allowed"
wallet.melt_quote(mint_quote.request, 100*1000), "internal mpp not allowed"
)

0 comments on commit 5c01cf2

Please sign in to comment.