Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
3197720
models + blink
a1denvalu3 Feb 24, 2025
5bb22cf
clnrest
a1denvalu3 Feb 24, 2025
7249b61
fakewallet
a1denvalu3 Feb 24, 2025
38addb4
lnbits
a1denvalu3 Feb 25, 2025
4a91cf9
lndrest + fixes
a1denvalu3 Feb 25, 2025
470580b
lnd_grpc + some more fixes
a1denvalu3 Feb 25, 2025
7a5a127
fix test
a1denvalu3 Feb 25, 2025
8c7c1eb
errors
a1denvalu3 Feb 28, 2025
823b527
errors+
a1denvalu3 Feb 28, 2025
6a7d800
settings and features
a1denvalu3 Feb 28, 2025
edf55f5
revert blink
a1denvalu3 Feb 28, 2025
7e85e3b
revert lnbits
a1denvalu3 Feb 28, 2025
2c6da13
fix
a1denvalu3 Feb 28, 2025
5b95277
support amountless flags in cln, fakewallet an lnd
a1denvalu3 Feb 28, 2025
c23b9a2
format
a1denvalu3 Feb 28, 2025
fc51f30
fix errors
a1denvalu3 Feb 28, 2025
80ff714
check invoice `amount_msat` against amountless `amount_msat`
a1denvalu3 Feb 28, 2025
31b5f57
fix
a1denvalu3 Feb 28, 2025
da42cf3
fix errors imports
a1denvalu3 Feb 28, 2025
c4cf9aa
format
a1denvalu3 Feb 28, 2025
1f14340
add tests + fix
a1denvalu3 Mar 5, 2025
accfb1e
format
a1denvalu3 Mar 5, 2025
e47c5c6
fix format err
a1denvalu3 Mar 5, 2025
a12ab8f
test info endpoint
a1denvalu3 Mar 5, 2025
c2aec9d
test assert response.status_code == 200
a1denvalu3 Mar 5, 2025
776b0c2
Merge remote-tracking branch 'upstream/main' into amountless-invoices
a1denvalu3 Mar 6, 2025
0cb8e89
new `PaymentQuoteKind` enum
a1denvalu3 Mar 6, 2025
58f888e
`pay_invoice` handle different `PaymentQuoteKind`
a1denvalu3 Mar 6, 2025
c97d70b
fix
a1denvalu3 Mar 6, 2025
db6bc12
add `PaymentQuoteKind` to db with a new migration
a1denvalu3 Mar 6, 2025
c0e550e
wallet
a1denvalu3 Mar 7, 2025
65d4200
fix
a1denvalu3 Mar 7, 2025
f1108df
concise assert
a1denvalu3 Mar 7, 2025
8600ff8
tests + wallet fixes
a1denvalu3 Mar 7, 2025
c9b3f33
fix test
a1denvalu3 Mar 7, 2025
e500e92
fix regtest mpp
a1denvalu3 Mar 7, 2025
d7ebd57
`quote_kind` is regular if no `kind` row is found in the DB
a1denvalu3 Mar 7, 2025
6e4ac03
mypy
a1denvalu3 Mar 7, 2025
83674e1
Merge branch 'main' into amountless-invoices
a1denvalu3 Mar 10, 2025
478e405
skip test if deprecated API
a1denvalu3 Mar 10, 2025
5af3624
fix
a1denvalu3 Mar 10, 2025
bdcafbf
test cheeky behaviour
a1denvalu3 Mar 10, 2025
86239ba
format
a1denvalu3 Mar 10, 2025
61a5afd
fix test
a1denvalu3 Mar 10, 2025
3e24b9f
format
a1denvalu3 Mar 10, 2025
c8ad2ab
fix .gitignore
a1denvalu3 Mar 12, 2025
3785c15
Merge remote-tracking branch 'upstream/main' into amountless-invoices
a1denvalu3 Apr 13, 2025
9d40f74
Merge remote-tracking branch 'upstream/main' into amountless-invoices
a1denvalu3 May 2, 2025
a0995aa
fix `get_payment_quote` behaviour in assigning the kind
a1denvalu3 May 2, 2025
ff58f3f
remove debug
a1denvalu3 May 2, 2025
c4c4934
modify tests wallet
a1denvalu3 May 2, 2025
5ed1d32
format checks
a1denvalu3 May 2, 2025
b5d5993
update tests
a1denvalu3 May 3, 2025
594aaec
fix tests
a1denvalu3 May 3, 2025
f8157ca
modify skip reason for `test_pay_amountless_invoice_without_specified…
a1denvalu3 May 3, 2025
2b31f1e
format checks
a1denvalu3 May 3, 2025
99183ba
skip if deprecated API
a1denvalu3 May 3, 2025
06e7e21
hopefully fix the tests?
a1denvalu3 May 3, 2025
4c3ac71
import `is_cln`
a1denvalu3 May 3, 2025
0985f34
import is_cln
a1denvalu3 May 3, 2025
11a7b84
import `is_lnd`
a1denvalu3 May 3, 2025
818a458
try
a1denvalu3 May 3, 2025
bdfdd19
Merge remote-tracking branch 'upstream/main' into amountless-invoices
a1denvalu3 Jun 3, 2025
a098828
format and mypy fixes
a1denvalu3 Jun 3, 2025
5e4aac8
fixes
a1denvalu3 Jun 3, 2025
7aaaef1
await assert_err
a1denvalu3 Jun 4, 2025
0bb5e09
fix import
a1denvalu3 Jun 4, 2025
46cd7cf
format
a1denvalu3 Jun 4, 2025
69e8437
try joint skip_if decorator
a1denvalu3 Jun 4, 2025
0dac8f4
modify test_pay_amountless_invoice to expect "amountless rejection" e…
a1denvalu3 Jun 4, 2025
af9cb2b
format
a1denvalu3 Jun 4, 2025
92c3a50
exception
a1denvalu3 Jun 4, 2025
9df7bd0
try string conversion
a1denvalu3 Jun 4, 2025
4790876
fix
a1denvalu3 Jun 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,4 @@ tor.pid
/test_data

# MacOS
.DS_Store
.DS_Store
13 changes: 13 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,17 @@ class MeltQuoteState(Enum):
def __str__(self):
return self.name

class PaymentQuoteKind(Enum):
# Regular payments
REGULAR = "REGULAR"
# Payments for which the request string did not specify an amount
AMOUNTLESS = "AMOUNTLESS"
# Payments for which this Mint is expect to pay only a part of the total amount
# of the request string
PARTIAL = "PARTIAL"

def __str__(self) -> str:
return self.name

class MeltQuote(LedgerEvent):
quote: str
Expand All @@ -298,6 +309,7 @@ class MeltQuote(LedgerEvent):
outputs: Optional[List[BlindedMessage]] = None
change: Optional[List[BlindedSignature]] = None
mint: Optional[str] = None
quote_kind: PaymentQuoteKind = PaymentQuoteKind.REGULAR

@classmethod
def from_row(cls, row: Row):
Expand Down Expand Up @@ -339,6 +351,7 @@ def from_row(cls, row: Row):
change=change,
expiry=expiry,
payment_preimage=payment_preimage,
quote_kind=PaymentQuoteKind(row.get("kind", "REGULAR")), # type: ignore
)

@classmethod
Expand Down
15 changes: 14 additions & 1 deletion cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,25 @@ def __init__(self, detail):
super().__init__(detail, code=self.code)


class TransactionAmountExceedsLimitError(TransactionError):
class TransactionAmountExceedsLimitError(CashuError):
code = 11006

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

class AmountlessInvoiceNotSupportedError(TransactionError):
detail = "Amountless invoice is not supported"
code = 11007

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

class IncorrectRequestAmountError(TransactionError):
detail = "Amount in request does not equal invoice"
code = 11008

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

class TransactionDuplicateInputsError(TransactionError):
detail = "Duplicate inputs provided"
Expand Down
18 changes: 17 additions & 1 deletion cashu/core/mint_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .base import Method, Unit
from .models import MintInfoContact, MintInfoProtectedEndpoint, Nut15MppSupport
from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MPP_NUT, WEBSOCKETS_NUT
from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MELT_NUT, MPP_NUT, WEBSOCKETS_NUT


class MintInfo(BaseModel):
Expand Down Expand Up @@ -48,6 +48,22 @@ def supports_mpp(self, method: str, unit: Unit) -> bool:

return False

def supports_amountless(self, method: str, unit: Unit) -> bool:
if not self.nuts:
return False
nut_5 = self.nuts.get(MELT_NUT)
if not nut_5 or not nut_5.get("methods"):
return False
for entry in nut_5["methods"]:
if (
entry["method"] == method
and entry["unit"] == str(unit)
and entry.get("amountless", None)
):
return True

return False

def supports_websocket_mint_quote(self, method: Method, unit: Unit) -> bool:
if not self.nuts or not self.supports_nut(WEBSOCKETS_NUT):
return False
Expand Down
13 changes: 12 additions & 1 deletion cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class MeltMethodSetting(BaseModel):
unit: str
min_amount: Optional[int] = None
max_amount: Optional[int] = None
amountless: Optional[bool] = None


class MintInfoContact(BaseModel):
Expand Down Expand Up @@ -199,9 +200,12 @@ class PostMintResponse_deprecated(BaseModel):
class PostMeltRequestOptionMpp(BaseModel):
amount: int = Field(gt=0) # input amount

class PostMeltRequestOptionAmountless(BaseModel):
amount_msat: int = Field(gt=0) # amount to pay to the amountless request

class PostMeltRequestOptions(BaseModel):
mpp: Optional[PostMeltRequestOptionMpp]
mpp: Optional[PostMeltRequestOptionMpp] = None
amountless: Optional[PostMeltRequestOptionAmountless] = None


class PostMeltQuoteRequest(BaseModel):
Expand All @@ -218,6 +222,13 @@ def is_mpp(self) -> bool:
else:
return False

@property
def is_amountless(self) -> bool:
if self.options and self.options.amountless:
return True
else:
return False

@property
def mpp_amount(self) -> int:
if self.is_mpp and self.options and self.options.mpp:
Expand Down
3 changes: 3 additions & 0 deletions cashu/lightning/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ..core.base import (
Amount,
MeltQuote,
PaymentQuoteKind,
Unit,
)
from ..core.models import PostMeltQuoteRequest
Expand All @@ -26,6 +27,7 @@ class PaymentQuoteResponse(BaseModel):
checking_id: str
amount: Amount
fee: Amount
kind: PaymentQuoteKind = PaymentQuoteKind.REGULAR


class InvoiceResponse(BaseModel):
Expand Down Expand Up @@ -110,6 +112,7 @@ def __str__(self) -> str:

class LightningBackend(ABC):
supports_mpp: bool = False
supports_amountless: bool = False
supports_incoming_payment_stream: bool = False
supported_units: set[Unit]
supports_description: bool = False
Expand Down
6 changes: 4 additions & 2 deletions cashu/lightning/blink.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,12 @@ async def get_payment_quote(
raise e

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

amount_msat = int(invoice_obj.amount_msat)
if not invoice_obj.amount_msat:
raise Exception("Invoice with no amount")

amount_msat = int(invoice_obj.amount_msat)

# we take the highest: fee_msat_response, or BLINK_MAX_FEE_PERCENT, or MINIMUM_FEE_MSAT msat
# Note: fees with BLINK_MAX_FEE_PERCENT are rounded to the nearest 1000 msat
fees_amount_msat: int = (
Expand Down
83 changes: 59 additions & 24 deletions cashu/lightning/clnrest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
)
from loguru import logger

from ..core.base import Amount, MeltQuote, Unit
from ..core.base import Amount, MeltQuote, PaymentQuoteKind, Unit
from ..core.errors import IncorrectRequestAmountError
from ..core.helpers import fee_reserve
from ..core.models import PostMeltQuoteRequest
from ..core.settings import settings
Expand Down Expand Up @@ -45,6 +46,7 @@ class CLNRestWallet(LightningBackend):
supported_units = {Unit.sat, Unit.msat}
unit = Unit.sat
supports_mpp = settings.mint_clnrest_enable_mpp
supports_amountless: bool = True
supports_incoming_payment_stream: bool = True
supports_description: bool = True

Expand Down Expand Up @@ -179,13 +181,6 @@ async def pay_invoice(
error_message=str(exc),
)

if not invoice.amount_msat or invoice.amount_msat <= 0:
error_message = "0 amount invoices are not allowed"
return PaymentResponse(
result=PaymentResult.FAILED,
error_message=error_message,
)

quote_amount_msat = Amount(Unit[quote.unit], quote.amount).to(Unit.msat).amount
fee_limit_percent = fee_limit_msat / quote_amount_msat * 100
post_data = {
Expand All @@ -195,18 +190,40 @@ async def pay_invoice(
# with fee < 5000 millisatoshi (which is default value of exemptfee)
}

# Handle Multi-Mint payout where we must only pay part of the invoice amount
logger.trace(f"{quote_amount_msat = }, {invoice.amount_msat = }")
if quote_amount_msat != invoice.amount_msat:
logger.trace("Detected Multi-Nut payment")
if self.supports_mpp:
post_data["partial_msat"] = quote_amount_msat
else:
error_message = "mint does not support MPP"
logger.error(error_message)
return PaymentResponse(
result=PaymentResult.FAILED, error_message=error_message
)

# Handle AMOUNTLESS, PARTIAL and REGULAR payment quotes
match quote.quote_kind:
case PaymentQuoteKind.REGULAR:
logger.debug("Paying REGULAR quote")
if not invoice.amount_msat:
error_message = "amountless invoices are not allowed"
return PaymentResponse(
result=PaymentResult.FAILED,
error_message=error_message,
)
case PaymentQuoteKind.AMOUNTLESS:
logger.debug("Paying AMOUNTLESS quote")
if self.supports_amountless:
post_data["amount_msat"] = quote_amount_msat
else:
error_message = "mint does not support amountless invoices"
logger.error(error_message)
return PaymentResponse(
result=PaymentResult.FAILED, error_message=error_message
)
case PaymentQuoteKind.PARTIAL:
logger.debug("Paying PARTIAL/MPP quote")
# Handle Multi-Mint payout where we must only pay part of the invoice amount
if self.supports_mpp:
post_data["partial_msat"] = quote_amount_msat
else:
error_message = "mint does not support MPP"
logger.error(error_message)
return PaymentResponse(
result=PaymentResult.FAILED, error_message=error_message
)

r = await self.client.post("/v1/pay", data=post_data, timeout=None)

if r.is_error or "message" in r.json():
Expand Down Expand Up @@ -341,17 +358,35 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:

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 = (
melt_quote.mpp_amount if melt_quote.is_mpp else (invoice_obj.amount_msat)
)

kind = PaymentQuoteKind.REGULAR
# Detect and handle amountless/partial/normal request
amount_msat = 0
if melt_quote.is_amountless:
# Check that the user isn't doing something cheeky
if invoice_obj.amount_msat:
raise IncorrectRequestAmountError()
amount_msat = melt_quote.options.amountless.amount_msat # type: ignore
kind = PaymentQuoteKind.AMOUNTLESS
elif melt_quote.is_mpp:
# Check that the user isn't doing something cheeky
if not invoice_obj.amount_msat:
raise IncorrectRequestAmountError()
amount_msat = melt_quote.options.mpp.amount # type: ignore
kind = PaymentQuoteKind.PARTIAL
else:
if not invoice_obj.amount_msat:
raise Exception("request has no amount and is not specified as amountless")
amount_msat = int(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)
return PaymentQuoteResponse(
checking_id=invoice_obj.payment_hash,
fee=fees.to(self.unit, round="up"),
amount=amount.to(self.unit, round="up"),
kind=kind
)
34 changes: 30 additions & 4 deletions cashu/lightning/fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
encode,
)

from ..core.base import Amount, MeltQuote, Unit
from ..core.base import Amount, MeltQuote, PaymentQuoteKind, Unit
from ..core.errors import (
IncorrectRequestAmountError,
)
from ..core.helpers import fee_reserve
from ..core.models import PostMeltQuoteRequest
from ..core.settings import settings
Expand Down Expand Up @@ -59,6 +62,7 @@ class FakeWallet(LightningBackend):

supports_incoming_payment_stream: bool = True
supports_description: bool = True
supports_amountless: bool = True

def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)
Expand Down Expand Up @@ -195,6 +199,7 @@ async def pay_invoice(self, quote: MeltQuote, fee_limit: int) -> PaymentResponse
await asyncio.sleep(settings.fakewallet_delay_outgoing_payment)

if settings.fakewallet_pay_invoice_state:
print(settings.fakewallet_pay_invoice_state)
if settings.fakewallet_pay_invoice_state == "SETTLED":
self.update_balance(invoice, incoming=False)
return PaymentResponse(
Expand Down Expand Up @@ -250,15 +255,35 @@ async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount."

if self.unit == Unit.sat or self.unit == Unit.msat:
# Payment quote is determined and saved in the response
kind = PaymentQuoteKind.REGULAR

# Detect and handle amountless/partial/normal request
amount_msat = 0
if melt_quote.is_amountless:
# Check that the user isn't doing something cheeky
if invoice_obj.amount_msat:
raise IncorrectRequestAmountError()
amount_msat = melt_quote.options.amountless.amount_msat # type: ignore
kind = PaymentQuoteKind.AMOUNTLESS
elif melt_quote.is_mpp:
# Check that the user isn't doing something cheeky
if not invoice_obj.amount_msat:
raise IncorrectRequestAmountError()
amount_msat = melt_quote.options.mpp.amount # type: ignore
kind = PaymentQuoteKind.PARTIAL
else:
if not invoice_obj.amount_msat:
raise Exception("request has no amount and is not specified as amountless")
amount_msat = int(invoice_obj.amount_msat)

if self.unit == Unit.sat or self.unit == Unit.msat:
fees_msat = fee_reserve(amount_msat)
fees = Amount(unit=Unit.msat, amount=fees_msat)
amount = Amount(unit=Unit.msat, amount=amount_msat)
elif self.unit == Unit.usd or self.unit == Unit.eur:
amount_usd = math.ceil(invoice_obj.amount_msat / 1e9 * self.fake_btc_price)
amount_usd = math.ceil(amount_msat / 1e9 * self.fake_btc_price)
amount = Amount(unit=self.unit, amount=amount_usd)
fees = Amount(unit=self.unit, amount=2)
else:
Expand All @@ -268,6 +293,7 @@ async def get_payment_quote(
checking_id=invoice_obj.payment_hash,
fee=fees.to(self.unit, round="up"),
amount=amount.to(self.unit, round="up"),
kind=kind,
)

async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
Expand Down
6 changes: 5 additions & 1 deletion cashu/lightning/lnbits.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,12 @@ async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount."

if not invoice_obj.amount_msat:
raise Exception("request has no amount")

amount_msat = int(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
Loading
Loading