Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ MINT_BACKEND_BOLT11_SAT=FakeWallet
# MINT_BACKEND_BOLT11_USD=FakeWallet
# MINT_BACKEND_BOLT11_EUR=FakeWallet

# Optional third-party payment methods. Plugins are Python entry points in the
# `cashu.payment_methods` group and must be explicitly allowlisted here.
# MINT_PAYMENT_METHOD_PLUGINS='["examplepay"]'
# MINT_PAYMENT_BACKENDS='[{"method":"examplepay","unit":"sat","api_url":"https://example.test"}]'
# CDK-compatible external processor (mTLS is required by default):
# MINT_PAYMENT_BACKENDS='[{"type":"GrpcPaymentProcessor","method":"examplepay","unit":"sat","endpoint":"processor:8090","tls_dir":"/run/secrets/payment-processor"}]'
# For trusted local development only, set "allow_insecure":true instead of tls_dir.
# See docs/payment_backends.md for plugin packaging, multi-method gRPC examples,
# transport security, activation checks, and troubleshooting.

# NUT-19 Cached responses
# Enable these settings to cache responses in Redis
#
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ mv .env.example .env
vim .env
```

Mint operators can use built-in funding sources, install Python payment-method
plugins, or connect external CDK-compatible gRPC payment processors. See the
[payment backends guide](docs/payment_backends.md) for configuration, security,
multi-method examples, and troubleshooting.

To use the wallet with the [public test mint](#test-instance), you need to change the appropriate entries in the `.env` file.

#### Test instance
Expand Down
41 changes: 33 additions & 8 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ class MeltQuote(LedgerEvent):
expiry: Optional[int] = None
change: Optional[List[BlindedSignature]] = None
mint: Optional[str] = None
method_data: Dict[str, Any] = Field(default_factory=dict)

@classmethod
def from_row(cls, row: Row, change: Optional[List[BlindedSignature]] = None):
Expand Down Expand Up @@ -319,13 +320,18 @@ def from_row(cls, row: Row, change: Optional[List[BlindedSignature]] = None):
change=change,
expiry=expiry,
payment_preimage=payment_preimage,
method_data=(
json.loads(row["method_data"])
if "method_data" in row.keys() and row["method_data"]
else {}
),
)

@classmethod
def from_resp_wallet(cls, melt_quote_resp, mint: str):
return cls(
quote=melt_quote_resp.quote,
method=Method.bolt11.name,
method=melt_quote_resp.method,
request=melt_quote_resp.request,
checking_id="",
unit=melt_quote_resp.unit,
Expand All @@ -334,6 +340,7 @@ def from_resp_wallet(cls, melt_quote_resp, mint: str):
state=MeltQuoteState(melt_quote_resp.state),
mint=mint,
change=melt_quote_resp.change,
method_data=melt_quote_resp.model_extra or {},
)

@property
Expand All @@ -342,8 +349,12 @@ def identifier(self) -> str:
return self.quote

@property
def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.BOLT11_MELT_QUOTE
def kind(self) -> Union[JSONRPCSubscriptionKinds, str]:
kind = f"{self.method}_melt_quote"
try:
return JSONRPCSubscriptionKinds(kind)
except ValueError:
return kind

@property
def unpaid(self) -> bool:
Expand Down Expand Up @@ -405,6 +416,7 @@ class MintQuote(LedgerEvent):
amount_paid: Optional[int] = 0
amount_issued: Optional[int] = 0
updated_at: Optional[int] = Field(default_factory=lambda: int(time.time()))
method_data: Dict[str, Any] = Field(default_factory=dict)

def __init__(self, **data: Any):
if "state" in data and "state_val" not in data:
Expand Down Expand Up @@ -468,6 +480,11 @@ def from_row(cls, row: Row):
updated_at=updated_at
if updated_at is not None
else (issued_time or paid_time or created_time or int(time.time())),
method_data=(
json.loads(row["method_data"])
if "method_data" in row.keys() and row["method_data"]
else {}
),
)

@classmethod
Expand Down Expand Up @@ -510,7 +527,7 @@ def from_resp_wallet(

return cls(
quote=mint_quote_resp.quote,
method=Method.bolt11.name,
method=mint_quote_resp.method,
request=mint_quote_resp.request,
checking_id="",
unit=mint_quote_resp.unit,
Expand All @@ -525,6 +542,7 @@ def from_resp_wallet(
amount_paid=mint_quote_resp.amount_paid,
amount_issued=mint_quote_resp.amount_issued,
updated_at=mint_quote_resp.updated_at,
method_data=mint_quote_resp.model_extra or {},
)

@classmethod
Expand Down Expand Up @@ -578,8 +596,11 @@ def check_stale_and_from_resp_wallet(

@property
def state(self) -> MintQuoteState:
if self.state_val == MintQuoteState.pending:
return MintQuoteState.pending
# UNPAID and PENDING are explicit orchestration states. In particular,
# an observed partial payment must not implicitly make a non-reusable
# quote issuable merely because amount_paid is non-zero.
if self.state_val in {MintQuoteState.unpaid, MintQuoteState.pending}:
return self.state_val
if self.amount_paid is not None and self.amount_issued is not None:
if self.amount_paid > self.amount_issued:
return MintQuoteState.paid
Expand Down Expand Up @@ -610,8 +631,12 @@ def identifier(self) -> str:
return self.quote

@property
def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE
def kind(self) -> Union[JSONRPCSubscriptionKinds, str]:
kind = f"{self.method}_mint_quote"
try:
return JSONRPCSubscriptionKinds(kind)
except ValueError:
return kind

@property
def unpaid(self) -> bool:
Expand Down
2 changes: 2 additions & 0 deletions cashu/core/json_rpc/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class JSONRPCMethods(Enum):
class JSONRPCSubscriptionKinds(Enum):
BOLT11_MINT_QUOTE = "bolt11_mint_quote"
BOLT11_MELT_QUOTE = "bolt11_melt_quote"
BOLT12_MINT_QUOTE = "bolt12_mint_quote"
BOLT12_MELT_QUOTE = "bolt12_melt_quote"
PROOF_STATE = "proof_state"


Expand Down
11 changes: 8 additions & 3 deletions cashu/core/models/info.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict


class MintMethodBolt11OptionSetting(BaseModel):
model_config = ConfigDict(extra="forbid")

description: Optional[bool] = None


Expand All @@ -13,7 +15,9 @@ class MintMethodSetting(BaseModel):
method_name: Optional[str] = None
min_amount: Optional[int] = None
max_amount: Optional[int] = None
options: Optional[MintMethodBolt11OptionSetting] = None
# BOLT11 historically exposes an attribute-based options model. Keep this
# field open so other methods may publish their own typed option model.
options: Optional[Union[MintMethodBolt11OptionSetting, Dict[str, Any]]] = None


class MeltMethodSetting(BaseModel):
Expand All @@ -22,6 +26,7 @@ class MeltMethodSetting(BaseModel):
method_name: Optional[str] = None
min_amount: Optional[int] = None
max_amount: Optional[int] = None
options: Optional[Dict[str, Any]] = None


class MintInfoContact(BaseModel):
Expand Down
32 changes: 24 additions & 8 deletions cashu/core/models/melt_quote.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List, Optional, Union

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

from cashu.core.base import BlindedSignature, MeltQuote
from cashu.core.constants import MAX_PAYMENT_REQUEST_LEN, MAX_UNIT_LEN
Expand All @@ -15,12 +15,14 @@ class PostMeltRequestOptions(BaseModel):


class PostMeltQuoteRequest(BaseModel):
model_config = ConfigDict(extra="allow")

unit: str = Field(..., max_length=MAX_UNIT_LEN) # input unit
request: str = Field(
..., max_length=MAX_PAYMENT_REQUEST_LEN
) # output payment request
amount: Optional[int] = Field(default=None, gt=0)
options: Optional[PostMeltRequestOptions] = None
prefer_async: bool = False

@property
def is_mpp(self) -> bool:
Expand All @@ -38,20 +40,34 @@ def mpp_amount(self) -> int:


class PostMeltQuoteResponse(BaseModel):
model_config = ConfigDict(extra="allow")

quote: str # quote id
amount: int # input amount
unit: str # input unit
method: str # payment method
request: str # output payment request
fee_reserve: int # input fee reserve
fee_reserve: Optional[int] = None # input fee reserve
state: str # state of the quote
expiry: Optional[int] # expiry of the quote
expiry: Optional[int] = None # expiry of the quote
payment_preimage: Optional[str] = None # payment preimage
change: Union[List[BlindedSignature], None] = None # NUT-08 change

@classmethod
def from_melt_quote(cls, melt_quote: MeltQuote) -> "PostMeltQuoteResponse":
to_dict = melt_quote.model_dump()
# turn state into string
to_dict["state"] = melt_quote.state.value
return cls.model_validate(to_dict)
# Keep internal settlement identifiers and timestamps off the wire while
# allowing a payment method to add its own protocol fields.
response = {
"quote": melt_quote.quote,
"amount": melt_quote.amount,
"unit": melt_quote.unit,
"method": melt_quote.method,
"request": melt_quote.request,
"fee_reserve": melt_quote.fee_reserve,
"state": melt_quote.state.value,
"expiry": melt_quote.expiry,
"payment_preimage": melt_quote.payment_preimage,
"change": melt_quote.change,
}
response = {**melt_quote.method_data, **response}
return cls.model_validate(response)
43 changes: 33 additions & 10 deletions cashu/core/models/mint_quote.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated, List, Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

from cashu.core.base import MintQuote
from cashu.core.constants import (
Expand All @@ -13,8 +13,10 @@


class PostMintQuoteRequest(BaseModel):
model_config = ConfigDict(extra="allow")

unit: str = Field(..., max_length=MAX_UNIT_LEN) # output unit
amount: int = Field(..., gt=0) # output amount
amount: Optional[int] = Field(default=None, gt=0) # method-specific output amount
description: Optional[str] = Field(
default=None, max_length=MAX_INVOICE_DESC_LEN
) # invoice description
Expand All @@ -30,9 +32,11 @@ class PostMintQuoteCheckRequest(BaseModel):


class PostMintQuoteResponse(BaseModel):
model_config = ConfigDict(extra="allow")

quote: str # quote id
request: str # input payment request
amount: int # output amount
amount: Optional[int] = None # method-specific output amount
unit: str # output unit
method: str # payment method
amount_paid: Optional[int] = None
Expand All @@ -42,12 +46,31 @@ class PostMintQuoteResponse(BaseModel):
expiry: Optional[int] = None # expiry of the quote
pubkey: Optional[str] = None # NUT-20 quote lock pubkey

@model_validator(mode="after")
def validate_method_fields(self) -> "PostMintQuoteResponse":
if self.method == "bolt11" and self.amount is None:
raise ValueError("bolt11 mint quote responses require an amount")
return self

@classmethod
def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse":
to_dict = mint_quote.model_dump()
# turn state into string
to_dict["state"] = mint_quote.state.value
to_dict["amount_paid"] = mint_quote.amount_paid
to_dict["amount_issued"] = mint_quote.amount_issued
to_dict["updated_at"] = mint_quote.updated_at
return cls.model_validate(to_dict)
# Build the public wire object explicitly. MintQuote also contains internal
# fields (for example checking_id) which must never become response extras.
response = {
"quote": mint_quote.quote,
"request": mint_quote.request,
"amount": mint_quote.amount or None,
"unit": mint_quote.unit,
"method": mint_quote.method,
"amount_paid": mint_quote.amount_paid,
"amount_issued": mint_quote.amount_issued,
"updated_at": mint_quote.updated_at,
"state": mint_quote.state.value,
"expiry": mint_quote.expiry,
"pubkey": mint_quote.pubkey,
}
# Processor-defined fields must never replace the normative NUT-04
# envelope. Unknown, non-reserved fields remain available for custom
# payment methods.
response = {**mint_quote.method_data, **response}
return cls.model_validate(response)
8 changes: 7 additions & 1 deletion cashu/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import sys
from pathlib import Path
from typing import List, Optional
from typing import Any, Dict, List, Optional

from environs import Env # type: ignore
from pydantic import Field
Expand Down Expand Up @@ -112,6 +112,12 @@ class MintBackends(MintSettings):
mint_backend_bolt11_msat: str = Field(default="")
mint_backend_bolt11_usd: str = Field(default="")
mint_backend_bolt11_eur: str = Field(default="")
# Entry-point names in the ``cashu.payment_methods`` group. Plugins are never
# auto-loaded merely because they are installed.
mint_payment_method_plugins: List[str] = Field(default=[])
# Additive structured backend declarations for non-BOLT11 methods. Legacy
# BOLT11 variables above remain supported and authoritative.
mint_payment_backends: List[Dict[str, Any]] = Field(default=[])

mint_strike_key: Optional[str] = Field(default=None)

Expand Down
9 changes: 9 additions & 0 deletions cashu/lightning/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@ class InvoiceQuoteResponse(BaseModel):


class PaymentQuoteResponse(BaseModel):
model_config = {"extra": "allow"}

checking_id: str
amount: Amount
fee: Amount


class InvoiceResponse(BaseModel):
model_config = {"extra": "allow"}

ok: bool # True: invoice created, False: failed
checking_id: Optional[str] = None
payment_request: Optional[str] = None
Expand All @@ -46,6 +50,8 @@ def __str__(self):


class PaymentResponse(BaseModel):
model_config = {"extra": "allow"}

result: PaymentResult
checking_id: Optional[str] = None
fee: Optional[Amount] = None
Expand All @@ -70,10 +76,13 @@ def unknown(self) -> bool:


class PaymentStatus(BaseModel):
model_config = {"extra": "allow"}

result: PaymentResult
fee: Optional[Amount] = None
preimage: Optional[str] = None
error_message: Optional[str] = None
amount_paid: Optional[Amount] = None

@property
def pending(self) -> bool:
Expand Down
Loading
Loading