diff --git a/cashu/cashu.go b/cashu/cashu.go index 81120f4..0b15b3e 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -502,6 +502,7 @@ const ( AmountLimitExceeded CashuErrCode = 11006 DuplicateInputErrCode CashuErrCode = 11007 DuplicateOutputErrCode CashuErrCode = 11008 + AmountlessMismatchErrCode CashuErrCode = 11012 UnknownKeysetErrCode CashuErrCode = 12001 InactiveKeysetErrCode CashuErrCode = 12002 @@ -514,8 +515,7 @@ const ( LightningPaymentErrCode CashuErrCode = 20004 MeltQuotePendingErrCode CashuErrCode = 20005 MeltQuoteAlreadyPaidErrCode CashuErrCode = 20006 - - MeltQuoteErrCode CashuErrCode = 20009 + MeltQuoteErrCode CashuErrCode = 20010 ) var ( @@ -531,6 +531,8 @@ var ( MintQuoteAlreadyIssued = Error{Detail: "quote already issued", Code: MintQuoteAlreadyIssuedErrCode} MintingDisabled = Error{Detail: "minting is disabled", Code: MintingDisabledErrCode} MintAmountExceededErr = Error{Detail: "max amount for minting exceeded", Code: AmountLimitExceeded} + MeltAmountlessMismatchErr = Error{Detail: "invoice amount and amountless option do not match", Code: AmountlessMismatchErrCode} + InvoiceAmountMissingErr = Error{Detail: "amountless option not specified for amountless invoice request", Code: AmountlessMismatchErrCode} MintQuoteInvalidSigErr = Error{Detail: "Mint quote with pubkey but no valid signature provided.", Code: MintQuoteInvalidSigErrCode} OutputsOverQuoteAmountErr = Error{Detail: "sum of the output amounts is greater than quote amount", Code: StandardErrCode} ProofAlreadyUsedErr = Error{Detail: "proof already used", Code: ProofAlreadyUsedErrCode} diff --git a/cashu/nuts/nut05/nut05.go b/cashu/nuts/nut05/nut05.go index 9776518..edb8c39 100644 --- a/cashu/nuts/nut05/nut05.go +++ b/cashu/nuts/nut05/nut05.go @@ -44,13 +44,22 @@ func StringToState(state string) State { } type PostMeltQuoteBolt11Request struct { - Request string `json:"request"` - Unit string `json:"unit"` - Options map[string]MppOption `json:"options,omitempty"` + Request string `json:"request"` + Unit string `json:"unit"` + Options MeltOptions `json:"options,omitempty"` } -type MppOption struct { - AmountMsat uint64 `json:"amount"` +type MeltOptions struct { + MppOption *Amount `json:"mpp,omitempty"` + AmountlessOption *AmountMsat `json:"amountless,omitempty"` +} + +type Amount struct { + Amount uint64 `json:"amount"` +} + +type AmountMsat struct { + AmountMsat uint64 `json:"amount_msat"` } type PostMeltQuoteBolt11Response struct { diff --git a/cashu/nuts/nut06/nut06.go b/cashu/nuts/nut06/nut06.go index 65c4fb0..9894d28 100644 --- a/cashu/nuts/nut06/nut06.go +++ b/cashu/nuts/nut06/nut06.go @@ -69,10 +69,11 @@ type NutSetting struct { } type MethodSetting struct { - Method string `json:"method"` - Unit string `json:"unit"` - MinAmount uint64 `json:"min_amount,omitempty"` - MaxAmount uint64 `json:"max_amount,omitempty"` + Method string `json:"method"` + Unit string `json:"unit"` + MinAmount uint64 `json:"min_amount,omitempty"` + MaxAmount uint64 `json:"max_amount,omitempty"` + Amountless bool `json:"amountless,omitempty"` } type Supported struct { diff --git a/mint/lightning/fakebackend.go b/mint/lightning/fakebackend.go index 80e0b36..280b671 100644 --- a/mint/lightning/fakebackend.go +++ b/mint/lightning/fakebackend.go @@ -81,7 +81,12 @@ func (fb *FakeBackend) InvoiceStatus(hash string) (Invoice, error) { return fb.Invoices[invoiceIdx].ToInvoice(), nil } -func (fb *FakeBackend) SendPayment(ctx context.Context, request string, maxFee uint64) (PaymentStatus, error) { +func (fb *FakeBackend) SendPayment( + ctx context.Context, + request string, + amount uint64, + maxFee uint64, +) (PaymentStatus, error) { invoice, err := decodepay.Decodepay(request) if err != nil { return PaymentStatus{}, fmt.Errorf("error decoding invoice: %v", err) diff --git a/mint/lightning/lightning.go b/mint/lightning/lightning.go index 4ae2377..8f30a2d 100644 --- a/mint/lightning/lightning.go +++ b/mint/lightning/lightning.go @@ -7,7 +7,8 @@ type Client interface { ConnectionStatus() error CreateInvoice(amount uint64) (Invoice, error) InvoiceStatus(hash string) (Invoice, error) - SendPayment(ctx context.Context, request string, maxFee uint64) (PaymentStatus, error) + // amount used only for supporting amountless invoices in melt requests + SendPayment(ctx context.Context, request string, amount uint64, maxFee uint64) (PaymentStatus, error) PayPartialAmount(ctx context.Context, request string, amountMsat uint64, maxFee uint64) (PaymentStatus, error) OutgoingPaymentStatus(ctx context.Context, hash string) (PaymentStatus, error) FeeReserve(amount uint64) uint64 diff --git a/mint/lightning/lnd.go b/mint/lightning/lnd.go index 7a6b1a7..a30cfcb 100644 --- a/mint/lightning/lnd.go +++ b/mint/lightning/lnd.go @@ -12,6 +12,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/macaroons" + decodepay "github.com/nbd-wtf/ln-decodepay" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) @@ -112,12 +113,27 @@ func (lnd *LndClient) InvoiceStatus(hash string) (Invoice, error) { return invoice, nil } -func (lnd *LndClient) SendPayment(ctx context.Context, request string, maxFee uint64) (PaymentStatus, error) { +func (lnd *LndClient) SendPayment( + ctx context.Context, + request string, + amount uint64, + maxFee uint64, +) (PaymentStatus, error) { feeLimit := &lnrpc.FeeLimit{Limit: &lnrpc.FeeLimit_Fixed{Fixed: int64(maxFee)}} sendPaymentRequest := lnrpc.SendRequest{ PaymentRequest: request, FeeLimit: feeLimit, } + + bolt11, err := decodepay.Decodepay(request) + if err != nil { + return PaymentStatus{}, err + } + // if this is an amountless invoice, pay the amount specified + if bolt11.MSatoshi == 0 { + sendPaymentRequest.Amt = int64(amount) + } + sendPaymentResponse, err := lnd.grpcClient.SendPaymentSync(ctx, &sendPaymentRequest) if err != nil { // if context deadline is exceeded (1 min), mark payment as pending diff --git a/mint/mint.go b/mint/mint.go index b79a7f4..af15e18 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -571,6 +571,7 @@ func (m *Mint) RequestMeltQuote(meltQuoteRequest nut05.PostMeltQuoteBolt11Reques return storage.MeltQuote{}, cashu.BuildCashuError(errmsg, cashu.UnitErrCode) } + var quoteAmount uint64 // check invoice passed is valid request := meltQuoteRequest.Request bolt11, err := decodepay.Decodepay(request) @@ -579,10 +580,23 @@ func (m *Mint) RequestMeltQuote(meltQuoteRequest nut05.PostMeltQuoteBolt11Reques return storage.MeltQuote{}, cashu.BuildCashuError(errmsg, cashu.MeltQuoteErrCode) } if bolt11.MSatoshi == 0 { - return storage.MeltQuote{}, cashu.BuildCashuError("invoice has no amount", cashu.MeltQuoteErrCode) + // if amountless invoice, check amount specified in options + if meltQuoteRequest.Options.AmountlessOption == nil { + return storage.MeltQuote{}, cashu.InvoiceAmountMissingErr + } else { + amountless := meltQuoteRequest.Options.AmountlessOption + quoteAmount = amountless.AmountMsat / 1000 + } + } else { + quoteAmount = uint64(bolt11.MSatoshi) / 1000 + + // if amountless option passed, check that amounts matched + if meltQuoteRequest.Options.AmountlessOption != nil { + if meltQuoteRequest.Options.AmountlessOption.AmountMsat != uint64(bolt11.MSatoshi) { + return storage.MeltQuote{}, cashu.MeltAmountlessMismatchErr + } + } } - invoiceSatAmount := uint64(bolt11.MSatoshi) / 1000 - quoteAmount := invoiceSatAmount // check if a mint quote exists with the same invoice. _, err = m.db.GetMintQuoteByPaymentHash(bolt11.PaymentHash) @@ -594,31 +608,31 @@ func (m *Mint) RequestMeltQuote(meltQuoteRequest nut05.PostMeltQuoteBolt11Reques isMpp := false var amountMsat uint64 = 0 // check mpp option - if len(meltQuoteRequest.Options) > 0 { - mpp, ok := meltQuoteRequest.Options["mpp"] - if ok { - if m.mppEnabled { - // if this is an internal invoice, reject MPP request - if isInternal { - return storage.MeltQuote{}, - cashu.BuildCashuError("mpp for internal invoice is not allowed", cashu.MeltQuoteErrCode) - } + if meltQuoteRequest.Options.MppOption != nil { + if m.mppEnabled { + // if this is an internal invoice, reject MPP request + if isInternal { + return storage.MeltQuote{}, + cashu.BuildCashuError("mpp for internal invoice is not allowed", cashu.MeltQuoteErrCode) + } - // check mpp msat amount is less than invoice amount - if mpp.AmountMsat >= uint64(bolt11.MSatoshi) { - return storage.MeltQuote{}, - cashu.BuildCashuError("mpp amount is not less than amount in invoice", - cashu.MeltQuoteErrCode) - } - isMpp = true - amountMsat = mpp.AmountMsat - quoteAmount = amountMsat / 1000 - m.logInfof("got melt quote request to pay partial amount '%v' of invoice with amount '%v'", - quoteAmount, invoiceSatAmount) - } else { + // check mpp msat amount is less than invoice amount + mppAmount := meltQuoteRequest.Options.MppOption.Amount + if mppAmount >= uint64(bolt11.MSatoshi) { return storage.MeltQuote{}, - cashu.BuildCashuError("MPP is not supported", cashu.MeltQuoteErrCode) + cashu.BuildCashuError("mpp amount is not less than amount in invoice", cashu.MeltQuoteErrCode) + } + if mppAmount > 0 && bolt11.MSatoshi == 0 { + return storage.MeltQuote{}, cashu.BuildCashuError("invalid invoice for MPP option", cashu.MeltQuoteErrCode) } + isMpp = true + amountMsat = mppAmount + quoteAmount = amountMsat / 1000 + m.logInfof("got melt quote request to pay partial amount '%v' of invoice with amount '%v'", + quoteAmount, bolt11.MSatoshi/1000) + } else { + return storage.MeltQuote{}, + cashu.BuildCashuError("MPP is not supported", cashu.MeltQuoteErrCode) } } @@ -661,8 +675,8 @@ func (m *Mint) RequestMeltQuote(meltQuoteRequest nut05.PostMeltQuoteBolt11Reques AmountMsat: amountMsat, } - m.logInfof("got melt quote request for invoice of amount '%v'. Setting fee reserve to %v", - invoiceSatAmount, meltQuote.FeeReserve) + m.logInfof("got melt quote request for amount '%v'. Setting fee reserve to %v", + quoteAmount, meltQuote.FeeReserve) if err := m.db.SaveMeltQuote(meltQuote); err != nil { errmsg := fmt.Sprintf("error saving melt quote to db: %v", err) @@ -861,7 +875,12 @@ func (m *Mint) MeltTokens(ctx context.Context, meltTokensRequest nut05.PostMeltB ) } else { m.logInfof("attempting to pay invoice: %v", meltQuote.InvoiceRequest) - sendPaymentResponse, err = m.lightningClient.SendPayment(ctx, meltQuote.InvoiceRequest, meltQuote.Amount) + sendPaymentResponse, err = m.lightningClient.SendPayment( + ctx, + meltQuote.InvoiceRequest, + meltQuote.Amount, + meltQuote.FeeReserve, + ) } if err != nil { // if SendPayment failed do not return yet, an extra check will be done @@ -1625,10 +1644,11 @@ func (m *Mint) SetMintInfo(mintInfo MintInfo) { Nut05: nut06.NutSetting{ Methods: []nut06.MethodSetting{ { - Method: cashu.BOLT11_METHOD, - Unit: cashu.Sat.String(), - MinAmount: m.limits.MeltingSettings.MinAmount, - MaxAmount: m.limits.MeltingSettings.MaxAmount, + Method: cashu.BOLT11_METHOD, + Unit: cashu.Sat.String(), + MinAmount: m.limits.MeltingSettings.MinAmount, + MaxAmount: m.limits.MeltingSettings.MaxAmount, + Amountless: true, }, }, Disabled: false, diff --git a/mint/mint_integration_test.go b/mint/mint_integration_test.go index eb913df..6f89f98 100644 --- a/mint/mint_integration_test.go +++ b/mint/mint_integration_test.go @@ -537,9 +537,57 @@ func TestRequestMeltQuote(t *testing.T) { // trying to create another melt quote with same invoice should throw error _, err = testMint.RequestMeltQuote(meltQuoteRequest) if !errors.Is(err, cashu.MeltQuoteForRequestExists) { - //if !errors.Is(err, cashu.PaymentMethodNotSupportedErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.MeltQuoteForRequestExists, err) } + + // test invoice and amountless amounts match + invoice, err = node2.CreateInvoice(10000) + if err != nil { + t.Fatalf("error creating invoice: %v", err) + } + meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ + Request: invoice.PaymentRequest, + Unit: cashu.Sat.String(), + Options: nut05.MeltOptions{ + AmountlessOption: &nut05.AmountMsat{AmountMsat: 5000 * 1000}, + }, + } + // invoice amount and amountless option do not match so this should throw an error + _, err = testMint.RequestMeltQuote(meltQuoteRequest) + if !errors.Is(err, cashu.MeltAmountlessMismatchErr) { + t.Fatalf("expected error '%v' but got '%v' instead", cashu.MeltAmountlessMismatchErr, err) + } + + // test melt request for amountless invoice + invoice, err = node2.CreateInvoice(0) + if err != nil { + t.Fatalf("error creating invoice: %v", err) + } + + // test request with amountless invoice but amountless option not specified + meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{Request: invoice.PaymentRequest, Unit: cashu.Sat.String()} + _, err = testMint.RequestMeltQuote(meltQuoteRequest) + if !errors.Is(err, cashu.InvoiceAmountMissingErr) { + t.Fatalf("expected error '%v' but got '%v' instead", cashu.InvoiceAmountMissingErr, err) + } + + var amountlessOption uint64 = 3000 + meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ + Request: invoice.PaymentRequest, + Unit: cashu.Sat.String(), + Options: nut05.MeltOptions{ + AmountlessOption: &nut05.AmountMsat{AmountMsat: amountlessOption * 1000}, + }, + } + // test that the amountless amount passed matches the quote amount + meltQuote, err := testMint.RequestMeltQuote(meltQuoteRequest) + if err != nil { + t.Fatalf("got unexpected error in melt request: %v", err) + } + + if meltQuote.Amount != amountlessOption { + t.Fatalf("expected melt quote with amount '%v' but got '%v'", amountlessOption, meltQuote.Amount) + } } func TestMeltQuoteState(t *testing.T) { @@ -596,7 +644,6 @@ func TestMeltQuoteState(t *testing.T) { if meltQuote.Preimage != invoice.Preimage { t.Fatalf("expected quote preimage '%v' but got '%v' instead", invoice.Preimage, meltQuote.Preimage) } - } func TestMelt(t *testing.T) { @@ -803,6 +850,31 @@ func TestMelt(t *testing.T) { if err != nil { t.Fatalf("got unexpected error in mint: %v", err) } + + // test melt amountless invoice + invoice, err = node2.CreateInvoice(0) + if err != nil { + t.Fatalf("error creating invoice: %v", err) + } + meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ + Request: invoice.PaymentRequest, + Unit: cashu.Sat.String(), + Options: nut05.MeltOptions{ + AmountlessOption: &nut05.AmountMsat{AmountMsat: 5000 * 1000}, + }, + } + + // invoice amount and amountless option do not match so this should throw an error + meltQuote, _ = testMint.RequestMeltQuote(meltQuoteRequest) + proofs, err = testutils.GetValidProofsForAmount(mintAmount, testMint, node2) + if err != nil { + t.Fatalf("error generating valid proofs: %v", err) + } + meltTokensRequest = nut05.PostMeltBolt11Request{Quote: meltQuote.Id, Inputs: proofs} + melt, err = testMint.MeltTokens(ctx, meltTokensRequest) + if err != nil { + t.Fatalf("got unexpected error in melt of amountless invoice: %v", err) + } } func TestMPPMelt(t *testing.T) { @@ -846,7 +918,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest := nut05.PostMeltQuoteBolt11Request{ Request: paymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 6000 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 6000 * 1000}, + }, } meltQuote1, err := testMint.RequestMeltQuote(meltQuoteRequest) if err != nil { @@ -856,7 +930,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ Request: paymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 4000 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 4000 * 1000}, + }, } meltQuote2, err := testMppMint.RequestMeltQuote(meltQuoteRequest) if err != nil { @@ -911,7 +987,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ Request: noRoutePaymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 6000 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 6000 * 1000}, + }, } meltQuote1, err = testMint.RequestMeltQuote(meltQuoteRequest) if err != nil { @@ -921,7 +999,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ Request: noRoutePaymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 4000 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 4000 * 1000}, + }, } meltQuote2, err = testMppMint.RequestMeltQuote(meltQuoteRequest) if err != nil { @@ -967,7 +1047,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ Request: newInvoice.PaymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 10100 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 10100 * 1000}, + }, } meltQuote1, err = testMint.RequestMeltQuote(meltQuoteRequest) if err == nil { @@ -993,7 +1075,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ Request: hodlInvoice.PaymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 2000 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 2000 * 1000}, + }, } meltQuote, err := testMint.RequestMeltQuote(meltQuoteRequest) if err != nil { @@ -1044,7 +1128,9 @@ func TestMPPMelt(t *testing.T) { meltQuoteRequest = nut05.PostMeltQuoteBolt11Request{ Request: mintQuote.PaymentRequest, Unit: cashu.Sat.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: 6000 * 1000}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: 6000 * 1000}, + }, } meltQuote1, err = testMint.RequestMeltQuote(meltQuoteRequest) expectedErrMsg = "mpp for internal invoice is not allowed" diff --git a/testutils/utils.go b/testutils/utils.go index 131b783..7e7f6b8 100644 --- a/testutils/utils.go +++ b/testutils/utils.go @@ -179,7 +179,10 @@ func (lndContainer *LndBackend) PayInvoice(invoice string) error { func (lndContainer *LndBackend) CreateInvoice(amount uint64) (*Invoice, error) { ctx := context.Background() - invoice := lnrpc.Invoice{Value: int64(amount)} + invoice := lnrpc.Invoice{} + if amount > 0 { + invoice.Value = int64(amount) + } addInvoiceResponse, err := lndContainer.Client.AddInvoice(ctx, &invoice) if err != nil { return nil, err @@ -456,10 +459,12 @@ func (clnContainer *CLNBackend) PayInvoice(invoice string) error { func (clnContainer *CLNBackend) CreateInvoice(amount uint64) (*Invoice, error) { body := map[string]any{ - "amount_msat": amount * 1000, "label": time.Now().Unix(), "description": "test", } + if amount > 0 { + body["amount_msat"] = amount * 1000 + } resp, err := clnContainer.Post(clnContainer.url+"/invoice", body) if err != nil { diff --git a/wallet/wallet.go b/wallet/wallet.go index 7fec14c..e5c5a0c 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1063,7 +1063,9 @@ func (w *Wallet) MultiMintPayment(request string, split map[string]uint64) ([]nu meltRequest := nut05.PostMeltQuoteBolt11Request{ Request: invoice, Unit: w.unit.String(), - Options: map[string]nut05.MppOption{"mpp": {AmountMsat: amount}}, + Options: nut05.MeltOptions{ + MppOption: &nut05.Amount{Amount: amount}, + }, } meltQuoteResponse, err := client.PostMeltQuoteBolt11(mint, meltRequest) if err != nil {